目录

网络IPC:套接字

套接字是通信端点的抽象。正如使用文件描述符访问文件,应用程序用套接字描述符访问套接字。

实际使用中,将套接字当作一套抽象层接口,业务中需处理不同的网络协议通信时,都能通过该接口进行操作。

下面介绍套接字学习的前置知识:字节序、地址。

同一台计算机上的进程通信时,一般不用考虑字节序。字节序是一个处理器架构特性,用于指示数据类型内部的字节如何排序

在大部分编程语言中,int32_t类型的整数大小为4字节,例如十六进制数0x12345678,在下列别分别存储为:

  1. 大端序(Big Endian) 数据的高位字节存储在低地址内存中,而低位字节存储在高地址内存中。
地址:    0x1000  0x1001  0x1002  0x1003
数据:     12       34      56       78
  1. 小端序(Little Endian) 数据的低位字节存储在低地址内存中,而高位字节存储在高地址内存中。
地址:    0x1000  0x1001  0x1002  0x1003
数据:     78       56      34       12
  1. 识别大小端 Linux系统中,使用lscpu命令识别。
(base) kwephispre11269:~ # lscpu | grep "Byte Order"
Byte Order:          Little Endian
  • 在大多数现代计算机体系结构中,小端字节序是更常见的格式,因为它与Intel x86架构和许多其他处理器兼容。因此,它是默认的字节序。
  • 网络通信通常使用大端字节序。
  1. 字节序转换函数
#include <netinet/in.h>
uint32_t ntohl (uint32_t __netlong);
// 返回值:网络字节序表示的32位整数
uint16_t ntohs (uint16_t __netshort);
// 返回值:网络字节序表示的16位整数
uint32_t htonl (uint32_t __hostlong);
// 返回值:主机字节序表示的32位整数
uint16_t htons (uint16_t __hostshort);
// 返回值:主机字节序表示的16位整数

h表示主机字节序,n表示网络字节序。

地址格式与特定的通信域相关,为使不同的格式地址能够传入到套接字函数,地址需要强制转换成一个通用的结构sockaddr

/* Structure describing a generic socket address.  */
struct sockaddr
  {
    sa_family_t    sa_family;
    char           sa_data[14];
  };

常用的IPv4和IPv6的格式如下:


/* Structure describing an Internet socket address.  */
struct sockaddr_in
  {
    sa_family_t sa_family;
    in_port_t sin_port;        /* Port number.  */
    struct in_addr sin_addr;   /* Internet address.  */

    /* Pad to size of `struct sockaddr'.  */
    unsigned char sin_zero[8];
  };

struct sockaddr_in6
  {
    sa_family_t sa_family;
    in_port_t sin6_port;        /* Transport layer port # */
    uint32_t sin6_flowinfo;     /* IPv6 flow information */
    struct in6_addr sin6_addr;  /* IPv6 address */
    uint32_t sin6_scope_id;     /* IPv6 scope-id */
  };

实际使用中,都会将上述的结构转换为通用的sockaddr,这是由于在框架库的设计中,sockaddr_*结构体的前部分是相同的,这个结构体中最为关键的成员sa_family,它将决定网络库如何解释sockaddr_*剩下的部分。

能够使用下面的函数,将地址转换为用户能够理解的格式。

#include <arpa/inet.h>

int inet_pton (int domain, const char *__restrict str, void *__restrict addr);
// 成功返回1;格式无效返回0;错误返回-1
const char *inet_ntop (int domain, const void *__restrict addr,
                       char *__restrict str, socklen_t size);
// 成功返回地址字符串指针;出错返回NULL

应用举例:

int main(int argc, char *argv[]) {
    struct in_addr addr = {};
    const char *ip_addr = "192.168.1.1";

    // 将字符串形式的 IP 地址转换为二进制格式
    (void)inet_pton(AF_INET, ip_addr, &addr);
    // 打印转换后的二进制 IP 地址
    printf("转换后的IP地址: 0x%x\n", addr.s_addr);

    char buf[INET_ADDRSTRLEN]; // INET_ADDRSTRLEN 是一个足够存放 IPv4 地址字符串的宏

    // 将二进制格式的 IP 地址转换为字符串形式
    const char *ip_readable = inet_ntop(AF_INET, &addr, buf, INET_ADDRSTRLEN);
    printf("IP地址: %s\n", ip_readable);
}

