9.1 套接字可选项和 I/O 缓冲大小

我们进行套接字编程时往往只关注数据通信,而忽略了套接字具有的不同特性。但是,理解这些特性并根据实际需要进行更改也很重要。

9.1.1 套接字多种可选项

之前的程序都是创建好套接字之后直接使用的,通过默认的套接字特性进行数据通信,下面列出了一些套接字可选项,套接字可选项是分层的:

  • SOL_SOCKET 层是套接字的通用可选项;
  • IPPROTO_IP 可选项是IP协议相关事项 ;
  • IPPROTO_TCP 层可选项是 TCP 协议的相关事项 ; | SOL_SOCKET 选项名 | 说明 | 数据类型 | | —- | —- | —- | | SO_DEBUG | 打开或关闭调试信息 | int | | SO_BROADCAST | 允许或禁止发送广播数据 | int | | SO_DONTROUTE | 打开或关闭路由查找功能 | int | | SO_ERROR | 获得套接字错误 | int | | SO_KEEPALIVE | 开启套接字保活机制 | int | | SO_REUSEADDR | 是否启用地址再分配,主要原理是操作关闭套接字的Time-wait时间等待的开启和关闭 | int | | SO_LINGER | 是否开启延时关闭,开启的情况下调用 close() 函数会被阻塞,同时可以设置延迟关闭的超时时间,如果到超时未发送完数据则直接复位套接口的虚电路(属于异常关闭),如果超时时间内发送完数据则正常关闭套接字回调 close()函数 | struct linger | | SO_TYPE | 获得套接字类型(这个只能获取,不能设置) | int | | SO_RCVBUF | 接收缓冲区大小 | int | | SO_SNDBUF | 发送缓冲区大小 | int | | SO_RCVLOWAT | 接收缓冲区下限 | int | | SO_SNDLOWAT | 发送缓冲区下限 | int | | SO_RCVTIMEO | 接收超时 | struct timeval | | SO_SNDTIMEO | 发送超时 | struct timeval |
IPPROTO_IP 选项名 说明 数据类型
IP_MULTICAST_TTL 生存时间(Time To Live),组播传送距离 int
IP_ADD_MEMBERSHIP 加入组播 int
IP_DROP_MEMBERSHIP 离开组播 int
IP_MULTICAST_IF 取默认接口或默认设置 int
IP_MULTICAST_LOOP 禁止组播数据回送 int
IP_HDRINCL 在数据包中包含IP首部 int
IP_OPTINOS IP首部选项 int
IPPROTO_TCP 选项名 说明 数据类型
TCP_KEEPALIVE TCP保活机制开启下,设置保活包空闲发送时间间隔 int
TCP_KEEPINTVL TCP保活机制开启下,设置保活包无响应情况下重发时间间隔 int
TCP_KEEPCNT TCP保活机制开启下,设置保活包无响应情况下重复发送次数 int
TCP_MAXSEG TCP最大数据段的大小 int
TCP_NODELAY 不使用Nagle算法 int

9.1.2 获取和设置socket选项

可选项的读取和设置通过以下两个函数来完成:

  1. #include <sys/socket.h>
  2. int getsockopt(int sock, int level, int optname,
  3. void *optval, socklen_t *optlen);
  4. /*
  5. 成功时返回 0 ,失败时返回 -1
  6. sock: 用于查看选项套接字文件描述符
  7. level: 要查看的可选项协议层
  8. optname: 要查看的可选项名
  9. optval: 保存查看结果的缓冲地址值
  10. optlen: 向第四个参数传递的缓冲大小。
  11. 调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数。
  12. */
  13. int setsockopt(int sock, int level, int optname,
  14. const void *optval, socklen_t optlen);
  15. /*
  16. 成功时返回 0 ,失败时返回 -1
  17. sock: 用于更改选项套接字文件描述符
  18. level: 要更改的可选项协议层
  19. optname: 要更改的可选项名
  20. optval: 保存更改结果的缓冲地址值
  21. optlen: 向第四个参数传递的缓冲大小。调用函数候,
  22. 该变量中保存通过第四个参数返回的可选项信息的字节数。
  23. */

