我们进行套接字编程时往往只关注数据通信,而忽略了套接字具有的不同特性。但是,理解这些特性并根据实际需要进行更改也十分主要。
我们之前写的程序都是创建好套接字后(未经特别操作)直接使用的,此时通过默认的套接字特性进行数据通信。之前的示例程序较为简单,无需特别操作套接字特性,但有时的确需要更改。下表列出了一部分套接字可选项。
协议层 | 选项名 | 读取 | 设置 |
---|---|---|---|
SOL_SOCKET | SO_SNDBUF | O | O |
SO_RCVBUF | O | O | |
SO_REUSEADDR | 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 | O | O | |
IP_MULTICAST_LOOP | O | O | |
IP_MULTICAST_IF | O | O | |
IPPROTO_TCP | TCP_KEEPALIVE | O | O |
TCP_NODELAY | O | O | |
TCP_MAXSEG | O | O |
从上表1-1中可以看出,套接字可选项是分层的。IPPROTO_IP 层可选项是IP协议相关事项,IPPROTO_TCP 层可选项是TCP协议相关的事项,SOL_SOCKET 层是套接字相关的通用可选项。
也许有人看到上图1-1的表格会产生畏惧感,但现在无需全部背下来或理解,因此不必有负担。实际能够设置的可选项数量是表1-1中的好几倍,也无需一下子理所有可选项,在实际工作中逐一掌握即可。接触的可选项多了,自然会掌握大部分重要的。本文中也只介绍其中一部分重要的可选项含义及更改方法。
我们几乎可以针对上表1-1中的所有可选项进行读取(Get)和设置(Set)(当然,有些可选项只能进行一种操作)。可选项的读取和设置是通过 getsockopt()函数 和 setsockopt()函数 来完成的。
#include
int getsockopt(int sock, int level, int optname, void *optval, socklent_t *optlen);
/*参数说明
sock: 用于查看选项套接字文件描述符
level: 要查看的可选项的协议层
optname: 要查看的可选项名,用符号常数表示的整型值
optval: 保存查看结果的缓冲地址值
optlen: 向第四个参数optval传递的缓冲大小。调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数
*/
//返回值: 成功时返回0,失败时返回-1
#include
int setsockopt(int sock, int level, int optname, const void *optval, socklen_t optlen);
/*参数说明
sock: 用于更改可选项的套接字文件描述符
level: 要更改的可选项协议层
optname: 要更改的可选项名
optval: 保存要更改的选项信息的缓冲地址值
optlen: 向第四个参数optval传递的可选项信息的字节数
*/
//返回值: 成功时返回0,失败时返回-1
下面我们编写一个示例程序来展示 getsockopt 函数的使用方法。示例程序中,协议层为 SOL_SOCKET、选项名为 SO_TYPE的可选项参看套接类型(TCP或UDP)。
#include
#include
#include
#include
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); //创建TCP套接字
udp_sock = socket(PF_INET, SOCK_DGRAM, 0); //创建UDP套接字
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); //获取TCP套接字类型信息
if(state != 0)
error_handling("getsockopt() error!");
printf("Socket type one: %d\n", sock_type);
state = getsockopt(udp_sock, SOL_SOCKET, SO_TYPE, (void*)&sock_type, &optlen); //获取UDP套接字类型信息
if(state != 0)
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);
}
编译程序:gcc sock_type.c -o socktype
运行程序:./socktype
SOCK_STREAM: 1
SOCK_DGRAM: 2
Socket type one: 1
Socket type two: 2
《程序说明》上述示例给出了调用 getsockopt 函数查看套接字信息的方法。另外,用于验证套接字类型的 SO_TYPE 是典型的只读可选项,这一点可以通过下面这句话解释:
“套接字类型只能在创建时确定,以后不能再更改。”
我们知道,创建TCP套接字时将同时生成I/O缓冲。SO_SNDBUF 和 SO_RCVBUF 就是I/O缓冲相关可选项。
SO_SNDBUF 是输入(接收)缓冲大小相关可选项,SO_RCVBUF 是输出(发送)缓冲大小相关可选项。用这两个可选项既可以读取当前I/O缓冲大小,也可以进行更改。通过下面示例程序读取创建TCP套接字时默认的I/O缓冲大小。
#include
#include
#include
#include
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 != 0)
error_handling("getsockopt() error!");
len = sizeof(rcv_buf);
state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, &len);
if(state != 0)
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);
}
编译程序:gcc get_buf.c -o getbuf
运行程序:./getbuf
Input buffer size: 87380
Output buffer size: 16384
接下来我们通过一个示例程序更改I/O缓冲大小。
#include
#include
#include
#include
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 != 0)
error_handling("setsockopt() error!");
state = setsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_buf, sizeof(snd_buf));
if(state != 0)
error_handling("setsockopt() error!");
len = sizeof(rcv_buf);
state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, &len);
if(state != 0)
error_handling("getsockopt() error!");
len = sizeof(snd_buf);
state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_buf, &len);
if(state != 0)
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);
}
编译程序:gcc set_buf.c -o setbuf
运行程序:./setbuf
Input buffer size: 6144
Output buffer size: 6144
《结果分析》输出结果跟我们预期的完全不同,但也算合理。I/O缓冲大小的设置需谨慎处理,因此不会完全按照我们人为设置的要求进行,只是通过调用 setsockopt 函数向系统传递我们的要求。如果把输出缓冲设置为0并如实反映这种不合理的人为设置,TCP协议将如何进行?如果要实现流控制和错误发生时的重传机制,至少要有一些缓冲空间吧。上述示例虽没有100%按照我们的请求设置缓冲大小,但也大致反映出了通过 setsockopt 函数设置的I/O缓冲大小。
可选项 SO_REUSEADDR 与 TCP套接字的 Time-wait 状态有关。
在学习 SO_REUSEADDR 可选项之前,应理解好 Time-wait 状态。
我们修改一下之前实现过的回声服务器端程序,修改后的示例程序如下。
#include
#include
#include
#include
#include
#include
#define TRUE 1
#define FALSE 0
#define BUF_SIZE 1024
void error_handling(char *message);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
char message[BUF_SIZE];
int str_len, option;
struct sockaddr_in serv_adr; //服务器端地址信息变量
struct sockaddr_in clnt_adr; //客户端地址信息变量
socklen_t optlen, clnt_adr_sz;
if(argc!=2) {
printf("Usage: %s \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, (void*)&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))==-1)
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(STDOUT_FILENO, message, str_len); //系统标准输出I/O,将接收到的消息输出到控制台
}
close(clnt_sock);
close(serv_sock);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
该服务器端程序可以结合之前的 echo_client.c 客户端程序运行,可以参见下面博文链接的第3节内容。
Linux网络编程 - 基于TCP的服务器端/客户端(1)
运行该服务器端程序,把第34~36行的代码注释掉,通过如下方式终止程序:
“在客户端控制台输入Q消息,或通过键盘组合键 Ctrl+C 终止程序。”
也就是说,客户端主动关闭了TCP连接。当在客户端控制台输入Q消息时会调用close函数,进入到TCP连接释放的过程。客户端向服务器端发送FIN报文段并经过四次挥手(也叫四报文挥手)过程。当然,输入 Ctrl+C 时也会向服务器端传递 FIN报文段消息。强制终止程序时,由操作系统关闭文件及套接字,此过程也相当于调用close函数,也会向服务器端传递FIN报文段消息。
“但看不到什么特殊情况啊?”
是的,通常都是由客户端先请求断开TCP连接,所以不会发生特别的事情。重新运行服务器端也不成问题,但按照如下方式终止程序时则不同。
“服务器端可客户端已建立TCP连接的前提下,向服务器端控制台(Linux叫终端)输入 Ctrl+C,即强制关闭服务器端。”
这主要模拟了服务器端主动关闭TCP连接的情景。服务器端会向客户端发送 FIN 报文段消息,并经过四次挥手过程。但如果以这种方式终止程序,那服务器端重新运行时将产生问题。如果用同一端口号重新运行服务器端,将输出 “bind() error!” 错误消息,并且无法再次运行。但在这种情况下,再经过大约3分钟即可重新运行服务器端。
上述2种运行方式唯一的区别就是谁先传输FIN报文段消息,但结果却迥然不同,原因何在呢?
关于TCP连接建立和连接释放过程,请参见下面的博文链接。
TCP协议-TCP连接管理
我们先了解TCP连接释放的过程,如下图所示:
假设上图2-1中的主机A是服务器端,因为是主机A向主机B发送FIN报文段消息,故可以想象成服务器端在控制台输入Ctrl+C。但问题是,服务器端TCP套接字经过四次挥手过程后并非立即进入 CLOSED 状态,而是要经过一段时间的 Time-wait状态。当然,只有先断开连接的(即先发送FIN报文段消息的)主机才经过 Time-wait 状态。因此,若服务器端先断开连接,则无法立即重新运行。套接字处于 Time-wait 状态过程中,相应端口号还是处于正在使用的状态。因此,bind函数调用过程中当然会发生错误。
《提示》客户端套接字不会经过 Time-wait 过程吗?
有些人可能会误以为 Time-wait 状态只存在于服务器端。但实际上,不管是服务器端还是客户端,套接字都会有 TIme-wait 过程。先断开TCP连接的套接字必然会经过 Time-wait 过程。但无需考虑客户端的 Time-wait 状态。因为客户端套接字的端口号是任意指定的。与服务器端不同,客户端每次运行程序时都是由操作系统动态分配端口号的,因此无需过多关注客户端套接字的 Time-wait 状态。
到底为什么会有 Time-wait 状态呢?上图2-1中,假设主机A向主机B传输第四个报文段消息(ACK, SEQ 5001, ACK 7502)后立即消除套接字,也就是进入 CLOSED 状态。但最后这条ACK报文段消息在传输途中丢失,未能传递给主机B。这时会发生什么?主机B会认为之前自己发送的FIN报文段消息(FIN, SEQ 7501, ACK 5001)没有抵达主机A,继而超时计时器到期后会重传该报文段。但此时主机A已是 CLOSED 状态,因此主机B永远无法收到从主机A最后传来的ACK报文段消息。相反,若主机A的套接字处于 Time-wait 状态,则会向主机B重传最后的ACK报文段消息,主机B也可以正常进入到 CLOSED状态。基于这些考虑,先传输FIN报文段消息的主机应经过 Time-wait 过程。
《补充说明》处于 Time-wait 状态的套接字,为什么需要等待 2MSL 的时间呢?
MSL(Maximum Segment Lifetime,最长报文段寿命) RFC 793 建议设置为2分钟,2MSL就是4分钟。
其一,为了保证主机A发送的最后一个ACK报文段消息能够到达主机B。最后这个ACK确认报文段有可能丢失,因而使得主机B收不到对已发送的 FIN+ACK 报文段的确认。B 会超时重传这个 FIN+ACK 报文段,而 A 就能在 2MSL 时间内收到这个重传的 FIN+ACK 报文段。接着 A 重传一次确认报文段,重新启动 2MSL 时间等待计时器。最后,A 和 B 都正常进入到 CLOSED 状态。如果 A 在 Time-wait 状态不等待一段时间,而是在发送完ACK报文段后立即释放连接,那么就无法收到 B 重传的 FIN+ACK 报文段,因而也不会再发送一次确认报文段。这样,B 就无法按照正常步骤进入 CLOSED 状态。
A 发送最后一个确认报文段到 B 接收到这个确认报文段,按 一个 MSL 时间计算;如果主机B在超时计时器到期前没有收到对已发送的 FIN+ACK报文段的确认,那么主机B就会重发这个 FIN+ACK报文段,从 B 重发 FIN+ACK 报文段到 A 接收到,也按一个 MSL 时间计算,合计 2MSL。
其二,使本TCP连接持续的时间内所产生的所有报文段都从网络中消失。主机 A 在发送完最后一个ACK报文段后,再经过 2MSL 时间,就可以使本连接内所产生的所有报文段都从网络中消失。
主机B只要收到了A发出的最后一个确认报文段,就进入到CLOSED状态。而主机A在发出最后一个确认报文段后,还需要等待 2MSL 时间后,才能撤销相应的传输控制块TCB,并结束此次TCP连接。因此,主机 A 结束TCP连接的时间要比主机 B 要晚。
Time-wait 看似重要,但并不一定讨人喜欢。考虑一下系统发生故障导致服务器端突然停止运行的情况。这时需要尽快重启服务器端以提供服务(一般采用守护进程的方式保证服务器端进程能快速重启),但因服务器端套接字处于 Time-wait状态而必须等待几分钟(通常是3分钟左右)才能重启成功。因此,Time-wait 并非只有优点,而且有些情况下可能引发更大问题。下图演示了四次挥手时不得不延长 TIme-wait 过程的情况。
如上图2-2所示,在主机A的四次挥手过程中,如果最后的ACK确认报文段丢失,则主机B在超时后会认为主机A未能收到自己发生的 FIN+ACK 报文段,因此重传这个 FIN+ACK 报文段。这时,接收到 FIN+ACK 报文段的主机A将重启Time-wait计时器。因此,如果网络状况不理想时,Time-wait 状态将持续下去。
解决服务器端 Time-wait 状态的这一问题的方法就是在套接字的可选项中设置 SO_REUSEADDR 选项。通过适当调整该选项的参数值,可将 Time-wait 状态下的套接字端口号重新分配给新的TCP套接字。SO_REUSEADDR 的默认值是0(假),这就意味着无法重新分配 Time-wait 状态下的套接字端口号。因此需要将这个值改为1(真)。具体做法已在示例程序 reuseadr_eserver.c 中给出,只需去掉下述代码的注释即可。
optlen = sizoeof(option);
option = TRUE;
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void*)&option, optlen);
在网络编程时,我们可能经常被问及如下问题:
“什么是Nagle(纳格)算法?使用该算法能够获得哪些数据通信特性?”
为了提高TCP的数据传输效率,我们希望将尽可能多的数据发送出去,但是为了防止因数据包过多而发生网络过载,Nagle算法在1984年诞生了。它应用于运输层的TCP协议中,在TCP的实现中广泛使用该算法。其使用与否会导致如下图所示的差异。
上图3-3 展示了通过Nagle算法发送字符串“Nagle”和未使用Nagle算法的差别。Nagle算法原理如下:
1、若发送应用进程把要发送的数据逐个字节地送到 TCP 的发送缓冲,则发送方就把第一个数据字节发送出去,把后面到达的数据字节都先缓存起来。
2、当发送方接收到对第一个数据字符的确认后,再把发送缓存中的所有数据组装成一个报文段再发送出去,同时继续对随后到达的数据进行缓存。只有在收到对前一个报文段的确认后,才继续发送下一个报文段。
3、当数据到达较快而网络速率较慢时,用这样的方法可明显地减少所用的网络带宽。
4、Nagle算法还规定:当到达的数据已达到发送窗口大小的一半或已经达到报文段的最大长度时,就立即发送一个报文段。
TCP套接字默认使用Nagle算法交换数据,因此最大限度地进行缓冲,直到收到ACK确认报文段。上图3-3左侧正是这种情况。为了发送字符串“Nagle”,先将其传递到发送缓冲。这时头字符“N”之前没有其他数据(没有需接收的ACK),因此立即发送。之后开始等待字符“N”的ACK确认,等待过程中,剩下的 “agle” 填入发送缓冲。接下来,当收到字符“N”的ACK确认后,将发送缓冲剩余的“agle” 装入一个TCP报文段发送。也就是说,一共需传递4个TCP报文段以传输1个字符串“Nagle”。
接下来我们分析一下未使用Nagle算法时,发送字符串“Nagle”的过程。假设字符 “N” 到 “e” 依次传递到发送缓冲。此时的发送过程与ACK接收与否无关,因此数据到达发送缓冲后立即被发送出去。从图3-3右侧可以看到,发送字符串 “Nagle” 时一共需10个TCP报文段。由此可知,不使用Nagle算法将对网络流量(Traffic:指网络负载或网络拥塞程度)产生负面影响。即使只传输1个字节的数据,仍然需要添加TCP首部信息(TCP首部长度最小是20字节)。因此,为了提高网络传输效率,必须使用Nagle算法。
《提示》图3-3是极端情况的演示
在程序中将字符串数据传递给发送缓冲时并不是逐字传递的,故发送字符串“Nagle”的实际情况并非如上图3-3所示。但如果隔一段时间再把构成字符串的字符传到发送缓冲(如果存在此类数据传递)的话,则有可能产生类似图3-3的情况。图3-3中所示的就是隔一段时间向TCP发送缓冲传递待发送数据的。
但Nagle算法并不是什么时候都适用。根据传输数据的特性,网络流量未受太大影响时,不使用Nagle算法要比使用它时传输速度快。最典型的是“传输大文件数据”。将文件数据传入发送缓冲不会花太多时间,因此,即使不使用Nagle算法,也会在装满发送缓冲时传输TCP报文段。这不仅不会增加报文段的数量,反而会在无需等待ACK的前提下连续传输,因此可以大大提高传输速度。
一般情况下,不使用Nagle算法可以提高传输速度。但如果无条件放弃使用Nagle算法,就会增加过多的网络流量,反而影响传输效率。因此,未准确判断数据特性时不应禁用Nagle算法。
上面说过的“传输大文件数据”时应禁用Nagle算法。换言之,如果有必要,就应禁用Nagle算法。
“Nagle算法使用与否在网络流量上差别不大,但使用Nagle算法的数据传输速度相比较而言会更慢些。”
禁用Nagle算法的方法非常简单,只需将套接字可选项 TCP_NODELAY 的参数值设置为1(真)即可。代码如下:
int option = 1;
int optlen = sizeof(option);
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&option, optlen);
可以通过 TCP_NODELAY 的值查看Nagle算法的设置状态。代码如下:
int option;
socklen_t optlen;
optlen = sizeof(option);
getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&option, &optlen);
如果正在使用Nagle算法,option 变量中会保存0;如果已禁用Nagle算法,则保存1。
a. Time-wait 状态只在服务器端的套接字中发生。
b. 断开连接的四次挥手过程中,先传输FIN消息的套接字将进入 Time-wait状态。
c. Time-wait 状态与断开连接的过程无关,而与请求连接中SYN消息的传输顺序有关。
d. Time-wait 状态通常并非必要,应尽可能通过更改套接字可选项防止其发生。
答:acd,原因分析如下:
(a):Time-wait 状态的发生是主动发起连接关闭的一方所要经历的状态,可能是服务器端,也有可能是客户端。
(c):Time-wait 状态与断开连接的过程有关,在(a)中已经说明了这一点。断开连接的过程与请求连接中SYN消息的传输顺序没有必然的联系。
(d):Time-wait 状态是TCP连接正常释放过程中必经的一个状态,但是如果TCP服务器端因为异常原因(系统故障、网络断连)而导致服务器端进程被kill掉了,但此时服务器端TCP套接字还处于Time-wait 状态,就会影响到服务器端进程的立即重启(一般都是由守护进程自动负责重启),会出现bind error的问题。为了解决“bind error”问题,我们可以在服务器端TCP套接字中使用 SO_REUSEADDR 可选项,并将该可选项的状态值设置为1(真),其默认是0(假)。所以,d 对 Time-wait 状态的描述是错误的。
答:根据TCP数据传输特性,网络流量未受太大影响时,禁用Nagle算法要比使用它时传输效率更高。最典型的就是“传输大文件数据”时,应当禁用Nagle算法。因为将文件数据传入发送缓冲不会花太多时间,因此,即便不使用Nagle算法,也会在填满发送缓冲时传输报文段。这不仅不会增加报文段的数量,反而会在无需等待ACK确认报文段的前提下连续传输,因此可以大大提高传输速度。
《TCP-IP网络编程(尹圣雨)》第9章 - 套接字的多种可选项
《计算机网络(第7版-谢希仁)》第5章 - 运输层
《TCP/IP网络编程》课后练习答案第一部分6~10章 尹圣雨