APUE笔记:Network IPC: Sockets(十六)

本文记录《UNIX环境高级编程》第3版 第16章 Network IPC: Sockets 的一些知识点。


解决网络问题的一般步骤(粗看-细看-深入分析):

  1. netstat -an
  2. 抓包 tcpdump
  3. 使用 Wireshark 工具分析

套接字描述符

套接字是通信端点的抽象。正如使用文件描述符访问文件,应用程序用套接字描述符访问套接字。在UNIX系统中,套接字描述符是作为文件描述符来实现的。实际上,许多用于处理文件描述符的函数,如 readwrite,也可用于套接字描述符。

为创建一个套接字,调用 socket 函数。

1
2
3
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
// 返回值:若成功,返回文件(套接字)描述符;若出错,返回−1

domain 参数决定了通信的性质,包括地址格式。图16.1总结了POSIX.1所规定的域。这些常量以 AF_(address family)开头,因为每个域都有其自身的地址表示格式。

Domain Description
AF_INET IPv4 Internet domain
AF_INET6 IPv6 Internet domain (optional in POSIX.1)
AF_UNIX UNIX domain
AF_UNSPEC unspecified

大多数系统还定义了 AF_LOCAL 域,这是 AF_UNIX 的别名。AF_UNSPEC 可以代表“任何”域。

参数 type 确定套接字的类型,进一步确定通信特征。下表总结了由POSIX.1定义的套接字类型,但在实现中可以自由增加其他类型的支持。

Type Description
SOCK_DGRAM fixed-length, connectionless, unreliable messages
SOCK_RAW datagram interface to IP (optional in POSIX.1)
SOCK_SEQPACKET fixed-length, sequenced, reliable, connection-oriented messages
SOCK_STREAM sequenced, reliable, bidirectional, connection-oriented byte streams

参数 protocol 通常是 0,表示为给定的域和套接字类型选择默认协议。当对同一域和套接字类型支持多个协议时,可以使用 protocol 选择一个特定协议。在 AF_INET 通信域中,套接字类型 SOCK_STREAM 的默认协议是传输控制协议(Transmission Control Protocol,TCP)。在 AF_INET 通信域中,套接字类型 SOCK_DGRAM 的默认协议是 UDP。下表列出了为因特网域套接字定义的协议。

Protocol Description
IPPROTO_IP IPv4 Internet Protocol
IPPROTO_IPV6 IPv6 Internet Protocol (optional in POSIX.1)
IPPROTO_ICMP Internet Control Message Protocol
IPPROTO_RAW Raw IP packets protocol (optional in POSIX.1)
IPPROTO_TCP Transmission Control Protocol
IPPROTO_UDP User Datagram Protocol

调用 socket 与调用 open 相类似,均可获得用于I/O的文件描述符。当不再需要该文件描述符时,调用 close 来关闭对文件或套接字的访问,并且释放该描述符以便重新使用。

虽然套接字描述符本质上是一个文件描述符,但不是所有参数为文件描述符的函数都可以接受套接字描述符。下图总结了到目前为止所讨论的大多数以文件描述符为参数的函数使用套接字描述符时的行为。未指定和由实现定义的行为通常意味着该函数对套接字描述符无效。例如, lseek 不能以套接字描述符为参数,因为套接字不支持文件偏移量的概念。

How file descriptor functions act with sockets

套接字通信是双向的。可以采用 shutdown 函数来禁用一个套接字的I/O。

1
2
3
#include <sys/socket.h>
int shutdown(int sockfd, int how);
// 返回值:若成功,返回0;若出错,返回−1

如果 howSHUT_RD(关闭读端),那么无法从套接字读取数据。如果howSHUT_WR(关闭写端),那么无法使用套接字发送数据。如果howSHUT_RDWR,则既无法读取数据,又无法发送数据。

能够关闭(close)一个套接字,为何还使用shutdown呢?这里有若干理由。首先,只有最后一个活动引用关闭时,close才释放网络端点。这意味着如果复制一个套接字(如采用dup),要直到关闭了最后一个引用它的文件描述符才会释放这个套接字。而 shutdown 允许使一个套接字处于不活动状态,和引用它的文件描述符数目无关。其次,有时可以很方便地关闭套接字双向传输中的一个方向。例如,如果想让所通信的进程能够确定数据传输何时结束,可以关闭该套接字的写端,然而通过该套接字读端仍可以继续接收数据。