下面的代码为 getsockopt 的使用方法。下面示例用协议层为 SOL_SOCKET ,SO_TYPE 为可选项的套接字类型(TCP 和 UDP )。

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>
  4. #include <sys/socket.h>
  5. void error_handling(char *message);
  6. int main(int argc, char *argv[]){
  7. int tcp_sock, udp_sock;
  8. int sock_type;
  9. socklen_t optlen;
  10. int state;
  11. optlen = sizeof(sock_type);
  12. tcp_sock = socket(PF_INET, SOCK_STREAM, 0);
  13. udp_sock = socket(PF_INET, SOCK_DGRAM, 0);
  14. printf("SOCK_STREAM: %d\n", SOCK_STREAM);
  15. printf("SOCK_DGRAM: %d\n", SOCK_DGRAM);
  16. state = getsockopt(tcp_sock, SOL_SOCKET, SO_TYPE,
  17. (void *)&sock_type, &optlen);
  18. if (state)
  19. error_handling("getsockopt() error");
  20. printf("Socket type one: %d \n", sock_type);
  21. state = getsockopt(udp_sock, SOL_SOCKET, SO_TYPE,
  22. (void *)&sock_type, &optlen);
  23. if (state)
  24. error_handling("getsockopt() error");
  25. printf("Socket type two: %d \n", sock_type);
  26. return 0;
  27. }
  28. void error_handling(char *message){
  29. fputs(message, stderr);
  30. fputc('\n', stderr);
  31. exit(1);
  32. }

编译运行:

  1. gcc sock_type.c -o sock_type
  2. ./sock_type

结果:

  1. SOCK_STREAM: 1
  2. SOCK_DGRAM: 2
  3. Socket type one: 1
  4. Socket type two: 2

首先创建了一个 TCP 套接字和一个 UDP 套接字。然后通过调用 getsockopt 函数来获得当前套接字的状态。

验证套接类型的 SO_TYPE 是只读可选项,因为套接字类型只能在创建时决定,以后不能再更改

9.1.3 设置IO缓冲大小

创建套接字的同时会生成 I/O 缓冲。关于 I/O 缓冲,可以去看第五章。

SO_RCVBUF 是输入缓冲大小相关可选项,SO_SNDBUF 是输出缓冲大小相关可选项。用这 2 个可选项既可以读取当前 I/O 大小,也可以进行更改。通过下列示例读取创建套接字时默认的 I/O 缓冲大小

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>
  4. #include <sys/socket.h>
  5. void error_handling(char *message);
  6. int main(int argc, char *argv[]){
  7. int sock;
  8. int snd_buf, rcv_buf, state;
  9. socklen_t len;
  10. sock = socket(PF_INET, SOCK_STREAM, 0);
  11. len = sizeof(snd_buf);
  12. state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void *)&snd_buf, &len);
  13. if (state)
  14. error_handling("getsockopt() error");
  15. len = sizeof(rcv_buf);
  16. state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void *)&rcv_buf, &len);
  17. if (state)
  18. error_handling("getsockopt() error");
  19. printf("Input buffer size: %d \n", rcv_buf);
  20. printf("Output buffer size: %d \n", snd_buf);
  21. return 0;
  22. }
  23. void error_handling(char *message){
  24. fputs(message, stderr);
  25. fputc('\n', stderr);
  26. exit(1);
  27. }

编译运行:

  1. gcc get_buf.c -o getbuf
  2. ./getbuf

运行结果:

  1. Input buffer size: 87380
  2. Output buffer size: 16384

可以看出本机的输入缓冲和输出缓冲大小。

下面的代码演示了,通过程序设置 I/O 缓冲区的大小:

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>
  4. #include <sys/socket.h>
  5. void error_handling(char *message);
  6. int main(int argc, char *argv[]){
  7. int sock;
  8. int snd_buf = 1024 * 3, rcv_buf = 1024 * 3;
  9. int state;
  10. socklen_t len;
  11. sock = socket(PF_INET, SOCK_STREAM, 0);
  12. len = sizeof(snd_buf);
  13. state = setsockopt(sock, SOL_SOCKET, SO_RCVBUF,
  14. (void *)&rcv_buf, sizeof(rcv_buf));
  15. if (state)
  16. error_handling("setsockopt() error");
  17. state = setsockopt(sock, SOL_SOCKET, SO_SNDBUF,
  18. (void *)&snd_buf, sizeof(snd_buf));
  19. if (state)
  20. error_handling("setsockopt() error");
  21. len = sizeof(snd_buf);
  22. state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void *)&snd_buf, &len);
  23. if (state)
  24. error_handling("getsockopt() error");
  25. len = sizeof(rcv_buf);
  26. state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void *)&rcv_buf, &len);
  27. if (state)
  28. error_handling("getsockopt() error");
  29. printf("Input buffer size: %d \n", rcv_buf);
  30. printf("Output buffer size: %d \n", snd_buf);
  31. return 0;
  32. }
  33. void error_handling(char *message){
  34. fputs(message, stderr);
  35. fputc('\n', stderr);
  36. exit(1);
  37. }