涉及到通过主机名或域名建立网络连接,基本会使用到下面的函数。

函数允许将一个主机名和一个服务名解析地址信息:

extern int getaddrinfo (const char *__restrict __name,
			const char *__restrict __service,
			const struct addrinfo *__restrict __req,
			struct addrinfo **__restrict __pai);
// 成功返回 0;失败返回非0 错误码
extern void freeaddrinfo (struct addrinfo *__ai);

调用 getaddrinfo 函数 可以单独提供主机名或服务名,若只提供一个名字,另外一个必须是空指针。它返回一个链表结构 addrinfo,能使用 freeaddrinfo 函数 来释放一个或多个这种结构(取决于 ai_next 字段链接的结构数量)。

req 参数结构指定了查询条件,如地址族、套接字类型;res 指向返回的地址信息链表。

函数将一个地址转换为一个主机名和一个服务名:

extern int getnameinfo (const struct sockaddr *__restrict __sa,
			socklen_t __salen, char *__restrict __host,
			socklen_t __hostlen, char *__restrict __serv,
			socklen_t __servlen, int __flags);

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

实例

void print_addr(struct sockaddr *addr, socklen_t len) {
    char host[NI_MAXHOST];
    char serv[NI_MAXSERV];
    if (getnameinfo(addr, len, host, NI_MAXHOST, serv, NI_MAXSERV, NI_NUMERICHOST | NI_NUMERICSERV) == 0) {
        printf("Address: %s:%s\n", host, serv);
    } else {
        perror("getnameinfo");
    }
}

int main() {
    const char *host = "www.xxx.com";
    const char *service = "80";

    struct addrinfo hints, *res, *p;
    int status;

    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    if ((status = getaddrinfo(host, service, &hints, &res)) != 0) {
        fprintf(stderr, "getaddrinfo error: %s\n", gai_strerror(status));
        exit(EXIT_FAILURE);
    }

    printf("Available addresses:\n");
    for (p = res; p != NULL; p = p->ai_next) {
        print_addr(p->ai_addr, p->ai_addrlen);
    }
}
#include <sys/socket.h>
int socket (int __domain, int __type, int __protocol);
// 成功返回描述符;失败返回-1
int shutdown (int __fd, int __how);
// 成功返回0;出错返回-1

参数domain(域)确定通信特性,包括地址格式,各个域都有自己表示地址的格式,表示各个域的常数都以AF_开头,指地址族(address family)。下面是一些常见的域:

描述
AF_INET IPv4因特网域
AF_INET6 IPv6因特网域
AF_UNIX UNIX域

参数type确定套接字的类型,进一步确定通信特征。

类型 描述 常用协议
SOCK_DGRAM 固定长度、无连接的、不可靠的报文传递 UDP
SOCK_RAW IP协议的数据报接口 IP\ICMP\ARP
SOCK_SEQPACKET 固定长度的、有序的、可靠的、面向连接的报文传递 SCTP
SOCK_STREAM 有序的、可靠的、双向的、面向连接的字节流 TCP

参数protocol通常是0,表示为给定的域和套接字类型选择默认的协议。当一对域和套接字类型组合支持多个协议时,可以使用protocol选择一个特定协议。

参数how将选择操作类型,SHUT_RDSHUT_WR分别是关闭读端与写端,它与close的差别在于:最后一个活动引用关闭时,close才释放网络端点;而shutdown允许使一个套接字处于不活动状态,与引用它的文件描述符无关。

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

int bind (int __fd, const struct sockaddr *__addr, socklen_t __len);
// 成功返回 0;失败返回 -1

对于使用的地址,有一些限制如下:

  • 在进程正在运行的计算机上,指定的地址必须有效;不能指定其他机器的地址
  • 地址必须和创建套接字时的地址族所支持的格式匹配
  • 地址中的端口号不小于1024,除非进程具有相应的特权(超级用户等)
  • 通常一个套接字端点只能绑定到一个给定地址上,尽管某些协议允许多重绑定

查找地址:

int getpeername (int __fd, struct sockaddr *__addr,
			socklen_t *__restrict __len);