既然可以 close 套接字,那为什么还需要 shutdown 呢?原因有几个。首先,只有当最后一个活动引用被关闭时,close才会释放网络端点。如果复制了套接字(例如使用 dup),那么只有关闭最后一个引用它的文件描述符时,该套接字才会被释放。而 shutdown 函数允许独立于引用套接字的活动文件描述符的数量来停用套接字。其次,有时仅在一个方向上关闭套接字会很方便。例如,如果希望与之通信的进程能够知道我们何时完成数据传输,同时仍允许我们使用该套接字接收该进程发送给我们的数据,就可以关闭套接字的写功能。


16.2 寻址

在学习用套接字做一些有意义的事情之前,需要知道如何标识一个目标通信进程。进程标识由两部分组成。一部分是计算机的网络地址,它可以帮助标识网络上我们想与之通信的计算机;另一部分是该计算机上用端口号表示的服务,它可以帮助标识特定的进程。

字节序

大小端字节序存储方式可参考:https://www.cnblogs.com/Invinc-Z/p/18802570

下图总结了本文所讨论的4种平台的字节序。

image-20250331160914776

网络协议指定了字节序,因此异构计算机系统能够交换协议信息而不会被字节序所混淆。TCP/IP协议栈使用大端字节序

对于TCP/IP应用程序,有4个用来在处理器字节序和网络字节序之间实施转换的函数。

1
2
3
4
5
6
7
8
9
10
11
12
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostint32);
// 返回值:以网络字节序表示的32位整数

uint16_t htons(uint16_t hostint16);
// 返回值:以网络字节序表示的16位整数

uint32_t ntohl(uint32_t netint32);
// 返回值:以主机字节序表示的32位整数

uint16_t ntohs(uint16_t netint16);
// 返回值:以主机字节序表示的16位整数

h 表示“主机”字节序,n 表示“网络”字节序。l 表示“长”(即4字节)整数,s 表示“短”(即2字节)整数。


地址格式

一个地址标识一个特定通信域的套接字端点,地址格式与这个特定的通信域相关。为使不同格式地址能够传入到套接字函数,地址会被强制转换成一个通用的地址结构 sockaddr

1
2
3
4
5
struct sockaddr {
sa_family_t     sa_family;  /* address family */
char         sa_data[];  /* variable-length address */
...
};

套接字实现可以自由地添加额外的成员并且定义 sa_data 成员的大小。

因特网地址定义在 <netinet/in.h> 头文件中。在IPv4因特网域(AF_INET)中,套接字地址用结构 sockaddr_in 表示:

1
2
3
4
5
6
7
8
struct in_addr {
in_addr_t      s_ addr;   /* IPv4 address */
};
struct sockaddr_in {
sa_family_t     sin_family; /* address family */
in_port_t      sin_port;  /* port number */
struct in_addr sin_addr;     /* IPv4 address */
};

数据类型 in_port_t 定义成 uint16_t。数据类型 in_addr_t 定义成 uint32_t。这些整数类型在 <stdint.h> 中定义并指定了相应的位数。

AF_INET 域相比较,IPv6因特网域(AF_INET6)套接字地址用结构 sockaddr_in6 表示:

1
2
3
4
5
6
7
8
9
10
11
struct_in6_addr {
uint8_t       s6_addr[16];     /* IPv6 address */
};

struct sockaddr_in6 {
sa_family_t     sin6_family;     /* address family */
in_port_t      sin6_port;      /* port number */
uint32_t       sin6_flowinfo;    /* traffic class and flow info */
struct in6_addr   sin6_addr;      /* IPv6 address*/
uint32_t       sin6_scope_id;    /* set of interfaces for scope */
};

注意,尽管 sockaddr_insockaddr_in6 结构相差比较大,但它们均被强制转换成 sockaddr 结构输入到套接字例程中。