编译运行:

  1. gcc get_buf.c -o setbuf
  2. ./setbuf

结果:

  1. Input buffer size: 6144
  2. Output buffer size: 6144

输出结果和我们预想的不是很相同,缓冲大小的设置需谨慎处理,因此不会完全按照我们的要求进行

9.2 SO_REUSEADDR

9.2.1 发生地址分配错误(Binding Error)

在学习 SO_REUSEADDR 可选项之前,应该好好理解 Time-wait 状态。看以下代码的示例:

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <string.h>
  4. #include <unistd.h>
  5. #include <arpa/inet.h>
  6. #include <sys/socket.h>
  7. void error_handling(char *message);
  8. #define TRUE 1
  9. #define FALSE 0
  10. int main(int argc, char *argv[]){
  11. int serv_sock, clnt_sock;
  12. char message[30];
  13. int option, str_len;
  14. socklen_t optlen, clnt_adr_sz;
  15. struct sockaddr_in serv_adr, clnt_adr;
  16. if (argc != 2){
  17. printf("Usage : %s <port>\n", argv[0]);
  18. exit(1);
  19. }
  20. serv_sock = socket(PF_INET, SOCK_STREAM, 0);
  21. if (serv_sock == -1)
  22. error_handling("socket() error");
  23. /*
  24. optlen = sizeof(option);
  25. option = TRUE;
  26. setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR,
  27. (void *)&option, optlen);
  28. */
  29. memset(&serv_adr, 0, sizeof(serv_adr));
  30. serv_adr.sin_family = AF_INET;
  31. serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
  32. serv_adr.sin_port = htons(atoi(argv[1]));
  33. if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)))
  34. error_handling("bind() error");
  35. if (listen(serv_sock, 5) == -1)
  36. error_handling("listen error");
  37. clnt_adr_sz = sizeof(clnt_adr);
  38. clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz);
  39. while ((str_len = read(clnt_sock, message, sizeof(message))) != 0){
  40. write(clnt_sock, message, str_len);
  41. write(1, message, str_len);
  42. }
  43. close(clnt_sock);
  44. close(serv_sock);
  45. return 0;
  46. }
  47. void error_handling(char *message){
  48. fputs(message, stderr);
  49. fputc('\n', stderr);
  50. exit(1);
  51. }

这是一个回声服务器的服务端代码,可以配合第四章的 echo_client.c 使用,在这个代码中,客户端通知服务器终止程序。在客户端控制台输入 Q 可以结束程序,向服务器发送 FIN 消息并经过四次握手过程。当然,输入 CTRL+C 也会向服务器传递 FIN 信息。强制终止程序时,由操作系统关闭文件套接字,此过程相当于调用 close 函数,也会向服务器发送 FIN 消息。

这样看不到是什么特殊现象,考虑以下情况:

服务器端和客户端都已经建立连接的状态下,向服务器控制台输入 CTRL+C ,强制关闭服务端

如果用这种方式终止程序,如果用同一端口号再次运行服务端,就会输出「bind() error」消息,并且无法再次运行。但是在这种情况下,再过大约 3 分钟就可以重新运行服务端。

9.2.2 Time-wait 状态

观察以下过程:

C09 套接字的多种可选项 - 图1
假设图中主机 A 是服务器,因为是主机 A 向 B 发送 FIN 消息,故可想象成服务器端在控制台中输入 CTRL+C 。但是问题是,主动断开连接的一方在套接字经过四次握手后并没有立即消除,而是要经过一段时间的 Time-wait 状态。因此,若服务器端先断开连接,则无法立即重新运行。套接字处在 Time-wait 过程时,相应端口是正在使用的状态。因此,就像之前验证过的,bind 函数调用过程中会发生错误。