int getsockname (int __fd, struct sockaddr *__addr,
			socklen_t *__restrict __len);
// 成功返回 0;失败返回 -1

能调用 getsockname 函数发现绑定到套接字上的地址,当没有地址绑定到套接字时,结果 addr 是未定义的。

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

如果要处理面向连接的网络服务(SOCK_STREAM 或 SOCK_SEQPACKET),那么开始交换数据以前,需要在客户端与服务端之间建立一个连接:

int connect (int __fd, const struct sockaddr *__addr, socklen_t __len);
// 成功返回 0;失败返回 -1

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

如果套接字描述符处于非阻塞模式,那么在连接无法立即建立时,connect 会返回-1 并且将 errno 设置为错误码 EINPROGRESS。程序可以使用 poll 或 select 来判断文件描述符何时可写。

同时,connect 函数也能用于无连接的网络服务(SOCK_DGRAM)。这样主要是为了设置传送报文的目标地址,后续每次传送报文时就无需提供地址。另外,仅能接收来自指定地址的报文。

服务器调用 listen 函数宣告它愿意接收连接请求:

int listen (int __fd, int __n);
// 成功返回 0;失败返回 -1

参数n 提示系统该进程所要入队的未完成连接请求数量。其实际值由系统决定,但上限由<sys/socket.h>中的 SOMAXCONN 指定。一旦队列满,系统就会拒绝多余的连接请求,所以 n值应该基于服务器期望负载和处理量来选择。

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

int accept (int __fd, struct sockaddr *__addr, socklen_t *__restrict __addr_len);
// 成功返回套接字描述符;失败返回 -1

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

若没有连接请求在等待,accept会阻塞到新的请求到来。如果 fd 处于非阻塞模式,accept 将返回-1 并将 errno 设置为 EAGAIN 或 EWOULDBLOCK。

其中发送数据、接收数据都分别有3个对应的函数,首先从发送数据的函数开始。

函数 send,他能指定标志来改变处理传输数据的方式:

ssize_t send (int __fd, const void *__buf, size_t __n, int __flags);
// 成功返回发送的字节数;失败返回 -1

使用 send 时,套接字必须已经连接。其中参数flag 可以提供一些额外的能力:如 MSG_DONTROUTE、MSG_DONTWAIT 等,可以在 <bit/socket.h> 中查看描述。

注意:即使 send 成功返回,也不表示另一端的进程一定接收到了数据,只能保证数据无错误地发送到网络驱动程序上。

函数 sendto 与 send 功能一致,区别在于在无连接的套接字上,需要指定一个目标地址。

ssize_t sendto (int __fd, const void *__buf, size_t __n,
		       int __flags, const struct sockaddr *__addr,
		       socklen_t __addr_len);
// 成功返回发送的字节数;失败返回 -1

函数 sendmsg 会更复杂一些,调用时带 msghdr 结构指定多重缓冲区缓冲数据。

ssize_t sendmsg (int __fd, const struct msghdr *__message, int __flags);
// 成功返回发送的字节数;失败返回 -1

该接口使用起来稍稍复杂,但能减少系统调用次数,适合性能要求高的场景。

实例

{
    struct msghdr msg = {};
    struct iovec iov[2];
    char buf1[] = "Hello, ";
    char buf2[] = "server!";
    iov[0].iov_base = buf1;
    iov[0].iov_len = sizeof(buf1);
    iov[1].iov_base = buf2;
    iov[1].iov_len = sizeof(buf2);
    msg.msg_iov = iov;
    msg.msg_iovlen = 2;

    if (sendmsg(sockfd, &msg, 0) == -1) {
        perror("sendmsg");
    }
}

函数 recv 能指定标志来控制如何接收数据:

ssize_t recv (int __fd, void *__buf, size_t __n, int __flags);
// 成功返回数据的字节长度,若无可用数据或对等方已按序结束,返回0;出错返回-1

当指定 MSG_PEEK 标志时,可以查看要读取的数据,而不取走它。当再次调用 read 或 recv 函数时,才会返回刚才查看的数据。

对于 SOCK_STREAM 套接字,接收的数据可以比预期少,使用标志 MSG_WAITALL 标志会阻止这种行为,直到所有请求的数据全部返回,recv 函数才会返回。