有时,需要打印出能被人理解而不是计算机所理解的地址格式。BSD 网络软件包含函数 inet_addrinet_ntoa,用于二进制地址格式与点分十进制字符表示(a.b.c.d)之间的相互转换。但是这些函数仅适用于IPv4地址。有两个新函数 inet_ntopinet_pton 具有相似的功能,而且同时支持IPv4地址和IPv6地址。

1
2
3
4
5
6
#include <arpa/inet.h>
const char *inet_ntop(int domain, const void *restrict addr, char *restrict str, socklen_t size);
// 返回值:若成功,返回地址字符串指针;若出错,返回NULL

int inet_pton(int domain, const char * restrict str, void *restrict addr);
// 返回值:若成功,返回1;若格式无效,返回0;若出错,返回−1

函数 inet_ntop 将网络字节序的二进制地址转换成文本字符串格式。inet_pton 将文本字符串格式转换成网络字节序的二进制地址。参数 domain 仅支持两个值:AF_INETAF_INET6

对于 inet_ntop,参数size指定了保存文本字符串的缓冲区(str)的大小。两个常数用于简化工作:INET_ADDRSTRLEN定义了足够大的空间来存放一个表示 IPv4 地址的文本字符串;INET6_ADDRSTRLEN 定义了足够大的空间来存放一个表示 IPv6 地址的文本字符串。对于 inet_pton,如果 domain是 AF_INET,则缓冲区addr 需要足够大的空间来存放一个32位地址,如果 domainAF_INET6,则需要足够大的空间来存放一个128位地址。


地址查询

通过调用 gethostent,可以找到给定计算机系统的主机信息。

1
2
3
4
5
6
#include <netdb.h>
struct hostent *gethostent(void);
// 返回值:若成功,返回指针;若出错,返回NULL

void sethostent(int stayopen);
void endhostent(void);

如果主机数据库文件没有打开,gethostent 会打开它。函数 gethostent 返回文件中的下一个条目。函数 sethostent 会打开文件,如果文件已经被打开,那么将其回绕。当 stayopen 参数设置成非0值时,调用 gethostent 之后,文件将依然是打开的。函数 endhostent 可以关闭文件。

gethostent 返回时,会得到一个指向 hostent 结构的指针,该结构可能包含一个静态的数据缓冲区,每次调用 gethostent,缓冲区都会被覆盖。hostent结构至少包含以下成员:

1
2
3
4
5
6
7
8
struct hostent{
char  *h_name;     /* name of host */
char  **h_aliases;   /* pointer to alternate host name array */
int   h_addrtype;   /* address type */
int    h_length;   /* length in bytes of address */
char  **h_addr_list;  /* pointer to array of network addresses */
...
};

返回的地址采用网络字节序。

能够采用一套相似的接口来获得网络名字和网络编号。

1
2
3
4
5
6
7
8
#include <netdb.h>
struct netent *getnetbyaddr (uint32_t net, int type);
struct netent *getnetbyname(const char *name);
struct netent *getnetent(void);
// 3个函数的返回值:若成功,返回指针;若出错,返回NULL

void setnetent(int stayopen);
void endnetent(void);

netent 结构至少包含以下字段:

1
2
3
4
5
6
7
struct netent {
char   *n_name;    /* network name */
char  **n_aliases;   /* alternate network name array pointer */
int    n_addrtype;  /* address type */
uint32_t n_net;     /* network number */
...
};

网络编号按照网络字节序返回。地址类型是地址族常量之一(如 AF_INET)。

可以用以下函数在协议名字和协议编号之间进行映射。

1
2
3
4
5
6
7
8
#include <netdb.h>
struct protoent *getprotobyname(const char *name);
struct protoent *getprotobynumber(int proto);
struct protoent *getprotoent(void);
// 3个函数的返回值:若成功,返回指针;若出错,返回NULL

void setprotoent(int stayopen);
void endprotoent(void);

POSIX.1定义的 protoent结构至少包含以下成员:

1
2
3
4
5
6
struct protoent {
char  *p_name;     /* protocol name */
char **p_ aliases;   /* pointer to altername protocol name array */
int   p_proto;     /* protocol number */
...
};

