TCP/IP网络编程之套接字的多种可选项
套接字可选项进而I/O缓冲大小
我们进行套接字编程时往往只关注数据通信,而忽略了套接字具有的不同特性。但是,理解这些特性并根据实际需要进行更改也十分重要。之前我们写的程序在创建好套接字后都是未经特别操作就直接使用,此时通过默认的套接字特性进行数据通信。之前的示例比较简单,无需特别操作套接字特性,但有时的确需要更改,表1-1列出一部分套接字可选项
协议层 | 选项名 | 说明 | 读取 | 设置 |
SOL_SOCKET | SO_SNDBUF | 发送缓冲区大小 | O | O |
SO_RCVBUF | 接收缓冲区大小 | O | O | |
SO_REUSEADDR | 是否启用地址再分配,主要原理是操作关闭套接字的Time-wait时间等待的开启和关闭 | O | O | |
SO_KEEPALIVE | 开启套接字保活机制 | O | O | |
SO_BROADCAST | 允许或禁止发送广播数据 | O | O | |
SO_DONTROUTE | 打开或关闭路由查找功能 | O | O | |
SO_OOBINLINE | 该数据字节并不放入套接字接收缓冲区,而是被放入该连接的一个独立的单字节带外缓冲区 | O | O | |
SO_ERROR | 获得套接字错误 | O | X | |
SO_TYPE | 获得套接字类型(这个只能获取,不能设置) | O | X | |
IPPROTO_IP | IP_TOS | 设定该字段的值,以区分不同服务的优先级 | O | O |
IP_TTL | 设置主机发送数据包的生存时间 | O | O | |
IP_MULTICAST_TTL | 生存时间(Time To Live),组播传送距离 | O | O | |
IP_MULTICAST_LOOP | 禁止组播数据回送 | O | O | |
IP_MULTICAST_IF | 取默认接口或默认设置 | O | O | |
IPPROTO_TCP | TCP_KEEPALIVE | TCP保活机制开启下,设置保活包空闲发送时间间隔 | O | O |
TCP_NODELAY | 不使用Nagle算法 | O | O | |
TCP_MAXSEG | TCP最大数据段的大小 | O | O |
从表1-1可以看出,套接字可选项是分层的。IPPROTO_IP层可选项是IP协议相关事项,IPPROTO_TCP层可选项是TCP协议相关的事项,SOL_SOCKET层是套接字相关的通用可选项。
getsockopt和setsockopt
我们几乎可以针对表1-1中的所有可选项进行读取(Get)和设置(Set),可选项的读取和设置通过如下两个函数完成:
#include <sys/socket.h> int getsockopt(int sock, int level, int optname, void *optval, socklen_t *optlen);//成功时返回0,失败时返回-1
- sock:用于查看选项套接字文件描述符
- level:要查看的可选项的协议层
- optname:要查看的可选项名
- optval:保存查看结果的缓冲地址值
- optlen:向第四个参数optval传递的缓冲大小,调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数
上述函数用于读取套接字可选项,并不难,接下来介绍更改可选项时调用的函数
#include <sys/socket.h> int setsockopt(int sock, int level, int optname, const void *optval, socklen_t optlen);//成功时返回0,失败时返回-1
- sock:用于更改可选项的套接字文件描述符
- level:要更改的可选项的协议层
- optname:要更改的可选项名
- optval:保存要更改的选项信息的缓冲地址值
- optlen:向第四个参数optval传递的可选项信息的字节数
接下来介绍这些函数的调用方法,我们先介绍getsockopt函数的调用方法,setsockopt函数的调用方法将在其他的示例中给出。下面示例用协议层为SOL_SOCKET、名为SO_TYPE的可选项查看套接字类型(TCP或UDP)
sock_type.c
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/socket.h> void error_handling(char *message); int main(int argc, char *argv[]) { int tcp_sock, udp_sock; int sock_type; socklen_t optlen; int state; optlen = sizeof(sock_type); tcp_sock = socket(PF_INET, SOCK_STREAM, 0); udp_sock = socket(PF_INET, SOCK_DGRAM, 0); printf("SOCK_STREAM: %d \n", SOCK_STREAM); printf("SOCK_DGRAM: %d \n", SOCK_DGRAM); state = getsockopt(tcp_sock, SOL_SOCKET, SO_TYPE, (void *)&sock_type, &optlen); if (state) error_handling("getsockopt() error!"); printf("Socket type one: %d \n", sock_type); state = getsockopt(udp_sock, SOL_SOCKET, SO_TYPE, (void *)&sock_type, &optlen); if (state) error_handling("getsockopt() error!"); printf("Socket type two: %d \n", sock_type); return 0; } void error_handling(char *message) { fputs(message, stderr); fputc('\n', stderr); exit(1); }
- 第15、16行:分别生成TCP、UDP套接字
- 第17、18行:输出创建TCP、UDP套接字时传入的SOCK_STREAM、SOCK_DGRAM
- 第20、25行:获取套接字类型信息,如果是TCP套接字,将获得SOCK_STREAM常数值1;如果是UDP套接字,则获得SOCK_DGRAM的常数值2
编译sock_type.c并运行
# gcc sock_type.c -o sock_type # ./sock_type SOCK_STREAM: 1 SOCK_DGRAM: 2 Socket type one: 1 Socket type two: 2
上述示例给出了调用getsockopt函数查看套接字信息的方法,另外,用于验证套接字类型的SO_TYPE是典型的只读可选项,即套接字类型只能在创建时决定,以后不能再更改
SO_SNDBUF和SO_RCVBUF
前面介绍过,创建套接字将同时生成I/O缓冲,SO_RCVBUF是输入缓冲大小相关可选项,SO_SNDBUF是输出缓冲大小相关可选项。用这两个可选项可以读取和修改当前I/O缓冲大小。通过下面的示例读取创建套接字时默认的I/O缓冲大大小
get_buf.c
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/socket.h> void error_handling(char *message); int main(int argc, char *argv[]) { int sock; int snd_buf, rcv_buf, state; socklen_t len; sock = socket(PF_INET, SOCK_STREAM, 0); len = sizeof(snd_buf); state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void *)&snd_buf, &len); if (state) error_handling("getsockopt() error"); len = sizeof(rcv_buf); state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void *)&rcv_buf, &len); if (state) error_handling("getsockopt() error"); printf("Input buffer size: %d \n", rcv_buf); printf("Outupt buffer size: %d \n", snd_buf); return 0; } void error_handling(char *message) { fputs(message, stderr); fputc('\n', stderr); exit(1); }
编译get_buf.c并运行
# gcc get_buf.c -o get_buf # ./get_buf Input buffer size: 87380 Outupt buffer size: 16384
这是我系统的运行结果,不同系统可能默认的输入缓冲和输出缓冲有所差异,接下来,我们通过程序修改I/O缓冲大小
set_buf.c
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/socket.h> void error_handling(char *message); int main(int argc, char *argv[]) { int sock; int snd_buf = 1024 * 3, rcv_buf = 1024 * 3; int state; socklen_t len; sock = socket(PF_INET, SOCK_STREAM, 0); state = setsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void *)&rcv_buf, sizeof(rcv_buf)); if (state) error_handling("setsockopt() error!"); state = setsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void *)&snd_buf, sizeof(snd_buf)); if (state) error_handling("setsockopt() error!"); len = sizeof(snd_buf); state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void *)&snd_buf, &len); if (state) error_handling("getsockopt() error!"); len = sizeof(rcv_buf); state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void *)&rcv_buf, &len); if (state) error_handling("getsockopt() error!"); printf("Input buffer size: %d \n", rcv_buf); printf("Output buffer size: %d \n", snd_buf); return 0; } void error_handling(char *message) { fputs(message, stderr); fputc('\n', stderr); exit(1); }
- 第15、19行:I/O缓冲大小更改为3M字节
- 第24、29行:为了验证I/O缓冲的更改,读取缓冲大小
编译set_buf.c并运行
# gcc set_buf.c -o set_buf # ./set_buf Input buffer size: 6144 Output buffer size: 6144
输出结果和我们预想的完全不同,但也算合理,缓冲大小的设置需谨慎,因此不会完全按照我们的要求进行,只是通过setsockopt函数向系统传递我们的要求。如果把输出缓冲设置为0并如实反映这种设置,TCP协议将如何进行?如果要实现流控制和错误发生时的重传机制,至少要有一些缓冲空间吧?上述示例虽没有完全按照我们的要求设置缓冲大小,但也大致反映出可以通过setsockopt函数设置缓冲大小
SO_REUSEADDR
学习SO_REUSEADDR之前,应先理解好Time-wait状态,我们看完下面的示例在了解后面的内容
reuseadr_eserver.c
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define TRUE 1 #define FALSE 0 void error_handling(char *message); int main(int argc, char *argv[]) { int serv_sock, clnt_sock; char message[30]; int option, str_len; socklen_t optlen, clnt_adr_sz; struct sockaddr_in serv_adr, clnt_adr; if (argc != 2) { printf("Usage : %s <port>\n", argv[0]); exit(1); } serv_sock = socket(PF_INET, SOCK_STREAM, 0); if (serv_sock == -1) error_handling("socket() error"); /* optlen=sizeof(option); option=TRUE; setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, &option, optlen); */ memset(&serv_adr, 0, sizeof(serv_adr)); serv_adr.sin_family = AF_INET; serv_adr.sin_addr.s_addr = htonl(INADDR_ANY); serv_adr.sin_port = htons(atoi(argv[1])); if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr))) error_handling("bind() error "); if (listen(serv_sock, 5) == -1) error_handling("listen error"); clnt_adr_sz = sizeof(clnt_adr); clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz); while ((str_len = read(clnt_sock, message, sizeof(message))) != 0) { write(clnt_sock, message, str_len); write(1, message, str_len); } close(clnt_sock); return 0; } void error_handling(char *message) { fputs(message, stderr); fputc('\n', stderr); exit(1); }
此示例是之前已实现多次的回声服务端,可以结合TCP/IP网络编程之基于TCP的服务端/客户端(一)这一章中的回声客户端运行。下面运行该示例,第30到32行应保持注释状态,可通过在客户端控制台输入Q或CTRL+C终止程序。也就是说,让客户端先通知服务端终止程序,在客户端控制台输入Q消息时调用close函数,向服务端发送FIN消息并经过四次握手过程。当然,输入CTRL+C时也会向服务端发送FIN消息。强制终止程序时,由操作系统关闭文件及套接字,此过程相当于调用close函数,也会向服务端发送FIN消息
服务端和客户端在已建立连接的状态下,向服务端控制台输入CTRL+C,即强制关闭服务端,这主要模拟了服务端向客户端发送FIN消息。但如果以这种方式终止程序,那么服务端重新运行将产生问题,如果用同一端口号重新运行服务端,将输出"bind() error"消息,并无法再次运行,需要等到两三分钟后才可重新运行服务端
上述两种终止运行的方式唯一的区别在于是谁先输出FIN消息,但结果迥然不同,原因何在呢?
Time-wait状态
这里需要对四次握手有很好的理解,如果还有疑问请看TCP/IP网络编程之基于TCP的服务端/客户端(二)这一章
图1-1 Time-wait状态下的套接字
图1-1中主机A是服务端,因为主机A向主机B发送FIN消息,故可以想象服务端在控制台输入CTRL+C。但问题是,套接字经过四次握手过程后并非立即消除,而是要经过一段时间的Time-wait状态。当然,只有先断开连接(即先发送FIN消息)的主机才经过Time-wait状态。因此,若服务端先断开连接,则无法立即重新运行。套接字处在Time-wait过程时,相应端口是正在使用的状态
刚才说过,先断开连接的主机的套接字,都会经过一段时间的Time-wait状态,因此,客户端或者服务端都有可能经历Time-wait状态,要看是谁先断开连接。但是客户端的套接字即便处在Time-wait状态也不要紧,因为客户端套接字的端口号是任意指定的,与服务端不同,客户端每次运行程序都动态分配端口号,因此无需太在意客户端的Time-wait状态
那么到底为什么会有Time-wait状态呢?图1-1中假设主机A向主机B传输ACK消息(SEQ 5001、ACK 7502)后立即消除套接字,但最后这条ACK消息在传递途中丢失,未能传给主机B。这时会发生什么?主机B会认为之前自己发送的FIN消息(SEQ 7501、ACK 5001)未能抵达主机A,继而试图重传。但此时主机A已是完全终止的状态,主机B永远无法收到从主机A最后传来的ACK消息。相反,若主机A的套接字处于Time-wait状态,则会向主机B重传最后的ACK消息,主机B也可以正常终止。基于这些考虑,先传输FIN消息的主机应经过Time-wait过程
地址再分配
Time-wait看似很重要,但不一定讨人喜欢,考虑一下系统发生故障从而紧急停止的情况,这时候需要尽快重启服务端以提供服务,但因Time-wait状态而必须等待几分钟。因此,Time-wait并非只有优点,而且有些情况下可能引起更大的问题。图1-2演示了四次握手不得不延长Time-wait过程的情况
图1-2 重启Time-wait计时器
图1-2所示,在主机A的四次握手过程中,如果最后数据丢失,主机B会认为主机A未能收到自己发送的FIN消息,因此重传。此时,收到FIN消息的主机A将重启Time-wait计时器。因此,如果网络状况不理想,Time-wait状态将持续
解决方案就是在套接字的可选项中更改SO_REUSEADDR的状态,适当调整该参数,可将Time-wait状态下的套接字端口重新分配给新的套接字。SO_REUSEADDR的默认值为0(假),这就意味着无法分配Time-wait状态下的套接字端口号,因此需要将这个值改成1(真)。具体做法已在示例reuseadr_eserver.c中给出,就是那段被注释的代码
optlen=sizeof(option); option=TRUE; setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, &option, optlen);
TCP_NODELAY
Nagle算法是为防止因数据包过多而发生网络过载,该算法应用于TCP层,非常简单,其使用与否会导致图1-3所示的差异
图1-3 Nagle算法
图1-3展示了通过Nagle算法发送字符串“Nagle”和未使用Nagle算法的差别,可以得到一条结论:只有收到前一条数据的ACK消息时,Nagle算法才发送下一条数据。TCP套接字默认使用Nagle算法交换数据,因此最大限度地进行缓冲,直到收到ACK消息。图1-3左侧正是这种情况,为了发送“Nagle”字符串,将其传递到输出缓冲,这时头字符“N”之前没有其他数据(没有需接收的ACK),因此立即传输。之后开始等待字符“N”的ACK消息,等待过程中,剩下的“agle”填入输出缓冲。接下来,收到字符“N”的ACK消息后,将输出缓冲的“agle”装入一个数据包发送。也就是说,共需传递四个数据包以输出一个字符串
接下来分析未使用Nagle算法时发送字符串“Nagle”的过程,假设字符“N”到“e”依序传输到输出缓冲,此时的发送过程与ACK接收与否无关,因此数据到达数据缓冲后立即被发送出去,从图1-3右侧可以看到,发送字符串“Nagle”共需十个数据包。由此可知,不使用Nagle算法将对网络流量产生负面影响。即使只传输一个字节,其头信息都有可能几十个字节,因此,为了提高网络传输效率,必须使用Nagle算法
Nagle算法并不是什么时候都适用,根据传输数据的特性,网络流量未受太大影响时,不使用Nagle算法要比使用它时传输速度快,最典型的是“传输大文件数据”。将文件数据传入输出缓冲不会花太多时间,因此即便不使用Nagle算法,也会在装满输出缓冲时传输数据包,这不仅不会增加数据包的数量,反而会在无需等待ACK的前提下连续传输,因此可以大大提高传输速度
一般情况下,不适用Nagle算法可以提高传输速度,但如果无条件放弃使用Nagle算法,就会增加过多的网络流量,反而会影响传输。因此,未准确判断数据特性时不应禁用Nagle算法
禁用Nagle算法
刚才说过的“大数据文件”应禁用Nagle算法,换言之,如果有必要,就应禁用Nagle算法。Nagle算法使用与否在于网络流量上差别不大,使用Nagle算法的传输速度更慢。禁用方法很简单,从下面代码可以看出,只需将套接字可选项TCP_NODELAY改为1(真)即可
int opt_val = 1; setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, sizeof(opt_val));
通过TCP_NODELAY的值查看Nagle算法的设置状态
int opt_val; socklen_t opt_len; opt_len = sizeof(opt_val); getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, opt_len);
如果正在使用Nagle算法,opt_val变量中会保存0,如果已禁用Nagle算法,则保存1