先断开连接的套接字必然会经过 Time-wait 过程,但是由于客户端套接字的端口是任意制定的,所以无需过多关注 Time-wait 状态。

9.2.3 地址再分配

Time-wait 状态看似重要,但是不一定讨人喜欢。如果系统发生故障紧急停止,这时需要尽快重启服务起以提供服务,但因处于 Time-wait 状态而必须等待几分钟。因此,Time-wait 并非只有优点,这些情况下容易引发大问题。下图中展示了四次握手时不得不延长 Time-wait 过程的情况。

C09 套接字的多种可选项 - 图2

从图上可以看出,在主机 A 四次握手的过程中,如果最后的数据丢失,则主机 B 会认为主机 A 未能收到自己发送的 FIN 信息,因此重传。这时,收到的 FIN 消息的主机 A 将重启 Time-wait 计时器。因此,如果网络状况不理想, Time-wait 将持续。

解决方案就是在套接字的可选项中更改 SO_REUSEADDR 的状态。适当调整该参数,可将 Time-wait 状态下的套接字端口号重新分配给新的套接字。SO_REUSEADDR 的默认值为 0.这就意味着无法分配 Time-wait 状态下的套接字端口号。因此需要将这个值改成 1 。具体作法已在示例 reuseadr_eserver.c给出,只需要把注释掉的东西接解除注释即可。

  1. optlen = sizeof(option);
  2. option = TRUE;
  3. setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void *)&option, optlen);

此时,已经解决了上述问题。

9.3 打开和关闭Nagle

9.3.1 Nagle 算法

为了防止因数据包过多而发生网络过载,Nagle 算法诞生了。它应用于 TCP 层。它是否使用会导致如图所示的差异:

C09 套接字的多种可选项 - 图3

图中展示了通过 Nagle 算法发送字符串 Nagle 和未使用 Nagle 算法的差别。可以得到一个结论。

只有接收到前一数据的 ACK 消息, **Nagle** 算法才发送下一数据。

TCP 套接字默认使用 Nagle 算法交换数据(现在「2022+」由于网络带宽大幅增大,已经默认关闭Nagle了!),因此最大限度的进行缓冲,直到收到 ACK 。左图也就是说一共传递 4 个数据包以传输一个字符串。从右图可以看出,发送数据包一共使用了 10 个数据包。由此可知,不使用 Nagle 算法将对网络流量产生负面影响。即使只传输一个字节的数据,其头信息都可能是几十个字节。因此,为了提高网络传输效率,必须使用 Nagle 算法。

Nagle 算法并不是什么情况下都适用,网络流量未受太大影响时,不使用 Nagle 算法要比使用它时传输速度快。最典型的就是「传输大文数据」。将文件数据传入输出缓冲不会花太多时间,因此,不使用 Nagle 算法,也会在装满输出缓冲时传输数据包。这不仅不会增加数据包的数量,反而在无需等待 ACK 的前提下连续传输,因此可以大大提高传输速度。

9.3.2 禁用 Nagle 算法

禁用 Nagle 算法应该使用:

  1. int opt_val = 1;
  2. setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, sizeof(opt_val));

通过 TCP_NODELAY 的值来查看Nagle 算法的设置状态。

  1. opt_len = sizeof(opt_val);
  2. getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, opt_len);

如果正在使用Nagle 算法,那么 opt_val 值为 0,如果禁用则为 1.

关于这个算法,可以参考这个回答:TCP连接中启用和禁用TCP_NODELAY有什么影响?

9.5 习题

  1. 下列关于 Time-wait 状态的说法错误的是?
    答:以下字体加粗的代表正确。
    1. Time-wait 状态只在服务器的套接字中发生
    2. 断开连接的四次握手过程中,先传输 FIN 消息的套接字将进入 Time-wait 状态。
    3. Time-wait 状态与断开连接的过程无关,而与请求连接过程中 SYN 消息的传输顺序有关
    4. Time-wait 状态通常并非必要,应尽可能通过更改套接字可选项来防止其发生
  2. TCP_NODELAY 可选项与 Nagle 算法有关,可通过它禁用 Nagle 算法。请问何时应考虑禁用 Nagle 算法?结合收发数据的特性给出说明。
    答:当网络流量未受太大影响时,不使用 Nagle 算法要比使用它时传输速度快,比如说在传输大文件时。