服务是由地址的端口号部分表示的。每个服务由一个唯一的众所周知的端口号来支持。可以使用函数 getservbyname 将一个服务名映射到一个端口号,使用函数getservbyport 将一个端口号映射到一个服务名,使用函数 getservent 顺序扫描服务数据库。

1
2
3
4
5
6
7
8
#include <netdb.h>
struct servent *getservbyname(const char *name, const char *proto);
struct servent *getserbyport(int port, const char *proto);
struct servent *getservent(void);
// 3个函数的返回值:若成功,返回指针,若出错,返回NULL

void setservent(int stayopen);
void endservent(void);

servent 结构至少包含以下成员:

1
2
3
4
5
6
7
struct servent{
char  *s_name;     /* service name */
char **s_aliases;    /* pointer to alternate service name array */
int   s_port;     /* port number */
char  *s_proto;     /* name of protocol */
...
};

POSIX.1定义了若干新的函数,允许一个应用程序将一个主机名和一个服务名映射到一个地址,或者反之。这些函数代替了较老的函数 gethostbynamegethostbyaddrgetaddrinfo 函数允许将一个主机名和一个服务名映射到一个地址。

1
2
3
4
5
6
7
#include <sys/socket.h>
#include <netdb.h>
int getaddrinfo(const char *restrict host, const char *restrict service,
const struct addrinfo *restrict hint, struct addrinfo **restrict res);
//返回值:若成功,返回0;若出错,返回非0错误码

void freeaddrinfo(struct addrinfo *ai);

需要提供主机名、服务名,或者两者都提供。如果仅仅提供一个名字,另外一个必须是一个空指针。主机名可以是一个节点名或点分格式的主机地址。

getaddrinfo 函数返回一个链表结构 addrinfo 。可以用 freeaddrinfo 来释放一个或多个这种结构,这取决于用 ai_next 字段链接起来的结构有多少。

addrinfo 结构的定义至少包含以下成员:

1
2
3
4
5
6
7
8
9
10
11
struct addrinfo {
int         ai_flags;     /* customize behavior */
int         ai_family;    /* address family */
int         ai_socktype;   /* socket type */
int         ai_protocol;   /* protocol */
socklen_t      ai_addrlen;    /* length in bytes of address */
struct sockaddr  *ai_addr;     /* address */
char        *ai_canonname;   /* canonical name of host */
struct addrinfo  *ai_next;     /* next in list */
...
};

可以提供一个可选的hint来选择符合特定条件的地址。hint是一个用于过滤地址的模板,包括 ai_familyai_flagsai_protocolai_socktype 字段。剩余的整数字段必须设置为0,指针字段必须为空。下图总结了 ai_flags 字段中的标志,可以用这些标志来自定义如何处理地址和名字。

image-20250422134802216

如果 getaddrinfo 失败,不能使用 perrorstrerror 来生成错误消息,而是要调用 gai_strerror 将返回的错误码转换成错误消息。

1
2
3
#include <netdb.h>
const char *gai_strerror(int error);
// 返回值:指向描述错误的字符串的指针

getnameinfo 函数将一个地址转换成一个主机名和一个服务名。

1
2
3
4
5
6
#include <sys/socket.h>
#include <netdb.h>
int getnameinfo(const struct sockaddr *restrict addr, socklen_t alen,
char *restrict host, socklen_t hostlen,
char *restrict service, socklen_t servlen, int flags);
// 返回值:若成功,返回0;若出错,返回非0值

套接字地址(addr)被翻译成一个主机名和一个服务名。如果host非空,则指向一个长度为hostlen字节的缓冲区用于存放返回的主机名。同样,如果service非空,则指向一个长度为servlen字节的缓冲区用于存放返回的主机名。

flags参数提供了一些控制翻译的方式。下图总结了支持的标志。

image-20250422135215156


将套接字与地址关联

将一个客户端的套接字关联上一个地址没有多少新意,可以让系统选一个默认的地址。然而,对于服务器,需要给一个接收客户端请求的服务器套接字关联上一个众所周知的地址。

使用 bind 函数来关联地址和套接字。