ssize_t recvfrom (int __fd, void *__restrict __buf, size_t __n,
			 int __flags, struct sockaddr *__addr,
			 socklen_t *__restrict __addr_len);

ssize_t recvmsg (int __fd, struct msghdr *__message, int __flags);
// 成功返回数据的字节长度,若无可用数据或对等方已按序结束,返回0;出错返回-1

函数 recvfrom、recvmsg 和发送方的函数对应。

其中能设置参数 flag 改变 recvmsg 的默认行为。返回时,msghdr 结构中的 msg_flags 字段被设为所接收数据的各种特种。

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

  • 通用选项,工作在所有套接字类型上
  • 在套接字层次,管理的选项,但依赖于下层协议的支持
  • 特定于某协议的选项,每个协议独有
int getsockopt (int __fd, int __level, int __optname,
		       void *__restrict __optval, socklen_t *__restrict __optlen);

int setsockopt (int __fd, int __level, int __optname,
		       const void *__optval, socklen_t __optlen);
// 成功返回0;失败返回-1

参数level 标识了选项应用的协议,如选项是通用的套接字层次则设置level 为SOL_SOCKET。否则设置成控制这个选项的协议编号,如:TCP选项对应IPPROTO_TCP,IP对应IPPROTO_IP

下面列举出常用的通用套接字层次选项,可在<bits/socket-constants.h>中查看:

选项 描述 参数类型
SO_RCVBUF 接收缓冲区字节长度 int
SO_SNDBUF 发送缓冲区字节长度 int
SO_SNDTIMEO 套接字发送调用的超时值 struct timeval

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

带外数据(out-of-band data)是一些通信协议所支持的可选功能,与普通数据相比,它允许更高优先级的数据传输:即使传输队列已经有数据,带外数据也能先行传输

某些 OSI 协议支持带外数据,但大多数应用层协议不支持,如:HTTP、FTP等。TCP 协议支持带外数据,并称之为紧急数据,但 UDP 不支持。

TCP 仅支持一个字节的紧急数据,但允许紧急数据在普通数据传递机制数据流之外传输。可以在 send 函数中指定 MSG_OOB 标志,则带该标志发送的字节数超过一个时,最后一个字节将被视为紧急数据字节。

为帮助判断是否已经到达紧急标记,可以使用函数 sockatmark:

int sockatmark (int __fd);
// 若在标记处,返回1,没在标记处,返回0;失败返回-1

应用场景

常用于快速通知重要事件的场景,如:数据中心灾难恢复计划中,带外数据可用于发送紧急关机、重启或切换到备份系统的指令,确保关键服务的连续性。

套接字在非阻塞模式下,行为会改变。这些函数不会阻塞而是会返回失败,同时将 errno 设置为 EAGAIN 或 EWOULDBLOCK。此时,能够使用 poll 或 select 来判断能够接收或者传输数据。

套接字机制有其自己的处理异步I/O的方式,但没有被标准化。

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

  • 建立套接字所有权,这样信号可以被传递到合适的进程(能用3种方式完成,如下)
    • 在 fcntl 中使用 F_SETOWN 命令
    • 在 ioctl 中使用 FIOSETOWN 命令
    • 在 ioctl 中使用 SIOCSPGRP 命令
  • 通知套接字当I/O操作不会阻塞时发信号(有两个选择完成,如下)
    • 在 fcntl 中使用 F_SETFL 命令并启用文件标志 O_ASYNC
    • 在 ioctl 中使用 FIOASYNC 命令

这里简单描述一下,同步/异步,阻塞/非阻塞的区别:

首先,同步与阻塞它们是对I/O不同维度的描述,阻塞/非阻塞关注调用是否阻塞当前的线程;同步/异步关注I/O操作是否完成。

同步,在当前线程中直接执行I/O操作;异步,I/O操作在后台运行(由操作系统内核或其他线程处理),当前线程无需等待,操作完成后通过回调或通知机制告知结果。

阻塞,调用者会一直等待I/O操作完成才返回,期间无法执行其他任务;非阻塞,调用会立即返回,即使I/O操作尚未完成,通常需要轮询或者其他方式检查操作状态。