1
2
3
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t len);
// 返回值:若成功,返回0;若出错,返回−1

对于使用的地址有以下一些限制。

  • 在进程正在运行的计算机上,指定的地址必须有效;不能指定一个其他机器的地址。

  • 地址必须和创建套接字时的地址族所支持的格式相匹配。

  • 地址中的端口号必须不小于1024,除非该进程具有相应的特权(即超级用户)。

  • 一般只能将一个套接字端点绑定到一个给定地址上,尽管有些协议允许多重绑定。

对于因特网域,如果指定IP地址为 INADDR_ANY<netinet/in.h>中定义的),套接字端点可以被绑定到所有的系统网络接口上。这意味着可以接收这个系统所安装的任何一个网卡的数据包。在下一节中可以看到,如果调用 connect 或 listen,但没有将地址绑定到套接字上,系统会选一个地址绑定到套接字上。

可以调用 getsockname 函数来发现绑定到套接字上的地址。

1
2
3
#include <sys/socket.h>
int getsockname(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict alenp);
// 返回值:若成功,返回0;若出错,返回−1

调用 getsockname 之前,将 alenp 设置为一个指向整数的指针,该整数指定缓冲区sockaddr 的长度。返回时,该整数会被设置成返回地址的大小。如果地址和提供的缓冲区长度不匹配,地址会被自动截断而不报错。如果当前没有地址绑定到该套接字,则其结果是未定义的。

如果套接字已经和对等方连接,可以调用 getpeername 函数来找到对方的地址。

1
2
3
#include <sys/socket.h>
int getpeername(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict alenp);
// 返回值:若成功,返回0;若出错,返回−1

除了返回对等方的地址,函数 getpeernamegetsockname 一样。


16.3 建立连接

如果要处理一个面向连接的网络服务(SOCK_STREAMSOCK_SEQPACKET),那么在开始交换数据以前,需要在请求服务的进程套接字(客户端)和提供服务的进程套接字(服务器)之间建立一个连接。使用connect函数来建立连接。

1
2
3
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t len);
// 返回值:若成功,返回0;若出错,返回−1

在connect中指定的地址是我们想与之通信的服务器地址。如果sockfd没有绑定到一个地址,connect会给调用者绑定一个默认地址。

当尝试连接服务器时,出于一些原因,连接可能会失败。要想一个连接请求成功,要连接的计算机必须是开启的,并且正在运行,服务器必须绑定到一个想与之连接的地址上,并且服务器的等待连接队列要有足够的空间(后面会有更详细的介绍)。因此,应用程序必须能够处理connect返回的错误,这些错误可能是由一些瞬时条件引起的。

如果套接字描述符处于非阻塞模式,那么在连接不能马上建立时,connect将会返回−1并且将 errno 设置为特殊的错误码 EINPROGRESS。应用程序可以使用poll或者select来判断文件描述符何时可写。如果可写,连接完成。

connect函数还可以用于无连接的网络服务(SOCK_DGRAM)。这看起来有点矛盾,实际上却是一个不错的选择。如果用 SOCK_DGRAM 套接字调用connect,传送的报文的目标地址会设置成connect调用中所指定的地址,这样每次传送报文时就不需要再提供地址。另外,仅能接收来自指定地址的报文。

服务器调用 listen 函数来宣告它愿意接受连接请求。

1
2
3
#include <sys/socket.h>
int listen(int sockfd, int backlog);
// 返回值:若成功,返回0;若出错,返回−1

参数 backlog 提供了一个提示,提示系统该进程所要入队的未完成连接请求数量。其实际值由系统决定,但上限由 <sys/socket.h> 中的 SOMAXCONN 指定。

一旦队列满,系统就会拒绝多余的连接请求,所以 backlog 的值应该基于服务器期望负载和处理量来选择,其中处理量是指接受连接请求与启动服务的数量。

一旦服务器调用了 listen,所用的套接字就能接收连接请求。使用 accept 函数获得连接请求并建立连接。

1
2
3
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict len);
// 返回值:若成功,返回文件(套接字)描述符;若出错,返回−1

函数 accept 所返回的文件描述符是套接字描述符,该描述符连接到调用connect的客户端。这个新的套接字描述符和原始套接字(sockfd)具有相同的套接字类型和地址族。传给accept的原始套接字没有关联到这个连接,而是继续保持可用状态并接收其他连接请求。

如果不关心客户端标识,可以将参数 addrlen 设为NULL。否则,在调用accept之前,将 addr 参数设为足够大的缓冲区来存放地址,并且将 len 指向的整数设为这个缓冲区的字节大小。返回时,accept会在缓冲区填充客户端的地址,并且更新指向len的整数来反映该地址的大小。

如果没有连接请求在等待,accept会阻塞直到一个请求到来。如果 sockfd 处于非阻塞模式, accept会返回−1,并将 errno 设置为 EAGAINEWOULDBLOCK

如果服务器调用accept,并且当前没有连接请求,服务器会阻塞直到一个请求到来。另外,服务器可以使用poll或select来等待一个请求的到来。在这种情况下,一个带有等待连接请求的套接字会以可读的方式出现。


16.4 数据传输

既然一个套接字端点表示为一个文件描述符,那么只要建立连接,就可以使用read和write来通过套接字通信。回忆前面所讲,通过在 connect 函数里面设置默认对等地址,数据报套接字也可以被“连接”。在套接字描述符上使用read和write是非常有意义的,因为这意味着可以将套接字描述符传递给那些原先为处理本地文件而设计的函数。而且还可以安排将套接字描述符传递给子进程,而该子进程执行的程序并不了解套接字。

尽管可以通过read和write交换数据,但这就是这两个函数所能做的一切。如果想指定选项,从多个客户端接收数据包,或者发送带外数据,就需要使用6个为数据传递而设计的套接字函数中的一个。

3个函数用来发送数据,3个用于接收数据。首先,考查用于发送数据的函数。

最简单的是send,它和write很像,但是可以指定标志来改变处理传输数据的方式

1
2
3
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
// 返回值:若成功,返回发送的字节数;若出错,返回−1

类似write,使用send时套接字必须已经连接。参数buf和nbytes的含义与write中的一致。

然而,与write不同的是,send支持第4个参数flags。3个标志是由Single UNIX Specification定义的,但是具体系统实现支持其他标志的情况也是很常见的。下图总结了这些标志。

image-20250422163828440

即使send成功返回,也并不表示连接的另一端的进程就一定接收了数据。我们所能保证的只是当send成功返回时,数据已经被无错误地发送到网络驱动程序上。

对于支持报文边界的协议,如果尝试发送的单个报文的长度超过协议所支持的最大长度,那么send会失败,并将errno设为 EMSGSIZE。对于字节流协议,send会阻塞直到整个数据传输完成。函数 sendtosend 很类似。区别在于 sendto 可以在无连接的套接字上指定一个目标地址。

1
2
3
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t nbytes, int flags, const struct sockaddr *destaddr, socklen_t destlen);
// 返回值:若成功,返回发送的字节数;若出错,返回−1

对于面向连接的套接字,目标地址是被忽略的,因为连接中隐含了目标地址。对于无连接的套接字,除非先调用 connect 设置了目标地址,否则不能使用send。sendto 提供了发送报文的另一种方式。

通过套接字发送数据时,还有一个选择。可以调用带有 msghdr 结构的 sendmsg 来指定多重缓冲区传输数据,这和 writev 函数很相似。

1
2
3
#include <sys/socket.h>
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
// 返回值:若成功,返回发送的字节数;若出错,返回−1

POSIX.1定义了 msghdr 结构,它至少有以下成员:

1
2
3
4
5
6
7
8
9
10
struct msghdr {
void     *msg_name;     /* optional address */
socklen_t    msg_namelen;   /* address size in bytes */
struct iovec *msg_iov;     /* array of I/O buffers */
int       msg_iovlen;    /* number of elements in array */
void      *msg_control;   /* ancillary data */
socklen_t    msg_controllen;  /* number of ancillary bytes */
int       msg_flags;    /* flags for received message */
...
};

函数recv和read相似,但是recv可以指定标志来控制如何接收数据。

1
2
3
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);
// 返回值:返回数据的字节长度;若无可用数据或对等方已经按序结束,返回0;若出错,返回−1

下图总结了这些标志。仅有3个标志是Single UNIX Specification定义的。

image-20250422165601021

当指定 MSG_PEEK 标志时,可以查看下一个要读取的数据但不真正取走它。当再次调用read或其中一个recv函数时,会返回刚才查看的数据。

对于SOCK_STREAM套接字,接收的数据可以比预期的少。MSG_WAITALL 标志会阻止这种行为,直到所请求的数据全部返回,recv函数才会返回。对于 SOCK_DGRAMSOCK_SEQPACKET 套接字,MSG_WAITALL 标志没有改变什么行为,因为这些基于报文的套接字类型一次读取就返回整个报文。

如果发送者已经调用 shutdown 来结束传输,或者网络协议支持按默认的顺序关闭并且发送端已经关闭,那么当所有的数据接收完毕后,recv 会返回0。

如果有兴趣定位发送者,可以使用 recvfrom 来得到数据发送者的源地址。

1
2
3
4
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *restrict buf, size_t len, int flags,
struct sockaddr *restrict addr, socklen_t *restrict addrlen);
// 返回值:返回数据的字节长度;若无可用数据或对等方已经按序结束,返回0;若出错,返回−1

如果 addr 非空,它将包含数据发送者的套接字端点地址。当调用 recvfrom 时,需要设置 addrlen 参数指向一个整数,该整数包含addr所指向的套接字缓冲区的字节长度。返回时,该整数设为该地址的实际字节长度。

因为可以获得发送者的地址,recvfrom 通常用于无连接的套接字。否则,recvfrom 等同于 recv

为了将接收到的数据送入多个缓冲区,类似于 readv,或者想接收辅助数据,可以使用 recvmsg

1
2
3
#include <sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
// 返回值:返回数据的字节长度;若无可用数据或对等方已经按序结束,返回0;若出错,返回−1

recvmsgmsghdr 结构指定接收数据的输入缓冲区。可以设置参数 flags 来改变 recvmsg 的默认行为。返回时,msghdr 结构中的 msg_flags 字段被设为所接收数据的各种特征。(进入 recvmsgmsg_flags 被忽略。)recvmsg 中返回的各种可能值总结在下图中。

image-20250422170830876


16.5 套接字选项

套接字机制提供了两个套接字选项接口来控制套接字行为。一个接口用来设置选项,另一个接口可以查询选项的状态。可以获取或设置以下3种选项。

(1)通用选项,工作在所有套接字类型上。

(2)在套接字层次管理的选项,但是依赖于下层协议的支持。

(3)特定于某协议的选项,每个协议独有的。

Single UNIX Specification定义了套接字层的选项(上述选项中的前两个选项类型)。

可以使用setsockopt函数来设置套接字选项。

1
2
3
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int option, const void *val, socklen_t len);
// 返回值:若成功,返回0;若出错,返回−1

参数 level 标识了选项应用的协议。如果选项是通用的套接字层次选项,则 level 设置成 SOL_SOCKET。否则,level 设置成控制这个选项的协议编号。对于TCP选项,level是 IPPROTO_TCP,对于IP,level是 IPPROTO_IP。下图总结了Single UNIX Specification中定义的通用套接字层次选项。

image-20250422215038859

参数 val 根据选项的不同指向一个数据结构或者一个整数。一些选项是on/off开关。如果整数非0,则启用选项。如果整数为0,则禁止选项。参数 len 指定了 val 指向的对象的大小。

可以使用 getsockopt 函数来查看选项的当前值。

1
2
3
#include <sys/socket.h>
int getsockopt(int sockfd, int level, int option, void *restrict val, socklen_t *restrict lenp);
// 返回值:若成功,返回0;若出错,返回−1

参数 lenp 是一个指向整数的指针。在调用 getsockopt 之前,设置该整数为复制选项缓冲区的长度。如果选项的实际长度大于此值,则选项会被截断。如果实际长度正好小于此值,那么返回时将此值更新为实际长度。


16.6 带外数据

带外数据(out-of-band data)是一些通信协议所支持的可选功能,与普通数据相比,它允许更高优先级的数据传输。带外数据先行传输,即使传输队列已经有数据。TCP 支持带外数据,但是UDP不支持。套接字接口对带外数据的支持很大程度上受TCP带外数据具体实现的影响。

TCP将带外数据称为紧急数据(urgent data)。TCP仅支持一个字节的紧急数据,但是允许紧急数据在普通数据传递机制数据流之外传输。为了产生紧急数据,可以在3个send函数中的任何一个里指定 MSG_OOB 标志。如果带 MSG_OOB 标志发送的字节数超过一个时,最后一个字节将被视为紧急数据字节。

如果通过套接字安排了信号的产生,那么紧急数据被接收时,会发送 SIGURG 信号。在 fcntl 中使用 F_SETOWN 命令来设置一个套接字的所有权。如果 fcntl 中的第三个参数为正值,那么它指定的就是进程ID。如果为非-1的负值,那么它代表的就是进程组ID。因此,可以通过调用以下函数安排进程接收套接字的信号:

fcntl(sockfd, F_SETOWN, pid);

F_GETOWN 命令可以用来获得当前套接字所有权。对于 F_SETOWN 命令,负值代表进程组ID,正值代表进程ID。因此,调用

owner = fcntl(sockfd, F_GETOWN, 0);

将返回owner,如果owner为正值,则等于配置为接收套接字信号的进程的ID。如果owner为负值,其绝对值为接收套接字信号的进程组的ID。

TCP支持紧急标记(urgent mark)的概念,即在普通数据流中紧急数据所在的位置。如果采用套接字选项 SO_OOBINLINE,那么可以在普通数据中接收紧急数据。为帮助判断是否已经到达紧急标记,可以使用函数 sockatmark

1
2
3
#include <sys/socket.h>
int sockatmark(int sockfd);
// 返回值:若在标记处,返回1;若没在标记处,返回0;若出错,返回−1

当下一个要读取的字节在紧急标志处时,sockatmark 返回1。

当带外数据出现在套接字读取队列时,select函数会返回一个文件描述符并且有一个待处理的异常条件。可以在普通数据流上接收紧急数据,也可以在其中一个 recv 函数中采用 MSG_OOB 标志在其他队列数据之前接收紧急数据。TCP队列仅用一个字节的紧急数据。如果在接收当前的紧急数据字节之前又有新的紧急数据到来,那么已有的字节会被丢弃。


16.7 非阻塞和异步I/O

通常,recv 函数没有数据可用时会阻塞等待。同样地,当套接字输出队列没有足够空间来发送消息时,send 函数会阻塞。在套接字非阻塞模式下,行为会改变。在这种情况下,这些函数不会阻塞而是会失败,将 errno 设置为 EWOULDBLOCK 或者 EAGAIN 。当这种情况发生时,可以使用 pollselect 来判断能否接收或者传输数据。

Single UNIX Specification包含通用异步I/O机制的支持。套接字机制有其自己的处理异步I/O的方式,但是这在Single UNIX Specification中没有标准化。一些文献把经典的基于套接字的异步I/O机制称为“基于信号的I/O”,区别于Single UNIX Specification中的通用异步I/O机制。

在基于套接字的异步I/O中,当从套接字中读取数据时,或者当套接字写队列中空间变得可用时,可以安排要发送的信号SIGIO。启用异步I/O是一个两步骤的过程。

(1)建立套接字所有权,这样信号可以被传递到合适的进程。

(2)通知套接字当I/O操作不会阻塞时发信号。

可以使用3种方式来完成第一个步骤。

(1)在 fcntl 中使用 F_SETOWN 命令。

(2)在 ioctl 中使用 FIOSETOWN 命令。

(3)在 ioctl 中使用 SIOCSPGRP 命令。

要完成第二个步骤,有两个选择。

(1)在 fcntl 中使用 F_SETFL 命令并且启用文件标志 O_ASYNC

(2)在 ioctl 中使用 FIOASYNC 命令。

虽然有多种选项,但它们没有得到普遍支持。下图总结了本文讨论的平台支持这些选项的情况。

image-20250422223748184