09 TIME_WAIT
TCP四次挥手,在四次挥手的过程中,发起连接断开的一方会有一段时间处于TIME_WAIT的状态,TIME_WAIT相关的问题始终是绕不过去的一道难题。
TIME_WAIT发生的场景
先从一例线上故障说起。在一次升级线上应用服务之后,我们发现该服务的可用性变得时好时坏,一段时间可以对外提供服务,一段时间突然又不可以,大家都百思不得其解。运维同学登录到服务所在的主机上,使用netstat命令查看后才发现,主机上有成千上万处于TIME_WAIT状态的连接。
经过层层剖析后,我们发现罪魁祸首就是TIME_WAIT。为什么呢?我们这个应用服务需要通过发起TCP连接对外提供服务。每个连接会占用一个本地端口,当在高并发的情况下,TIME_WAIT状态的连接过多,多到把本机可用的端口耗尽,应用服务对外表现的症状,就是不能正常工作了。当过了一段时间之后,处于TIME_WAIT的连接被系统回收并关闭后,释放出本地端口可供使用,应用服务对外表现为,可以正常工作。这样周而复始,便会出现了一会儿不可以,过一两分钟又可以正常工作的现象。
那么为什么会产生这么多的TIME_WAIT连接呢?
这要从TCP的四次挥手说起。
TCP连接终止时,主机1先发送FIN报文,主机2进入CLOSE_WAIT状态,并发送一个ACK应答,同时,主机2通过read调用获得EOF,并将此结果通知应用程序进行主动关闭操作,发送FIN报文。主机1在接收到FIN报文后发送ACK应答,此时主机1进入TIME_WAIT状态。
主机1在TIME_WAIT停留持续时间是固定的,是最长分节生命期MSL(maximum segment lifetime)的两倍,一般称之为2MSL。和大多数BSD派生的系统一样,Linux系统里有一个硬编码的字段,名称为 TCP_TIMEWAIT_LEN
,其值为60秒。也就是说, Linux系统停留在TIME_WAIT的时间为固定的60秒。
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME- WAIT state, about 60 seconds */
过了这个时间之后,主机1就进入CLOSED状态。为什么是这个时间呢?
只有发起连接终止的一方会进入TIME_WAIT状态。这一点面试的时候经常会被问到。
TIME_WAIT的作用
为什么不直接进入CLOSED状态,而要停留在TIME_WAIT这个状态?
这要从两个方面来说。
首先,这样做是为了确保最后的ACK能让被动关闭方接收,从而帮助其正常关闭。
TCP在设计的时候,做了充分的容错性设计,比如,TCP假设报文会出错,需要重传。在这里,如果图中主机1的ACK报文没有传输成功,那么主机2就会重新发送FIN报文。
如果主机1没有维护TIME_WAIT状态,而直接进入CLOSED状态,它就失去了当前状态的上下文,只能回复一个RST操作,从而导致被动关闭方出现错误。
现在主机1知道自己处于TIME_WAIT的状态,就可以在接收到FIN报文之后,重新发出一个ACK报文,使得主机2可以进入正常的CLOSED状态。
第二个理由和连接“化身”和报文迷走有关系,为了让旧连接的重复分节在网络中自然消失。
我们知道,在网络中,经常会发生报文经过一段时间才能到达目的地的情况,产生的原因是多种多样的,如路由器重启,链路突然出现故障等。如果迷走报文到达时,发现TCP连接四元组(源IP,源端口,目的IP,目的端口)所代表的连接不复存在,那么很简单,这个报文自然丢弃。
我们考虑这样一个场景,在原连接中断后,又重新创建了一个原连接的“化身”,说是化身其实是因为这个连接和原先的连接四元组完全相同,如果迷失报文经过一段时间也到达,那么这个报文会被误认为是连接“化身”的一个TCP分节,这样就会对TCP通信产生影响。
所以,TCP就设计出了这么一个机制,经过2MSL这个时间,足以让两个方向上的分组都被丢弃,使得原来连接的分组在网络中都自然消失,再出现的分组一定都是新化身所产生的。
划重点,2MSL的时间是 从主机1接收到FIN后发送ACK开始计时的;如果在TIME_WAIT时间内,因为主机1的ACK没有传输到主机2,主机1又接收到了主机2重发的FIN报文,那么2MSL时间将重新计时。道理很简单,因为2MSL的时间,目的是为了让旧连接的所有报文都能自然消亡,现在主机1重新发送了ACK报文,自然需要重新计时,以便防止这个ACK报文对新可能的连接化身造成干扰。
TIME_WAIT的危害
过多的TIME_WAIT的主要危害有两种。
第一是内存资源占用,这个目前看来不是太严重,基本可以忽略。
第二是对端口资源的占用,一个TCP连接至少消耗一个本地端口。要知道,端口资源也是有限的,一般可以开启的端口为32768~61000 ,也可以通过 net.ipv4.ip_local_port_range
指定,如果TIME_WAIT状态过多,会导致无法创建新连接。
如何优化TIME_WAIT?
在高并发的情况下,如果我们想对TIME_WAIT做一些优化,该如何办呢?
net.ipv4.tcp_max_tw_buckets
一个暴力的方法是通过sysctl命令,将系统值调小。这个值默认为18000,当系统中处于TIME_WAIT的连接一旦超过这个值时,系统就会将所有的TIME_WAIT连接状态重置,并且只打印出警告信息。这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多,不推荐使用。
调低TCP_TIMEWAIT_LEN,重新编译系统
这个方法是一个不错的方法,缺点是需要“一点”内核方面的知识,能够重新编译内核。我想这个不是大多数人能接受的方式。
SO_LINGER的设置
英文单词“linger”的意思为停留,我们可以通过设置套接字选项,来设置调用close或者shutdown关闭连接时的行为。
int setsockopt(int sockfd, int level, int optname, const void *optval,
socklen_t optlen);
struct linger {
int l_onoff; /* 0=off, nonzero=on */
int l_linger; /* linger time, POSIX specifies units as seconds */
}
设置linger参数有几种可能:
- 如果
l_onoff
为0,那么关闭本选项。l_linger
的值被忽略,这对应了默认行为,close或shutdown立即返回。如果在套接字发送缓冲区中有数据残留,系统会将试着把这些数据发送出去。 - 如果
l_onoff
为非0, 且l_linger
值也为0,那么调用close后,会立该发送一个RST标志给对端,该TCP连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。这种关闭的方式称为“强行关闭”。 在这种情况下,排队数据不会被发送,被动关闭方也不知道对端已经彻底断开。只有当被动关闭方正阻塞在recv()
调用上时,接受到RST时,会立刻得到一个“connet reset by peer”的异常。
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s,SOL_SOCKET,SO_LINGER, &so_linger,sizeof(so_linger));
- 如果
l_onoff
为非0, 且l_linger
的值也非0,那么调用close后,调用close的线程就将阻塞,直到数据被发送出去,或者设置的l_linger
计时时间到。
第二种可能为跨越TIME_WAIT状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。
net.ipv4.tcp_tw_reuse:更安全的设置
那么Linux有没有提供更安全的选择呢?
当然有。这就是 net.ipv4.tcp_tw_reuse
选项。
Linux系统对于 net.ipv4.tcp_tw_reuse
的解释如下:
Allow to reuse TIME-WAIT sockets for new connections when it is safe from protocol viewpoint. Default value is 0.It should not be changed without advice/request of technical experts.
这段话的大意是从协议角度理解如果是安全可控的,可以复用处于TIME_WAIT的套接字为新的连接所用。
那么什么是协议角度理解的安全可控呢?主要有两点:
- 只适用于连接发起方(C/S模型中的客户端);
- 对应的TIME_WAIT状态的连接创建时间超过1秒才可以被复用。
使用这个选项,还有一个前提,需要打开对TCP时间戳的支持,即 net.ipv4.tcp_timestamps=1
(默认即为1)。
要知道,TCP协议也在与时俱进,RFC 1323中实现了TCP拓展规范,以便保证TCP的高可用,并引入了新的TCP选项,两个4字节的时间戳字段,用于记录TCP发送方的当前时间戳和从对端接收到的最新时间戳。由于引入了时间戳,我们在前面提到的2MSL问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。
总结
在今天的内容里,我讲了TCP的四次挥手,重点对TIME_WAIT的产生、作用以及优化进行了讲解,你需要记住以下三点:
- TIME_WAIT的引入是为了让TCP报文得以自然消失,同时为了让被动关闭方能够正常关闭;
- 不要试图使用
SO_LINGER
设置套接字选项,跳过TIME_WAIT; - 现代Linux系统引入了更安全可控的方案,可以帮助我们尽可能地复用TIME_WAIT状态的连接。
10 优雅地关闭还是粗暴地关闭
TCP的四次挥手,其中发起连接关闭的一方会有一段时间处于TIME_WAIT状态。那么究竟如何来发起连接关闭呢?
一个TCP连接需要经过三次握手进入数据传输阶段,最后来到连接关闭阶段。在最后的连接关闭阶段,我们需要重点关注的是“半连接”状态。
因为TCP是双向的,这里说的方向,指的是数据流的写入-读出的方向。
比如客户端到服务器端的方向,指的是客户端通过套接字接口,向服务器端发送TCP报文;而服务器端到客户端方向则是另一个传输方向。在绝大多数情况下,TCP连接都是先关闭一个方向,此时另外一个方向还是可以正常进行数据传输。
举个例子,客户端主动发起连接的中断,将自己到服务器端的数据流方向关闭,此时,客户端不再往服务器端写入数据,服务器端读完客户端数据后就不会再有新的报文到达。但这并不意味着,TCP连接已经完全关闭,很有可能的是,服务器端正在对客户端的最后报文进行处理,比如去访问数据库,存入一些数据;或者是计算出某个客户端需要的值,当完成这些操作之后,服务器端把结果通过套接字写给客户端,我们说这个套接字的状态此时是“半关闭”的。最后,服务器端才有条不紊地关闭剩下的半个连接,结束这一段TCP连接的使命。
当然,我这里描述的是服务器端“优雅”地关闭了连接。如果服务器端处理不好,就会导致最后的关闭过程是“粗暴”的,达不到我们上面描述的“优雅”关闭的目标,形成的后果,很可能是服务器端处理完的信息没办法正常传送给客户端,破坏了用户侧的使用场景。
接下来我们就来看看关闭连接时,都有哪些方式呢?
close函数
首先,我们来看最常见的close函数:
int close(int sockfd)
这个函数很简单,对已连接的套接字执行close操作就可以,若成功则为0,若出错则为-1。
这个函数会对套接字引用计数减一,一旦发现套接字引用计数到0,就会对套接字进行彻底释放,并且会关闭 TCP两个方向的数据流。
套接字引用计数是什么意思呢?因为套接字可以被多个进程共享,你可以理解为我们给每个套接字都设置了一个积分,如果我们通过fork的方式产生子进程,套接字就会积分+1, 如果我们调用一次close函数,套接字积分就会-1。这就是套接字引用计数的含义。
close函数具体是如何关闭两个方向的数据流呢?
在输入方向,系统内核会将该套接字设置为不可读,任何读操作都会返回异常。
在输出方向,系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个FIN报文,接下来如果再对该套接字进行写操作会返回异常。
如果对端没有检测到套接字已关闭,还继续发送报文,就会收到一个RST报文,告诉对端:“Hi, 我已经关闭了,别再给我发数据了。”
我们会发现,close函数并不能帮助我们关闭连接的一个方向,那么如何在需要的时候关闭一个方向呢?幸运的是,设计TCP协议的人帮我们想好了解决方案,这就是shutdown函数。
shutdown函数
shutdown函数的原型是这样的:
int shutdown(int sockfd, int howto)
对已连接的套接字执行shutdown操作,若成功则为0,若出错则为-1。
howto是这个函数的设置选项,它的设置有三个主要选项:
- SHUT_RD(0):关闭连接的“读”这个方向,对该套接字进行读操作直接返回EOF。从数据角度来看,套接字上接收缓冲区已有的数据将被丢弃,如果再有新的数据流到达,会对数据进行ACK,然后悄悄地丢弃。也就是说,对端还是会接收到ACK,在这种情况下根本不知道数据已经被丢弃了。
- SHUT_WR(1):关闭连接的“写”这个方向,这就是常被称为“半关闭”的连接。此时,不管套接字引用计数的值是多少,都会直接关闭连接的写方向。套接字上发送缓冲区已有的数据将被立即发送出去,并发送一个FIN报文给对端。应用程序如果对该套接字进行写操作会报错。
- SHUT_RDWR(2):相当于SHUT_RD和SHUT_WR操作各一次,关闭套接字的读和写两个方向。
讲到这里,不知道你是不是有和我当初一样的困惑,使用SHUT_RDWR来调用shutdown不是和close基本一样吗,都是关闭连接的读和写两个方向。
其实,这两个还是有差别的。
第一个差别:close会关闭连接,并释放所有连接对应的资源,而shutdown并不会释放掉套接字和所有的资源。
第二个差别:close存在引用计数的概念,并不一定导致该套接字不可用;shutdown则不管引用计数,直接使得该套接字不可用,如果有别的进程企图使用该套接字,将会受到影响。
第三个差别:close的引用计数导致不一定会发出FIN结束报文,而shutdown则总是会发出FIN结束报文,这在我们打算关闭连接通知对端的时候,是非常重要的。
体会close和shutdown的差别
下面,我们通过构建一组客户端和服务器程序,来进行close和shutdown的实验。
客户端程序,从标准输入不断接收用户输入,把输入的字符串通过套接字发送给服务器端,同时,将服务器端的应答显示到标准输出上。
如果用户输入了“close”,则会调用close函数关闭连接,休眠一段时间,等待服务器端处理后退出;如果用户输入了“shutdown”,调用shutdown函数关闭连接的写方向,注意我们不会直接退出,而是会继续等待服务器端的应答,直到服务器端完成自己的操作,在另一个方向上完成关闭。
使用select使得我们可以同时完成对连接套接字和标准输入两个I/O对象的处理。
# include "lib/common.h"
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: graceclient <IPaddress>");
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
if (connect_rt < 0) {
error(1, errno, "connect failed ");
}
char send_line[MAXLINE], recv_line[MAXLINE + 1];
int n;
fd_set readmask;
fd_set allreads;
FD_ZERO(&allreads);
FD_SET(0, &allreads);
FD_SET(socket_fd, &allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
if (rc <= 0)
error(1, errno, "select failed");
if (FD_ISSET(socket_fd, &readmask)) {
n = read(socket_fd, recv_line, MAXLINE);
if (n < 0) {
error(1, errno, "read error");
} else if (n == 0) {
error(1, 0, "server terminated \n");
}
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs("\n", stdout);
}
if (FD_ISSET(0, &readmask)) {
if (fgets(send_line, MAXLINE, stdin) != NULL) {
if (strncmp(send_line, "shutdown", 8) == 0) {
FD_CLR(0, &allreads);
if (shutdown(socket_fd, 1)) {
error(1, errno, "shutdown failed");
}
} else if (strncmp(send_line, "close", 5) == 0) {
FD_CLR(0, &allreads);
if (close(socket_fd)) {
error(1, errno, "close failed");
}
sleep(6);
exit(0);
} else {
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
printf("now sending %s\n", send_line);
size_t rt = write(socket_fd, send_line, strlen(send_line));
if (rt < 0) {
error(1, errno, "write failed ");
}
printf("send bytes: %zu \n", rt);
}
}
}
}
}
我对这个程序的细节展开解释一下:
第一部分是套接字的创建和select初始化工作:
- 9-10行创建了一个TCP套接字;
- 12-16行设置了连接的目标服务器IPv4地址,绑定到了指定的IP和端口;
- 18-22行使用创建的套接字,向目标IPv4地址发起连接请求;
- 30-32行为使用select做准备,初始化描述字集合,这部分我会在后面详细解释,这里就不再深入。
第二部分是程序的主体部分,从33-80行, 使用select多路复用观测在连接套接字和标准输入上的I/O事件,其中:
- 38-48行:当连接套接字上有数据可读,将数据读入到程序缓冲区中。40-41行,如果有异常则报错退出;42-43行如果读到服务器端发送的EOF则正常退出。
- 49-77行:当标准输入上有数据可读,读入后进行判断。如果输入的是“shutdown”,则关闭标准输入的I/O事件感知,并调用shutdown函数关闭写方向;如果输入的是“close”,则调用close函数关闭连接;64-74行处理正常的输入,将回车符截掉,调用write函数,通过套接字将数据发送给服务器端。
服务器端程序稍微简单一点,连接建立之后,打印出接收的字节,并重新格式化后,发送给客户端。
服务器端程序有一点需要注意,那就是对SIGPIPE这个信号的处理。后面我会结合程序的结果展开说明。
#include "lib/common.h"
static int count;
static void sig_int(int signo) {
printf("\nreceived %d datagrams\n", count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
signal(SIGINT, sig_int);
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}
char message[MAXLINE];
count = 0;
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
message[n] = 0;
printf("received %d bytes: %s\n", n, message);
count++;
char send_line[MAXLINE];
sprintf(send_line, "Hi, %s", message);
sleep(5);
int write_nc = send(connfd, send_line, strlen(send_line), 0);
printf("send bytes: %zu \n", write_nc);
if (write_nc < 0) {
error(1, errno, "error write");
}
}
}
服务器端程序的细节也展开解释一下:
第一部分是套接字和连接创建过程:
- 11-12行创建了一个TCP套接字;
- 14-18行设置了本地服务器IPv4地址,绑定到了ANY地址和指定的端口;
- 20-40行使用创建的套接字,依次执行bind、listen和accept操作,完成连接建立。
第二部分是程序的主体,通过read函数获取客户端传送来的数据流,并回送给客户端:
- 51-52行显示收到的字符串,在56行对原字符串进行重新格式化,之后调用send函数将数据发送给客户端。注意,在发送之前,让服务器端程序休眠了5秒,以模拟服务器端处理的时间。
我们启动服务器,再启动客户端,依次在标准输入上输入data1、data2和close,观察一段时间后我们看到:
$./graceclient 127.0.0.1
data1
now sending data1
send bytes:5
data2
now sending data2
send bytes:5
close
$./graceserver
received 5 bytes: data1
send bytes: 9
received 5 bytes: data2
send bytes: 9
client closed
客户端依次发送了data1和data2,服务器端也正常接收到data1和data2。在客户端close掉整个连接之后,服务器端接收到SIGPIPE信号,直接退出。客户端并没有收到服务器端的应答数据。
我在下面放了一张图,这张图详细解释了客户端和服务器端交互的时序图。因为客户端调用close函数关闭了整个连接,当服务器端发送的“Hi, data1”分组到底时,客户端给回送一个RST分组;服务器端再次尝试发送“Hi, data2”第二个应答分组时,系统内核通知SIGPIPE信号。这是因为,在RST的套接字进行写操作,会直接触发SIGPIPE信号。
这回知道你的程序莫名其妙终止的原因了吧。
我们可以像这样注册一个信号处理函数,对SIGPIPE信号进行处理,避免程序莫名退出:
static void sig_pipe(int signo) {
printf("\nreceived %d datagrams\n", count);
exit(0);
}
signal(SIGINT, sig_pipe);
接下来,再次启动服务器,再启动客户端,依次在标准输入上输入data1、data2和shutdown函数,观察一段时间后我们看到:
$./graceclient 127.0.0.1
data1
now sending data1
send bytes:5
data2
now sending data2
send bytes:5
shutdown
Hi, data1
Hi,data2
server terminated
$./graceserver
received 5 bytes: data1
send bytes: 9
received 5 bytes: data2
send bytes: 9
client closed
和前面的结果不同,服务器端输出了data1、data2;客户端也输出了“Hi,data1”和“Hi,data2”,客户端和服务器端各自完成了自己的工作后,正常退出。
我们再看下客户端和服务器端交互的时序图。因为客户端调用shutdown函数只是关闭连接的一个方向,服务器端到客户端的这个方向还可以继续进行数据的发送和接收,所以“Hi,data1”和“Hi,data2”都可以正常传送;当服务器端读到EOF时,立即向客户端发送了FIN报文,客户端在read函数中感知了EOF,也进行了正常退出。
总结
在这一讲中,我们讲述了close函数关闭连接的方法,使用close函数关闭连接有两个需要明确的地方。
- close函数只是把套接字引用计数减1,未必会立即关闭连接;
- close函数如果在套接字引用计数达到0时,立即终止读和写两个方向的数据传送。
基于这两点,在期望关闭连接其中一个方向时,应该使用shutdown函数。
思考题
和往常一样,给你留两道思考题。
第一道题,你可以看到在今天的服务器端程序中,直接调用 exit(0)
完成了FIN报文的发送,这是为什么呢?为什么不调用close函数或shutdown函数呢?
第二道题关于信号量处理,今天的程序中,使用的是 SIG_IGN
默认处理,你知道默认处理和自定义函数处理的区别吗?不妨查查资料,了解一下。
11 连接无效:使用Keep-Alive还是应用心跳来检测?
如何使用close和shutdown来完成连接的关闭,在大多数情况下,我们会优选shutdown来完成对连接一个方向的关闭,待对端处理完之后,再完成另外一个方向的关闭。
在很多情况下,连接的一端需要一直感知连接的状态,如果连接无效了,应用程序可能需要报错,或者重新发起连接等。
从一个例子开始
一个基于NATS消息系统的项目,多个消息的提供者 (pub)和订阅者(sub)都连到NATS消息系统,通过这个系统来完成消息的投递和订阅处理。
突然有一天,线上报了一个故障,一个流程不能正常处理。经排查,发现消息正确地投递到了NATS服务端,但是消息订阅者没有收到该消息,也没能做出处理,导致流程没能进行下去。
通过观察消息订阅者后发现,消息订阅者到NATS服务端的连接虽然显示是“正常”的,但实际上,这个连接已经是无效的了。为什么呢?这是因为NATS服务器崩溃过,NATS服务器和消息订阅者之间的连接中断FIN包,由于异常情况,没能够正常到达消息订阅者,这样造成的结果就是消息订阅者一直维护着一个“过时的”连接,不会收到NATS服务器发送来的消息。
这个故障的根本原因在于,作为NATS服务器的客户端,消息订阅者没有及时对连接的有效性进行检测,这样就造成了问题。
保持对连接有效性的检测,是在实战中必须要注意的一个点。
TCP Keep-Alive选项
很多刚接触TCP编程的人会惊讶地发现,在没有数据读写的“静默”的连接上,是没有办法发现TCP连接是有效还是无效的。比如客户端突然崩溃,服务器端可能在几天内都维护着一个无用的 TCP连接。前面提到的例子就是这样的一个场景。
那么有没有办法开启类似的“轮询”机制,让TCP告诉我们,连接是不是“活着”的呢?
这就是TCP保持活跃机制所要解决的问题。实际上,TCP有一个保持活跃的机制叫做Keep-Alive。
这个机制的原理是这样的:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的TCP连接已经死亡,系统内核将错误信息通知给上层应用程序。
上述的可定义变量,分别被称为保活时间、保活时间间隔和保活探测次数。在Linux系统中,这些变量分别对应sysctl变量 net.ipv4.tcp_keepalive_time
、 net.ipv4.tcp_keepalive_intvl
、 net.ipv4.tcp_keepalve_probes
,默认设置是7200秒(2小时)、75秒和9次探测。
如果开启了TCP保活,需要考虑以下几种情况:
第一种,对端程序是正常工作的。当TCP保活的探测报文发送给对端, 对端会正常响应,这样TCP保活时间会被重置,等待下一个TCP保活时间的到来。
第二种,对端程序崩溃并重启。当TCP保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个RST报文,这样很快就会发现TCP连接已经被重置。
第三种,是对端程序崩溃,或对端由于其他原因导致报文不可达。当TCP保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP会报告该TCP连接已经死亡。
TCP保活机制默认是关闭的,当我们选择打开时,可以分别在连接的两个方向上开启,也可以单独在一个方向上开启。如果开启服务器端到客户端的检测,就可以在客户端非正常断连的情况下清除在服务器端保留的“脏数据”;而开启客户端到服务器端的检测,就可以在服务器无响应的情况下,重新发起连接。
为什么TCP不提供一个频率很好的保活机制呢?早期的网络带宽非常有限,如果提供一个频率很高的保活机制,对有限的带宽是一个比较严重的浪费。
应用层探活
如果使用TCP自身的keep-Alive机制,在Linux系统中,最少需要经过2小时11分15秒才可以发现一个“死亡”连接。这个时间是怎么计算出来的呢?其实是通过2小时,加上75秒乘以9的总和。实际上,对很多对时延要求敏感的系统中,这个时间间隔是不可接受的。
所以,必须在应用程序这一层来寻找更好的解决方案。
我们可以通过在应用程序中模拟TCP Keep-Alive机制,来完成在应用层的连接探活。
我们可以设计一个PING-PONG的机制,需要保活的一方,比如客户端,在保活时间达到后,发起对连接的PING操作,如果服务器端对PING操作有回应,则重新设置保活时间,否则对探测次数进行计数,如果最终探测次数达到了保活探测次数预先设置的值之后,则认为连接已经无效。
这里有两个比较关键的点:
第一个是需要使用定时器,这可以通过使用I/O复用自身的机制来实现;第二个是需要设计一个PING-PONG的协议。
下面我们尝试来完成这样的一个设计。
消息格式设计
我们的程序是客户端来发起保活,为此定义了一个消息对象。你可以看到这个消息对象,这个消息对象是一个结构体,前4个字节标识了消息类型,为了简单,这里设计了 MSG_PING
、 MSG_PONG
、 MSG_TYPE 1
和 MSG_TYPE 2
四种消息类型。
typedef struct {
u_int32_t type;
char data[1024];
} messageObject;
#define MSG_PING 1
#define MSG_PONG 2
#define MSG_TYPE1 11
#define MSG_TYPE2 21
客户端程序设计
客户端完全模拟TCP Keep-Alive的机制,在保活时间达到后,探活次数增加1,同时向服务器端发送PING格式的消息,此后以预设的保活时间间隔,不断地向服务器端发送PING格式的消息。如果能收到服务器端的应答,则结束保活,将保活时间置为0。
这里我们使用select I/O复用函数自带的定时器,select函数将在后面详细介绍。
#include "lib/common.h"
#include "message_objecte.h"
#define MAXLINE 4096
#define KEEP_ALIVE_TIME 10
#define KEEP_ALIVE_INTERVAL 3
#define KEEP_ALIVE_PROBETIMES 3
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: tcpclient <IPaddress>");
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
if (connect_rt < 0) {
error(1, errno, "connect failed ");
}
char recv_line[MAXLINE + 1];
int n;
fd_set readmask;
fd_set allreads;
struct timeval tv;
int heartbeats = 0;
tv.tv_sec = KEEP_ALIVE_TIME;
tv.tv_usec = 0;
messageObject messageObject;
FD_ZERO(&allreads);
FD_SET(socket_fd, &allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv);
if (rc < 0) {
error(1, errno, "select failed");
}
if (rc == 0) {
if (++heartbeats > KEEP_ALIVE_PROBETIMES) {
error(1, 0, "connection dead\n");
}
printf("sending heartbeat #%d\n", heartbeats);
messageObject.type = htonl(MSG_PING);
rc = send(socket_fd, (char *) &messageObject, sizeof(messageObject), 0);
if (rc < 0) {
error(1, errno, "send failure");
}
tv.tv_sec = KEEP_ALIVE_INTERVAL;
continue;
}
if (FD_ISSET(socket_fd, &readmask)) {
n = read(socket_fd, recv_line, MAXLINE);
if (n < 0) {
error(1, errno, "read error");
} else if (n == 0) {
error(1, 0, "server terminated \n");
}
printf("received heartbeat, make heartbeats to 0 \n");
heartbeats = 0;
tv.tv_sec = KEEP_ALIVE_TIME;
}
}
}
这个程序主要分成三大部分:
第一部分为套接字的创建和连接建立:
- 15-16行,创建了TCP套接字;
- 18-22行,创建了IPv4目标地址,其实就是服务器端地址,注意这里使用的是传入参数作为服务器地址;
- 24-28行,向服务器端发起连接。
第二部分为select定时器准备:
- 39-40行,设置了超时时间为KEEP_ALIVE_TIME,这相当于保活时间;
- 44-45行,初始化select函数的套接字。
最重要的为第三部分,这一部分需要处理心跳报文:
- 48行调用select函数,感知I/O事件。这里的I/O事件,除了套接字上的读操作之外,还有在39-40行设置的超时事件。当KEEP_ALIVE_TIME这段时间到达之后,select函数会返回0,于是进入53-63行的处理;
- 在53-63行,客户端已经在KEEP_ALIVE_TIME这段时间内没有收到任何对当前连接的反馈,于是发起PING消息,尝试问服务器端:“喂,你还活着吗?”这里我们通过传送一个类型为MSG_PING的消息对象来完成PING操作,之后我们会看到服务器端程序如何响应这个PING操作;
- 第65-74行是客户端在接收到服务器端程序之后的处理。为了简单,这里就没有再进行报文格式的转换和分析。在实际的工作中,这里其实是需要对报文进行解析后处理的,只有是PONG类型的回应,我们才认为是PING探活的结果。这里认为既然收到服务器端的报文,那么连接就是正常的,所以会对探活计数器和探活时间都置零,等待下一次探活时间的来临。
服务器端程序设计
服务器端的程序接受一个参数,这个参数设置的比较大,可以模拟连接没有响应的情况。服务器端程序在接收到客户端发送来的各种消息后,进行处理,其中如果发现是PING类型的消息,在休眠一段时间后回复一个PONG消息,告诉客户端:“嗯,我还活着。”当然,如果这个休眠时间很长的话,那么客户端就无法快速知道服务器端是否存活,这是我们模拟连接无响应的一个手段而已,实际情况下,应该是系统崩溃,或者网络异常。
#include "lib/common.h"
#include "message_objecte.h"
static int count;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: tcpsever <sleepingtime>");
}
int sleepingTime = atoi(argv[1]);
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}
messageObject message;
count = 0;
for (;;) {
int n = read(connfd, (char *) &message, sizeof(messageObject));
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
printf("received %d bytes\n", n);
count++;
switch (ntohl(message.type)) {
case MSG_TYPE1 :
printf("process MSG_TYPE1 \n");
break;
case MSG_TYPE2 :
printf("process MSG_TYPE2 \n");
break;
case MSG_PING: {
messageObject pong_message;
pong_message.type = MSG_PONG;
sleep(sleepingTime);
ssize_t rc = send(connfd, (char *) &pong_message, sizeof(pong_message), 0);
if (rc < 0)
error(1, errno, "send failure");
break;
}
default :
error(1, 0, "unknown message type (%d)\n", ntohl(message.type));
}
}
}
服务器端程序主要分为两个部分。
第一部分为监听过程的建立,包括7-38行; 第13-14行先创建一个本地TCP监听套接字;16-20行绑定该套接字到本地端口和ANY地址上;第27-38行分别调用listen和accept完成被动套接字转换和监听。
第二部分为43行到77行,从建立的连接套接字上读取数据,解析报文,根据消息类型进行不同的处理。
- 55-57行为处理MSG_TYPE1的消息;
- 59-61行为处理MSG_TYPE2的消息;
- 重点是64-72行处理MSG_PING类型的消息。通过休眠来模拟响应是否及时,然后调用send函数发送一个PONG报文,向客户端表示“还活着”的意思;
- 74行为异常处理,因为消息格式不认识,所以程序出错退出。
实验
基于上面的程序设计,让我们分别做两个不同的实验:
第一次实验,服务器端休眠时间为60秒。
我们看到,客户端在发送了三次心跳检测报文PING报文后,判断出连接无效,直接退出了。之所以造成这样的结果,是因为在这段时间内没有接收到来自服务器端的任何PONG报文。当然,实际工作的程序,可能需要不一样的处理,比如重新发起连接。
$./pingclient 127.0.0.1
sending heartbeat #1
sending heartbeat #2
sending heartbeat #3
connection dead
$./pingserver 60
received 1028 bytes
received 1028 bytes
第二次实验,我们让服务器端休眠时间为5秒。
我们看到,由于这一次服务器端在心跳检测过程中,及时地进行了响应,客户端一直都会认为连接是正常的。
$./pingclient 127.0.0.1
sending heartbeat #1
sending heartbeat #2
received heartbeat, make heartbeats to 0
received heartbeat, make heartbeats to 0
sending heartbeat #1
sending heartbeat #2
received heartbeat, make heartbeats to 0
received heartbeat, make heartbeats to 0
$./pingserver 5
received 1028 bytes
received 1028 bytes
received 1028 bytes
received 1028 bytes
总结
虽然TCP没有提供系统的保活能力,让应用程序可以方便地感知连接的存活,但是,我们可以在应用程序里灵活地建立这种机制。一般来说,这种机制的建立依赖于系统定时器,以及恰当的应用层报文协议。比如,使用心跳包就是这样一种保持Keep Alive的机制。
12 小数据包应对之策:TCP协议中的动态传输
从TCP角度看待数据流的发送和接收。
调用数据发送接口以后……
如何通过套接字发送数据,比如使用write或者send方法来进行数据流的发送。
调用这些接口并不意味着数据被真正发送到网络上,其实,这些数据只是从应用程序中被拷贝到了系统内核的套接字缓冲区中,或者说是发送缓冲区中,等待协议栈的处理。至于这些数据是什么时候被发送出去的,对应用程序来说,是无法预知的。对这件事情真正负责的,是运行于操作系统内核的TCP协议栈实现模块。
流量控制和生产者-消费者模型
我们可以把理想中的TCP协议可以想象成一队运输货物的货车,运送的货物就是TCP数据包,这些货车将数据包从发送端运送到接收端,就这样不断周而复始。
我们仔细想一下,货物达到接收端之后,是需要卸货处理、登记入库的,接收端限于自己的处理能力和仓库规模,是不可能让这队货车以不可控的速度发货的。接收端肯定会和发送端不断地进行信息同步,比如接收端通知发送端:“后面那20车你给我等等,等我这里腾出地方你再继续发货。”
其实这就是发送窗口和接收窗口的本质,我管这个叫做“TCP的生产者-消费者”模型。
发送窗口和接收窗口是TCP连接的双方,一个作为生产者,一个作为消费者,为了达到一致协同的生产-消费速率、而产生的算法模型实现。
说白了,作为TCP发送端,也就是生产者,不能忽略TCP的接收端,也就是消费者的实际状况,不管不顾地把数据包都传送过来。如果都传送过来,消费者来不及消费,必然会丢弃;而丢弃反过来使得生产者又重传,发送更多的数据包,最后导致网络崩溃。
我想,理解了“TCP的生产者-消费者”模型,再反过来看发送窗口和接收窗口的设计目的和方式,我们就会恍然大悟了。
拥塞控制和数据传输
TCP的生产者-消费者模型,只是在考虑单个连接的数据传递,但是, TCP数据包是需要经过网卡、交换机、核心路由器等一系列的网络设备的,网络设备本身的能力也是有限的,当多个连接的数据包同时在网络上传送时,势必会发生带宽争抢、数据丢失等,这样, TCP就必须考虑多个连接共享在有限的带宽上,兼顾效率和公平性的控制,这就是拥塞控制的本质。
举个形象一点的例子,有一个货车行驶在半夜三点的大路上,这样的场景是断然不需要拥塞控制的。
我们可以把网络设备形成的网络信息高速公路和生活中实际的高速公路做个对比。正是因为有多个TCP连接,形成了高速公路上的多队运送货车,高速公路上开始变得熙熙攘攘,这个时候,就需要拥塞控制的接入了。
在TCP协议中,拥塞控制是通过拥塞窗口来完成的,拥塞窗口的大小会随着网络状况实时调整。
拥塞控制常用的算法有“慢启动”,它通过一定的规则,慢慢地将网络发送数据的速率增加到一个阈值。超过这个阈值之后,慢启动就结束了,另一个叫做“拥塞避免”的算法登场。在这个阶段,TCP会不断地探测网络状况,并随之不断调整拥塞窗口的大小。
现在你可以发现,在任何一个时刻,TCP发送缓冲区的数据是否能真正发送出去, 至少 取决于两个因素,一个是 当前的发送窗口大小,另一个是 拥塞窗口大小,而TCP协议中总是取两者中最小值作为判断依据。比如当前发送的字节为100,发送窗口的大小是200,拥塞窗口的大小是80,那么取200和80中的最小值,就是80,当前发送的字节数显然是大于拥塞窗口的,结论就是不能发送出去。
这里千万要分清楚发送窗口和拥塞窗口的区别。
发送窗口反应了作为单TCP连接、点对点之间的流量控制模型,它是需要和接收端一起共同协调来调整大小的;而拥塞窗口则是反应了作为多个TCP连接共享带宽的拥塞控制模型,它是发送端独立地根据网络状况来动态调整的。
一些有趣的场景
注意我在前面的表述中,提到了在任何一个时刻里,TCP发送缓冲区的数据是否能真正发送出去,用了“至少两个因素”这个说法,细心的你有没有想过这个问题,除了之前引入的发送窗口、拥塞窗口之外,还有什么其他因素吗?
我们考虑以下几个有趣的场景:
第一个场景,接收端处理得急不可待,比如刚刚读入了100个字节,就告诉发送端:“喂,我已经读走100个字节了,你继续发”,在这种情况下,你觉得发送端应该怎么做呢?
第二个场景是所谓的“交互式”场景,比如我们使用telnet登录到一台服务器上,或者使用SSH和远程的服务器交互,这种情况下,我们在屏幕上敲打了一个命令,等待服务器返回结果,这个过程需要不断和服务器端进行数据传输。这里最大的问题是,每次传输的数据可能都非常小,比如敲打的命令“pwd”,仅仅三个字符。这意味着什么?这就好比,每次叫了一辆大货车,只送了一个小水壶。在这种情况下,你又觉得发送端该怎么做才合理呢?
第三个场景是从接收端来说的。我们知道,接收端需要对每个接收到的TCP分组进行确认,也就是发送ACK报文,但是ACK报文本身是不带数据的分段,如果一直这样发送大量的ACK报文,就会消耗大量的带宽。之所以会这样,是因为TCP报文、IP报文固有的消息头是不可或缺的,比如两端的地址、端口号、时间戳、序列号等信息, 在这种情形下,你觉得合理的做法是什么?
TCP之所以复杂,就是因为TCP需要考虑的因素较多。像以上这几个场景,都是TCP需要考虑的情况,一句话概况就是如何有效地利用网络带宽。
第一个场景也被叫做糊涂窗口综合症,这个场景需要在接收端进行优化。也就是说,接收端不能在接收缓冲区空出一个很小的部分之后,就急吼吼地向发送端发送窗口更新通知,而是需要在自己的缓冲区大到一个合理的值之后,再向发送端发送窗口更新通知。这个合理的值,由对应的RFC规范定义。
第二个场景需要在发送端进行优化。这个优化的算法叫做Nagle算法,Nagle算法的本质其实就是限制大批量的小数据包同时发送,为此,它提出,在任何一个时刻,未被确认的小数据包不能超过一个。这里的小数据包,指的是长度小于最大报文段长度MSS的TCP分组。这样,发送端就可以把接下来连续的几个小数据包存储起来,等待接收到前一个小数据包的ACK分组之后,再将数据一次性发送出去。
第三个场景,也是需要在接收端进行优化,这个优化的算法叫做延时ACK。延时ACK在收到数据后并不马上回复,而是累计需要发送的ACK报文,等到有数据需要发送给对端时,将累计的ACK 捎带一并发送出去。当然,延时ACK机制,不能无限地延时下去,否则发送端误认为数据包没有发送成功,引起重传,反而会占用额外的网络带宽。
禁用Nagle算法
有没有发现一个很奇怪的组合,即Nagle算法和延时ACK的组合。
这个组合为什么奇怪呢?我举一个例子你来体会一下。
比如,客户端分两次将一个请求发送出去,由于请求的第一部分的报文未被确认,Nagle算法开始起作用;同时延时ACK在服务器端起作用,假设延时时间为200ms,服务器等待200ms后,对请求的第一部分进行确认;接下来客户端收到了确认后,Nagle算法解除请求第二部分的阻止,让第二部分得以发送出去,服务器端在收到之后,进行处理应答,同时将第二部分的确认捎带发送出去。
你从这张图中可以看到,Nagle算法和延时确认组合在一起,增大了处理时延,实际上,两个优化彼此在阻止对方。
从上面的例子可以看到,在有些情况下Nagle算法并不适用, 比如对时延敏感的应用。
幸运的是,我们可以通过对套接字的修改来关闭Nagle算法。
int on = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&on, sizeof(on));
值得注意的是,除非我们对此有十足的把握,否则不要轻易改变默认的TCP Nagle算法。因为在现代操作系统中,针对Nagle算法和延时ACK的优化已经非常成熟了,有可能在禁用Nagle算法之后,性能问题反而更加严重。
将写操作合并
其实前面的例子里,如果我们能将一个请求一次性发送过去,而不是分开两部分独立发送,结果会好很多。所以,在写数据之前,将数据合并到缓冲区,批量发送出去,这是一个比较好的做法。不过,有时候数据会存储在两个不同的缓存中,对此,我们可以使用如下的方法来进行数据的读写操作,从而避免Nagle算法引发的副作用。
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt)
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);
这两个函数的第二个参数都是指向某个iovec结构数组的一个指针,其中iovec结构定义如下:
struct iovec {
void *iov_base; /* starting address of buffer */
size_t iov_len; /* size of buffer */
};”
下面的程序展示了集中写的方式:
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: tcpclient <IPaddress>");
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
if (connect_rt < 0) {
error(1, errno, "connect failed ");
}
char buf[128];
struct iovec iov[2];
char *send_one = "hello,";
iov[0].iov_base = send_one;
iov[0].iov_len = strlen(send_one);
iov[1].iov_base = buf;
while (fgets(buf, sizeof(buf), stdin) != NULL) {
iov[1].iov_len = strlen(buf);
int n = htonl(iov[1].iov_len);
if (writev(socket_fd, iov, 2) < 0)
error(1, errno, "writev failure");
}
exit(0);
}
这个程序的前半部分创建套接字,建立连接就不再赘述了。关键的是24-33行,使用了iovec数组,分别写入了两个不同的字符串,一个是“hello,”,另一个通过标准输入读入。
在启动该程序之前,我们需要启动服务器端程序,在客户端依次输入“world”和“network”:
world
network
接下来我们可以看到服务器端接收到了iovec组成的新的字符串。这里的原理其实就是在调用writev操作时,会自动把几个数组的输入合并成一个有序的字节流,然后发送给对端。
received 12 bytes: hello,world
received 14 bytes: hello,network
总结
- 发送窗口用来控制发送和接收端的流量;阻塞窗口用来控制多条连接公平使用的有限带宽。
- 小数据包加剧了网络带宽的浪费,为了解决这个问题,引入了如Nagle算法、延时ACK等机制。
- 在程序设计层面,不要多次频繁地发送小报文,如果有,可以使用writev批量发送。
13 UDP的”已连接”状态
14 地址已经被使用?
服务器端程序重启时,地址被占用的原因和解决方法。
我们已经知道,网络编程中,服务器程序需要绑定本地地址和一个端口,然后就监听在这个地址和端口上,等待客户端连接的到来。在实战中,你可能会经常碰到一个问题,当服务器端程序重启之后,总是碰到“Address in use”的报错信息,服务器程序不能很快地重启。那么这个问题是如何产生的?我们又该如何避免呢?
这个“地址已经被使用”的问题。
从例子开始
为了引入讨论,我们从之前讲过的一个TCP服务器端程序开始说起:
static int count;
static void sig_int(int signo) {
printf("\nreceived %d datagrams\n", count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}
char message[MAXLINE];
count = 0;
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
message[n] = 0;
printf("received %d bytes: %s\n", n, message);
count++;
}
}
这个服务器端程序绑定到一个本地端口,使用的是通配地址ANY,当连接建立之后,从该连接中读取输入的字符流。
启动服务器,之后我们使用Telnet登录这个服务器,并在屏幕上输入一些字符,例如:network,good。
和我们期望的一样,服务器端打印出Telnet客户端的输入。在Telnet端关闭连接之后,服务器端接收到EOF,也顺利地关闭了连接。服务器端也可以很快重启,等待新的连接到来。
$./addressused
received 9 bytes: network
received 6 bytes: good
client closed
$./addressused
接下来,我们改变一下连接的关闭顺序。和前面的过程一样,先启动服务器,再使用Telnet作为客户端登录到服务器,在屏幕上输入一些字符。注意接下来的不同,我不会在Telnet端关闭连接,而是直接使用Ctrl+C的方式在服务器端关闭连接。
$telnet 127.0.0.1 9527
network
bad
Connection closed by foreign host.
我们看到,连接已经被关闭,Telnet客户端也感知连接关闭并退出了。接下来,我们尝试重启服务器端程序。你会发现,这个时候服务端程序重启失败,报错信息为: bind failed: Address already in use。
$./addressused
received 9 bytes: network
received 6 bytes: good
client closed
$./addressused
bind faied: Address already in use(98)
复习TIME_WAIT
那么,这个错误到底是怎么发生的呢?
当连接的一方主动关闭连接,在接收到对端的FIN报文之后,主动关闭连接的一方会在TIME_WAIT这个状态里停留一段时间,这个时间大约为2MSL。如果你对此有点淡忘,没有关系,我在下面放了一张图,希望会唤起你的记忆。
如果我们此时使用netstat去查看服务器程序所在主机的TIME_WAIT的状态连接,你会发现有一个服务器程序生成的TCP连接,当前正处于TIME_WAIT状态。这里9527是本地监听端口,36650是telnet客户端端口。当然了,Telnet客户端端口每次也会不尽相同。
通过服务器端发起的关闭连接操作,引起了一个已有的TCP连接处于TME_WAIT状态,正是这个TIME_WAIT的连接,使得服务器重启时,继续绑定在127.0.0.1地址和9527端口上的操作,返回了 Address already in use 的错误。
重用套接字选项
我们知道,一个TCP连接是通过四元组(源地址、源端口、目的地址、目的端口)来唯一确定的,如果每次Telnet客户端使用的本地端口都不同,就不会和已有的四元组冲突,也就不会有TIME_WAIT的新旧连接化身冲突的问题。
事实上,即使在很小的概率下,客户端Telnet使用了相同的端口,从而造成了新连接和旧连接的四元组相同,在现代Linux操作系统下,也不会有什么大的问题,原因是现代Linux操作系统对此进行了一些优化。
第一种优化是新连接SYN告知的初始序列号,一定比TIME_WAIT老连接的末序列号大,这样通过序列号就可以区别出新老连接。
第二种优化是开启了tcp_timestamps,使得新连接的时间戳比老连接的时间戳大,这样通过时间戳也可以区别出新老连接。
在这样的优化之下,一个TIME_WAIT的TCP连接可以忽略掉旧连接,重新被新的连接所使用。
这就是重用套接字选项,通过给套接字配置可重用属性,告诉操作系统内核,这样的TCP连接完全可以复用TIME_WAIT状态的连接。代码片段已经放在文章中了:
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
SO_REUSEADDR套接字选项,允许启动绑定在一个端口,即使之前存在一个和该端口一样的连接。前面的例子已经表明,在默认情况下,服务器端历经创建socket、bind和listen重启时,如果试图绑定到一个现有连接上的端口,bind操作会失败,但是如果我们在创建socket和bind之间,使用上面的代码片段设置SO_REUSEADDR套接字选项,情况就会不同。
下面我们对原来的服务器端代码进行升级,升级的部分主要在11-12行,在bind监听套接字之前,调用setsockopt方法,设置重用套接字选项:
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}
char message[MAXLINE];
count = 0;
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
message[n] = 0;
printf("received %d bytes: %s\n", n, message);
count++;
}
}
重新编译过后,重复上面那个例子,先启动服务器,再使用Telnet作为客户端登录到服务器,在屏幕上输入一些字符,使用Ctrl+C的方式在服务器端关闭连接。马上尝试重启服务器,这个时候我们发现,服务器正常启动,没有出现 Address already in use 的错误。这说明我们的修改已经起作用。
$./addressused2
received 9 bytes: network
received 6 bytes: good
client closed
$./addressused2
SO_REUSEADDR套接字选项还有一个作用,那就是本机服务器如果有多个地址,可以在不同地址上使用相同的端口提供服务。
比如,一台服务器有192.168.1.101和10.10.2.102两个地址,我们可以在这台机器上启动三个不同的HTTP服务,第一个以本地通配地址ANY和端口80启动;第二个以192.168.101和端口80启动;第三个以10.10.2.102和端口80启动。
这样目的地址为192.168.101,目的端口为80的连接请求会被发往第二个服务;目的地址为10.10.2.102,目的端口为80的连接请求会被发往第三个服务;目的端口为80的所有其他连接请求被发往第一个服务。
我们必须给这三个服务设置SO_REUSEADDR套接字选项,否则第二个和第三个服务调用bind绑定到80端口时会出错。
最佳实践
这里的最佳实践可以总结成一句话: 服务器端程序,都应该设置SO_REUSEADDR套接字选项,以便服务端程序可以在极短时间内复用同一个端口启动。
有些人可能觉得这不是安全的。其实,单独重用一个套接字不会有任何问题。我在前面已经讲过,TCP连接是通过四元组唯一区分的,只要客户端不使用相同的源端口,连接服务器是没有问题的,即使使用了相同的端口,根据序列号或者时间戳,也是可以区分出新旧连接的。
而且,TCP的机制绝对不允许在相同的地址和端口上绑定不同的服务器,即使我们设置SO_REUSEADDR套接字选项,也不可能在ANY通配符地址下和端口9527上重复启动两个服务器实例。如果我们启动第二个服务器实例,不出所料会得到 Address already in use 的报错,即使当前还没有任何一条有效TCP连接产生。
比如下面就是第二次运行服务器端程序的报错信息:
$./addressused2
bind faied: Address already in use(98)
你可能还记得 第10讲 中,我们提到过一个叫做tcp_tw_reuse的内核配置选项,这里又提到了SO_REUSEADDR套接字选择,你会不会觉得两个有点混淆呢?
其实,这两个东西一点关系也没有。
- tcp_tw_reuse是内核选项,主要用在连接的发起方。TIME_WAIT状态的连接创建时间超过1秒后,新的连接才可以被复用,注意,这里是连接的发起方;
- SO_REUSEADDR是用户态的选项,SO_REUSEADDR选项用来告诉操作系统内核,如果端口已被占用,但是TCP连接状态位于TIME_WAIT ,可以重用端口。如果端口忙,而TCP处于其他状态,重用端口时依旧得到“Address already in use”的错误信息。注意,这里一般都是连接的服务方。
总结
今天我们分析了“Address already in use”产生的原因和解决方法。你只要记住一句话, 在所有TCP服务器程序中,调用bind之前请设置SO_REUSEADDR套接字选项。这不会产生危害,相反,它会帮助我们在很快时间内重启服务端程序,而这一点恰恰是很多场景所需要的。
15 TCP中的”流”
TCP是一种流式协议
在前面的章节中,都是单个客户端-服务器的例子,好像TCP是一种应答形式的数据传输过程,比如发送端一次发送network和program这样的报文,在前面的例子中,我们看到的结果基本是这样的:
发送端:network —-> 接收端回应:Hi, network
发送端:program —–> 接收端回应:Hi, program
这其实是一个假象,之所以会这样,是因为网络条件比较好,而且发送的数据也比较少。
为了让大家理解TCP数据是流式的这个特性,我们分别从发送端和接收端来阐述。
我们知道,在发送端,当我们调用send函数完成数据“发送”以后,数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了操作系统内核协议栈中,至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。也就是说,我们不能假设每次send调用发送的数据,都会作为一个整体完整地被发送出去。
如果我们考虑实际网络传输过程中的各种影响,假设发送端陆续调用send函数先后发送network和program报文,那么实际的发送很有可能是这个样子的。
第一种情况,一次性将network和program在一个TCP分组中发送出去,像这样:
...xxxnetworkprogramxxx...
第二种情况,program的部分随network在一个TCP分组中发送出去,像这样:
TCP分组1:
...xxxxxnetworkpro
TCP分组2:
gramxxxxxxxxxx...
第三种情况,network的一部分随TCP分组被发送出去,另一部分和program一起随另一个TCP分组发送出去,像这样。
TCP分组1:
...xxxxxxxxxxxnet
TCP分组2:
workprogramxxx...
实际上类似的组合可以枚举出无数种。不管是哪一种,核心的问题就是,我们不知道network和program这两个报文是如何进行TCP分组传输的。换言之,我们在发送数据的时候,不应该假设“数据流和TCP分组是一种映射关系”。就好像在前面,我们似乎觉得network这个报文一定对应一个TCP分组,这是完全不正确的。
如果我们再来看客户端,数据流的特征更明显。
我们知道,接收端缓冲区保留了没有被取走的数据,随着应用程序不断从接收端缓冲区读出数据,接收端缓冲区就可以容纳更多新的数据。如果我们使用recv从接收端缓冲区读取数据,发送端缓冲区的数据是以字节流的方式存在的,无论发送端如何构造TCP分组,接收端最终收到的字节流总是像下面这样:
xxxxxxxxxxxxxxxxxnetworkprogramxxxxxxxxxxxx
关于接收端字节流,有两点需要注意:
第一,这里netwrok和program的顺序肯定是会保持的,也就是说,先调用send函数发送的字节,总在后调用send函数发送字节的前面,这个是由TCP严格保证的;
第二,如果发送过程中有TCP分组丢失,但是其后续分组陆续到达,那么TCP协议栈会缓存后续分组,直到前面丢失的分组到达,最终,形成可以被应用程序读取的数据流。
网络字节排序
我们知道计算机最终保存和传输,用的都是0101这样的二进制数据,字节流在网络上的传输,也是通过二进制来完成的。
从二进制到字节是通过编码完成的,比如著名的ASCII编码,通过一个字节8个比特对常用的西方字母进行了编码。
这里有一个有趣的问题,如果需要传输数字,比如0x0201,对应的二进制为0000001000000001,那么两个字节的数据到底是先传0x01,还是相反?
在计算机发展的历史上,对于如何存储这个数据没有形成标准。比如这里讲到的问题,不同的系统就会有两种存法,一种是将0x02高字节存放在起始地址,这个叫做 大端字节序(Big-Endian)。另一种相反,将0x01低字节存放在起始地址,这个叫做 小端字节序(Little-Endian)。
但是在网络传输中,必须保证双方都用同一种标准来表达,这就好比我们打电话时说的是同一种语言,否则双方不能顺畅地沟通。这个标准就涉及到了网络字节序的选择问题,对于网络字节序,必须二选一。我们可以看到网络协议使用的是大端字节序,我个人觉得大端字节序比较符合人类的思维习惯,你可以想象手写一个多位数字,从开始往小位写,自然会先写大位,比如写12, 1234,这个样子。
为了保证网络字节序一致,POSIX标准提供了如下的转换函数:
uint16_t htons (uint16_t hostshort)
uint16_t ntohs (uint16_t netshort)
uint32_t htonl (uint32_t hostlong)
uint32_t ntohl (uint32_t netlong)
这里函数中的n代表的就是network,h代表的是host,s表示的是short,l表示的是long,分别表示16位和32位的整数。
这些函数可以帮助我们在主机(host)和网络(network)的格式间灵活转换。当使用这些函数时,我们并不需要关心主机到底是什么样的字节顺序,只要使用函数给定值进行网络字节序和主机字节序的转换就可以了。
你可以想象,如果碰巧我们的系统本身是大端字节序,和网络字节序一样,那么使用上述所有的函数进行转换的时候,结果都仅仅是一个空实现,直接返回。
比如这样:
# if __BYTE_ORDER == __BIG_ENDIAN
/* The host byte order is the same as network byte order,
so these functions are all just identity. */
# define ntohl(x) (x)
# define ntohs(x) (x)
# define htonl(x) (x)
# define htons(x) (x)
报文读取和解析
应该看到,报文是以字节流的形式呈现给应用程序的,那么随之而来的一个问题就是,应用程序如何解读字节流呢?
这就要说到报文格式和解析了。报文格式实际上定义了字节的组织形式,发送端和接收端都按照统一的报文格式进行数据传输和解析,这样就可以保证彼此能够完成交流。
只有知道了报文格式,接收端才能针对性地进行报文读取和解析工作。
报文格式最重要的是如何确定报文的边界。常见的报文格式有两种方法,一种是发送端把要发送的报文长度预先通过报文告知给接收端;另一种是通过一些特殊的字符来进行边界的划分。
显式编码报文长度
报文格式
下面我们来看一个例子,这个例子是把要发送的报文长度预先通过报文告知接收端:
由图可以看出,这个报文的格式很简单,首先4个字节大小的消息长度,其目的是将真正发送的字节流的大小显式通过报文告知接收端,接下来是4个字节大小的消息类型,而真正需要发送的数据则紧随其后。
发送报文
发送端的程序如下:
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: tcpclient <IPaddress>");
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
if (connect_rt < 0) {
error(1, errno, "connect failed ");
}
struct {
u_int32_t message_length;
u_int32_t message_type;
char buf[128];
} message;
int n;
while (fgets(message.buf, sizeof(message.buf), stdin) != NULL) {
n = strlen(message.buf);
message.message_length = htonl(n);
message.message_type = 1;
if (send(socket_fd, (char *) &message, sizeof(message.message_length) + sizeof(message.message_type) + n, 0) <
0)
error(1, errno, "send failure");
}
exit(0);
}
程序的1-20行是常规的创建套接字和地址,建立连接的过程。我们重点往下看,21-25行就是图示的报文格式转化为结构体,29-37行从标准输入读入数据,分别对消息长度、类型进行了初始化,注意这里使用了htonl函数将字节大小转化为了网络字节顺序,这一点很重要。最后我们看到23行实际发送的字节流大小为消息长度4字节,加上消息类型4字节,以及标准输入的字符串大小。
解析报文:程序
下面给出的是服务器端的程序,和客户端不一样的是,服务器端需要对报文进行解析。
static int count;
static void sig_int(int signo) {
printf("\nreceived %d datagrams\n", count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}
char buf[128];
count = 0;
while (1) {
int n = read_message(connfd, buf, sizeof(buf));
if (n < 0) {
error(1, errno, "error read message");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
buf[n] = 0;
printf("received %d bytes: %s\n", n, buf);
count++;
}
exit(0);
}
这个程序1-41行创建套接字,等待连接建立部分和前面基本一致。我们重点看42-55行的部分。45-55行循环处理字节流,调用read_message函数进行报文解析工作,并把报文的主体通过标准输出打印出来。
解析报文:readn函数
在了解read_message工作原理之前,我们先来看第5讲就引入的一个函数:readn。这里一定要强调的是readn函数的语义, 读取报文预设大小的字节,readn调用会一直循环,尝试读取预设大小的字节,如果接收缓冲区数据空,readn函数会阻塞在那里,直到有数据到达。
size_t readn(int fd, void *buffer, size_t length) {
size_t count;
ssize_t nread;
char *ptr;
ptr = buffer;
count = length;
while (count > 0) {
nread = read(fd, ptr, count);
if (nread < 0) {
if (errno == EINTR)
continue;
else
return (-1);
} else if (nread == 0)
break; /* EOF */
count -= nread;
ptr += nread;
}
return (length - count); /* return >= 0 */
}
readn函数中使用count来表示还需要读取的字符数,如果count一直大于0,说明还没有满足预设的字符大小,循环就会继续。第9行通过read函数来服务最多count个字符。11-17行针对返回值进行出错判断,其中返回值为0的情形是EOF,表示对方连接终止。19-20行要读取的字符数减去这次读到的字符数,同时移动缓冲区指针,这样做的目的是为了确认字符数是否已经读取完毕。
解析报文: read_message函数
有了readn函数作为基础,我们再看一下read_message对报文的解析处理:
size_t read_message(int fd, char *buffer, size_t length) {
u_int32_t msg_length;
u_int32_t msg_type;
int rc;
rc = readn(fd, (char *) &msg_length, sizeof(u_int32_t));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
msg_length = ntohl(msg_length);
rc = readn(fd, (char *) &msg_type, sizeof(msg_type));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
if (msg_length > length) {
return -1;
}
rc = readn(fd, buffer, msg_length);
if (rc != msg_length)
return rc < 0 ? -1 : 0;
return rc;
}
在这个函数中,第6行通过调用readn函数获取4个字节的消息长度数据,紧接着,第11行通过调用readn函数获取4个字节的消息类型数据。第15行判断消息的长度是不是太大,如果大到本地缓冲区不能容纳,则直接返回错误;第19行调用readn一次性读取已知长度的消息体。
实验
我们依次启动作为报文解析的服务器一端,以及作为报文发送的客户端。我们看到,每次客户端发送的报文都可以被服务器端解析出来,在标准输出上的结果验证了这一点。
$./streamserver
received 8 bytes: network
received 5 bytes: good
$./streamclient
network
good
特殊字符作为边界
前面我提到了两种报文格式,另外一种报文格式就是通过设置特殊字符作为报文边界。HTTP是一个非常好的例子。
HTTP通过设置回车符、换行符作为HTTP报文协议的边界。
下面的read_line函数就是在尝试读取一行数据,也就是读到回车符 \r
,或者读到回车换行符 \r\n
为止。这个函数每次尝试读取一个字节,第9行如果读到了回车符 \r
,接下来在11行的“观察”下看有没有换行符,如果有就在第12行读取这个换行符;如果没有读到回车符,就在第16-17行将字符放到缓冲区,并移动指针。
int read_line(int fd, char *buf, int size) {
int i = 0;
char c = '\0';
int n;
while ((i < size - 1) && (c != '\n')) {
n = recv(fd, &c, 1, 0);
if (n > 0) {
if (c == '\r') {
n = recv(fd, &c, 1, MSG_PEEK);
if ((n > 0) && (c == '\n'))
recv(fd, &c, 1, 0);
else
c = '\n';
}
buf[i] = c;
i++;
} else
c = '\n';
}
buf[i] = '\0';
return (i);
}
总结
和我们预想的不太一样,TCP数据流特性决定了字节流本身是没有边界的,一般我们通过显式编码报文长度的方式,以及选取特殊字符区分报文边界的方式来进行报文格式的设计。而对报文解析的工作就是要在知道报文格式的情况下,有效地对报文信息进行还原。
思考题
第一道题关于HTTP的报文格式,我们看到,既要处理只有回车的情景,也要处理同时有回车和换行的情景,你知道造成这种情况的原因是什么吗?
因为Unix下的文件的行尾只有\n,而Windows下的文件行尾是\r\n,服务器端不知道客户端是谁。
第二道题是,我们这里讲到的报文格式,和TCP分组的报文格式,有什么区别和联系吗?
区别的话应该是所属层级不同吧,我们自己定义的报文格式是用于应用层,而TCP分组的报文格式是用于传输层;而联系就在于,我们自己定义的报文格式是包含在TCP分组的报文格式中的,即TCP分组报文去掉消息头之后,得到的消息体的格式就是我们自己定义的报文格式
16 TCP不总是可靠的
TCP是可靠的?
你可能会认为,TCP是一种可靠的协议,这种可靠体现在端到端的通信上。这似乎给我们带来了一种错觉,从发送端来看,应用程序通过调用send函数发送的数据流总能可靠地到达接收端;而从接收端来看,总是可以把对端发送的数据流完整无损地传递给应用程序来处理。
事实上,如果我们对TCP传输环节进行详细的分析,你就会沮丧地发现,上述论断是不正确的。
前面我们已经了解,发送端通过调用send函数之后,数据流并没有马上通过网络传输出去,而是存储在套接字的发送缓冲区中,由网络协议栈决定何时发送、如何发送。当对应的数据发送给接收端,接收端回应ACK,存储在发送缓冲区的这部分数据就可以删除了,但是,发送端并无法获取对应数据流的ACK情况,也就是说,发送端没有办法判断对端的接收方是否已经接收发送的数据流,如果需要知道这部分信息,就必须在应用层自己添加处理逻辑,例如显式的报文确认机制。
从接收端来说,也没有办法保证ACK过的数据部分可以被应用程序处理,因为数据需要接收端程序从接收缓冲区中拷贝,可能出现的状况是,已经ACK的数据保存在接收端缓冲区中,接收端处理程序突然崩溃了,这部分数据就没有办法被应用程序继续处理。
你有没有发现,TCP协议实现并没有提供给上层应用程序过多的异常处理细节,或者说,TCP协议反映链路异常的能力偏弱,这其实是有原因的。要知道,TCP诞生之初,就是为美国国防部服务的,考虑到军事作战的实际需要,TCP不希望暴露更多的异常细节,而是能够以无人值守、自我恢复的方式运作。
TCP连接建立之后,能感知TCP链路的方式是有限的,一种是以read为核心的读操作,另一种是以write为核心的写操作。接下来,我们就看下如何通过读写操作来感知异常情况,以及对应的处理方式。
故障模式总结
在实际情景中,我们会碰到各种异常的情况。在这里我把这几种异常情况归结为两大类:
第一类,是对端无FIN包发送出来的情况;第二类是对端有FIN包发送出来。而这两大类情况又可以根据应用程序的场景细分,接下来我们详细讨论。
网络中断造成的对端无FIN包
很多原因都会造成网络中断,在这种情况下,TCP程序并不能及时感知到异常信息。除非网络中的其他设备,如路由器发出一条ICMP报文,说明目的网络或主机不可达,这个时候通过read或write调用就会返回Unreachable的错误。
可惜大多数时候并不是如此,在没有ICMP报文的情况下,TCP程序并不能理解感应到连接异常。如果程序是阻塞在read调用上,那么很不幸,程序无法从异常中恢复。这显然是非常不合理的,不过,我们可以通过给read操作设置超时来解决。
如果程序先调用了write操作发送了一段数据流,接下来阻塞在read调用上,结果会非常不同。Linux系统的TCP协议栈会不断尝试将发送缓冲区的数据发送出去,大概在重传12次、合计时间约为9分钟之后,协议栈会标识该连接异常,这时,阻塞的read调用会返回一条TIMEOUT的错误信息。如果此时程序还执着地往这条连接写数据,写操作会立即失败,返回一个SIGPIPE信号给应用程序。
系统崩溃造成的对端无FIN包
当系统突然崩溃,如断电时,网络连接上来不及发出任何东西。这里和通过系统调用杀死应用程序非常不同的是,没有任何FIN包被发送出来。
这种情况和网络中断造成的结果非常类似,在没有ICMP报文的情况下,TCP程序只能通过read和write调用得到网络连接异常的信息,超时错误是一个常见的结果。
不过还有一种情况需要考虑,那就是系统在崩溃之后又重启,当重传的TCP分组到达重启后的系统,由于系统中没有该TCP分组对应的连接数据,系统会返回一个RST重置分节,TCP程序通过read或write调用可以分别对RST进行错误处理。
如果是阻塞的read调用,会立即返回一个错误,错误信息为连接重置(Connection Reset)。
如果是一次write操作,也会立即失败,应用程序会被返回一个SIGPIPE信号。
对端有FIN包发出
对端如果有FIN包发出,可能的场景是对端调用了close或shutdown显式地关闭了连接,也可能是对端应用程序崩溃,操作系统内核代为清理所发出的。从应用程序角度上看,无法区分是哪种情形。
阻塞的read操作在完成正常接收的数据读取之后,FIN包会通过返回一个EOF来完成通知,此时,read调用返回值为0。这里强调一点,收到FIN包之后read操作不会立即返回。你可以这样理解,收到FIN包相当于往接收缓冲区里放置了一个EOF符号,之前已经在接收缓冲区的有效数据不会受到影响。
为了展示这些特性,我分别编写了服务器端和客户端程序。
//服务端程序
int main(int argc, char **argv) {
int connfd;
char buf[1024];
connfd = tcp_server(SERV_PORT);
for (;;) {
int n = read(connfd, buf, 1024);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
sleep(5);
int write_nc = send(connfd, buf, n, 0);
printf("send bytes: %zu \n", write_nc);
if (write_nc < 0) {
error(1, errno, "error write");
}
}
exit(0);
}
服务端程序是一个简单的应答程序,在收到数据流之后回显给客户端,在此之前,休眠5秒,以便完成后面的实验验证。
客户端程序从标准输入读入,将读入的字符串传输给服务器端:
//客户端程序
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: reliable_client01 <IPaddress>");
}
int socket_fd = tcp_client(argv[1], SERV_PORT);
char buf[128];
int len;
int rc;
while (fgets(buf, sizeof(buf), stdin) != NULL) {
len = strlen(buf);
rc = send(socket_fd, buf, len, 0);
if (rc < 0)
error(1, errno, "write failed");
rc = read(socket_fd, buf, sizeof(buf));
if (rc < 0)
error(1, errno, "read failed");
else if (rc == 0)
error(1, 0, "peer connection closed\n");
else
fputs(buf, stdout);
}
exit(0);
}
read直接感知FIN包
我们依次启动服务器端和客户端程序,在客户端输入good字符之后,迅速结束掉服务器端程序,这里需要赶在服务器端从睡眠中苏醒之前杀死服务器程序。
屏幕上打印出:peer connection closed。客户端程序正常退出。
$./reliable_client01 127.0.0.1
$ good
$ peer connection closed
这说明客户端程序通过read调用,感知到了服务端发送的FIN包,于是正常退出了客户端程序。
注意如果我们的速度不够快,导致服务器端从睡眠中苏醒,并成功将报文发送出来后,客户端会正常显示,此时我们停留,等待标准输入。如果不继续通过read或write操作对套接字进行读写,是无法感知服务器端已经关闭套接字这个事实的。
通过write产生RST,read调用感知RST
这一次,我们仍然依次启动服务器端和客户端程序,在客户端输入bad字符之后,等待一段时间,直到客户端正确显示了服务端的回应“bad”字符之后,再杀死服务器程序。客户端再次输入bad2,这时屏幕上打印出”peer connection closed“。
这是这个案例的屏幕输出和时序图。
$./reliable_client01 127.0.0.1
$bad
$bad
$bad2
$peer connection closed
在很多书籍和文章中,对这个程序的解读是,收到FIN包的客户端继续合法地向服务器端发送数据,服务器端在无法定位该TCP连接信息的情况下,发送了RST信息,当程序调用read操作时,内核会将RST错误信息通知给应用程序。这是一个典型的write操作造成异常,再通过read操作来感知异常的样例。
不过,我在Linux 4.4内核上实验这个程序,多次的结果都是,内核正常将EOF信息通知给应用程序,而不是RST错误信息。
我又在Max OS 10.13.6上尝试这个程序,read操作可以返回RST异常信息。输出和时序图也已经给出。
$./reliable_client01 127.0.0.1
$bad
$bad
$bad2
$read failed: Connection reset by peer (54)
向一个已关闭连接连续写,最终导致SIGPIPE
为了模拟这个过程,我对服务器端程序和客户端程序都做了如下修改。
nt main(int argc, char **argv) {
int connfd;
char buf[1024];
int time = 0;
connfd = tcp_server(SERV_PORT);
while (1) {
int n = read(connfd, buf, 1024);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
time++;
fprintf(stdout, "1K read for %d \n", time);
usleep(1000);
}
exit(0);
}
服务器端每次读取1K数据后休眠1秒,以模拟处理数据的过程。
客户端程序在第8行注册了SIGPIPE的信号处理程序,在第14-22行客户端程序一直循环发送数据流。
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: reliable_client02 <IPaddress>");
}
int socket_fd = tcp_client(argv[1], SERV_PORT);
signal(SIGPIPE, SIG_IGN);
char *msg = "network programming";
ssize_t n_written;
int count = 10000000;
while (count > 0) {
n_written = send(socket_fd, msg, strlen(msg), 0);
fprintf(stdout, "send into buffer %ld \n", n_written);
if (n_written <= 0) {
error(1, errno, "send error");
return -1;
}
count--;
}
return 0;
}
如果在服务端读取数据并处理过程中,突然杀死服务器进程,我们会看到客 户端很快也会退出,并在屏幕上打印出“Connection reset by peer”的提示。
$./reliable_client02 127.0.0.1
$send into buffer 5917291
$send into buffer -1
$send: Connection reset by peer
这是因为服务端程序被杀死之后,操作系统内核会做一些清理的事情,为这个套接字发送一个FIN包,但是,客户端在收到FIN包之后,没有read操作,还是会继续往这个套接字写入数据。这是因为根据TCP协议,连接是双向的,收到对方的FIN包只意味着 对方不会再发送任何消息。 在一个双方正常关闭的流程中,收到FIN包的一端将剩余数据发送给对面(通过一次或多次write),然后关闭套接字。
当数据到达服务器端时,操作系统内核发现这是一个指向关闭的套接字,会再次向客户端发送一个RST包,对于发送端而言如果此时再执行write操作,立即会返回一个RST错误信息。
你可以看到针对这个全过程的一张描述图,你可以参考这张图好好理解一下这个过程。
以上是在Linux 4.4内核上测试的结果。
在很多书籍和文章中,对这个实验的期望结果不是这样的。大部分的教程是这样说的:在第二次write操作时,由于服务器端无法查询到对应的TCP连接信息,于是发送了一个RST包给客户端,客户端第二次操作时,应用程序会收到一个SIGPIPE信号。如果不捕捉这个信号,应用程序会在毫无征兆的情况下直接退出。
我在Max OS 10.13.6上尝试这个程序,得到的结果确实如此。你可以看到屏幕显示和时序图。
#send into buffer 19
#send into buffer -1
#send error: Broken pipe (32)
这说明,Linux4.4的实现和类BSD的实现已经非常不一样了。限于时间的关系,我没有仔细对比其他版本的Linux,还不清楚是新的内核特性,但有一点是可以肯定的,我们需要记得为SIGPIPE注册处理函数,通过write操作感知RST的错误信息,这样可以保证我们的应用程序在Linux 4.4和Mac OS上都能正常处理异常。
总结
在这一讲中,我们意识到TCP并不是那么“可靠”的。我把故障分为两大类,一类是对端无FIN包,需要通过巡检或超时来发现;另一类是对端有FIN包发出,需要通过增强read或write操作的异常处理,帮助我们发现此类异常。
17 检查数据的有效性
对端的异常状况
一些防范对端异常的方法,比如,通过read等调用时,可以通过对EOF的判断,随时防范对方程序崩溃。
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
error(1, errno, "error read message");
} else if (nBytes == 0) {
error(1, 0, "client closed \n");
}
你可以看到这一个程序中的第4行,当调用read函数返回0字节时,实际上就是操作系统内核返回EOF的一种反映。如果是服务器端同时处理多个客户端连接,一般这里会调用shutdown关闭连接的这一端。
上一讲也讲到了,不是每种情况都可以通过读操作来感知异常,比如,服务器完全崩溃,或者网络中断的情况下,此时,如果是阻塞套接字,会一直阻塞在read等调用上,没有办法感知套接字的异常。
其实有几种办法来解决这个问题。
第一个办法是给套接字的read操作设置超时,如果超过了一段时间就认为连接已经不存在。具体的代码片段如下:
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
setsockopt(connfd, SOL_SOCKET, SO_RCVTIMEO, (const char *) &tv, sizeof tv);
while (1) {
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("read timeout\n");
onClientTimeout(connfd);
} else {
error(1, errno, "error read message");
}
} else if (nBytes == 0) {
error(1, 0, "client closed \n");
}
...
}
这个代码片段在第4行调用setsockopt函数,设置了套接字的读操作超时,超时时间为在第1-3行设置的5秒,当然在这里这个时间值是“拍脑袋”设置的,比较科学的设置方法是通过一定的统计之后得到一个比较合理的值。关键之处在读操作返回异常的第9-11行,根据出错信息是 EAGAIN
或者 EWOULDBLOCK
,判断出超时,转而调用 onClientTimeout
函数来进行处理。
这个处理方式虽然比较简单,却很实用,很多FTP服务器端就是这么设计的。连接这种FTP服务器之后,如果FTP的客户端没有续传的功能,在碰到网络故障或服务器崩溃时就会挂断。
第二个办法是第12讲中提到的办法,添加对连接是否正常的检测。如果连接不正常,需要从当前read阻塞中返回并处理。
还有一个办法,前面第12讲也提到过,那就是利用多路复用技术自带的超时能力,来完成对套接字I/O的检查,如果超过了预设的时间,就进入异常处理。
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
FD_ZERO(&allreads);
FD_SET(socket_fd, &allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv);
if (rc < 0) {
error(1, errno, "select failed");
}
if (rc == 0) {
printf("read timeout\n");
onClientTimeout(socket_fd);
}
...
}
这段代码使用了select多路复用技术来对套接字进行I/O事件的轮询,程序的13行是到达超时后的处理逻辑,调用 onClientTimeout
函数来进行超时后的处理。
缓冲区处理
一个设计良好的网络程序,应该可以在随机输入的情况下表现稳定。不仅是这样,随着互联网的发展,网络安全也愈发重要,我们编写的网络程序能不能在黑客的刻意攻击之下表现稳定,也是一个重要考量因素。
很多黑客程序,会针对性地构建出一定格式的网络协议包,导致网络程序产生诸如缓冲区溢出、指针异常的后果,影响程序的服务能力,严重的甚至可以夺取服务器端的控制权,随心所欲地进行破坏活动,比如著名的SQL注入,就是通过针对性地构造出SQL语句,完成对数据库敏感信息的窃取。
所以,在网络程序的编写过程中,我们需要时时刻刻提醒自己面对的是各种复杂异常的场景,甚至是别有用心的攻击者,保持“防人之心不可无”的警惕。
那么程序都有可能出现哪几种漏洞呢?
第一个例子
char Response[] = "COMMAND OK";
char buffer[128];
while (1) {
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
error(1, errno, "error read message");
} else if (nBytes == 0) {
error(1, 0, "client closed \n");
}
buffer[nBytes] = '\0';
if (strcmp(buffer, "quit") == 0) {
printf("client quit\n");
send(socket, Response, sizeof(Response), 0);
}
printf("received %d bytes: %s\n", nBytes, buffer);
}
这段代码从连接套接字中获取字节流,并且判断了出差和EOF情况,如果对端发送来的字符是“quit”就回应“COMAAND OK”的字符流,乍看上去一切正常。
但仔细看一下,这段代码很有可能会产生下面的结果。
char buffer[128];
buffer[128] = '\0';
通过recv读取的字符数为128时,就会这样的结果。因为buffer的大小只有128字节,最后的赋值环节,产生了缓冲区溢出的问题。
所谓缓冲区溢出,是指计算机程序中出现的一种内存违规操作。本质是计算机程序向缓冲区填充的数据,超出了原本缓冲区设置的大小限制,导致了数据覆盖了内存栈空间的其他合法数据。这种覆盖破坏了原来程序的完整性,使用过游戏修改器的同学肯定知道,如果不小心修改错游戏数据的内存空间,很可能导致应用程序产生如“Access violation”的错误,导致应用程序崩溃。
我们可以对这个程序稍加修改,主要的想法是留下buffer里的一个字节,以容纳后面的 '\0'
。
int nBytes = recv(connfd, buffer, sizeof(buffer)-1, 0);
这个例子里面,还昭示了一个有趣的现象。你会发现我们发送过去的字符串,调用的是 sizeof
,那也就意味着,Response字符串中的 '\0'
是被发送出去的,而我们在接收字符时,则假设没有 '\0'
字符的存在。
为了统一,我们可以改成如下的方式,使用strlen的方式忽略最后一个 '\0'
字符。
send(socket, Response, strlen(Response), 0);
第二个例子
第16讲中提到了对变长报文解析的两种手段,一个是使用特殊的边界符号,例如HTTP使用的回车换行符;另一个是将报文信息的长度编码进入消息。
在实战中,我们也需要对这部分报文长度保持警惕。
size_t read_message(int fd, char *buffer, size_t length) {
u_int32_t msg_length;
u_int32_t msg_type;
int rc;
rc = readn(fd, (char *) &msg_length, sizeof(u_int32_t));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
msg_length = ntohl(msg_length);
rc = readn(fd, (char *) &msg_type, sizeof(msg_type));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
if (msg_length > length) {
return -1;
}
/* Retrieve the record itself */
rc = readn(fd, buffer, msg_length);
if (rc != msg_length)
return rc < 0 ? -1 : 0;
return rc;
}
在进行报文解析时,第15行对实际的报文长度 msg_length
和应用程序分配的缓冲区大小进行了比较,如果报文长度过大,导致缓冲区容纳不下,直接返回-1表示出错。千万不要小看这部分的判断,试想如果没有这个判断,对方程序发送出来的消息体,可能构建出一个非常大的 msg_length
,而实际发送的报文本体长度却没有这么大,这样后面的读取操作就不会成功,如果应用程序实际缓冲区大小比 msg_length
小,也产生了缓冲区溢出的问题。
struct {
u_int32_t message_length;
u_int32_t message_type;
char data[128];
} message;
int n = 65535;
message.message_length = htonl(n);
message.message_type = 1;
char buf[128] = "just for fun\0";
strncpy(message.data, buf, strlen(buf));
if (send(socket_fd, (char *) &message,
sizeof(message.message_length) + sizeof(message.message_type) + strlen(message.data), 0) < 0)
error(1, errno, "send failure");
就是这样一段发送端“不小心”构造的一个程序,消息的长度“不小心”被设置为65535长度,实际发送的报文数据为“just for fun”。在去掉实际的报文长度 msg_length
和应用程序分配的缓冲区大小做比较之后,服务器端一直阻塞在read调用上,这是因为服务器端误认为需要接收65535大小的字节。
第三个例子
如果我们需要开发一个函数,这个函数假设报文的分界符是换行符(\n),一个简单的想法是每次读取一个字符,判断这个字符是不是换行符。
这里有一个这样的函数,这个函数的最大问题是工作效率太低,要知道每次调用recv函数都是一次系统调用,需要从用户空间切换到内核空间,上下文切换的开销对于高性能来说最好是能省则省。
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
char c;
while (length > 0 && recv(fd, &c, 1, 0) == 1) {
*buffer++ = c;
length--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
于是,就有了第二个版本,这个函数一次性读取最多512字节到临时缓冲区,之后将临时缓冲区的字符一个一个拷贝到应用程序最终的缓冲区中,这样的做法明显效率会高很多。
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
static char *buffer_pointer;
int nleft = 0;
static char read_buffer[512];
char c;
while (length-- > 0) {
if (nleft <= 0) {
int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
if (nread < 0) {
if (errno == EINTR) {
length++;
continue;
}
return -1;
}
if (nread == 0)
return 0;
buffer_pointer = read_buffer;
nleft = nread;
}
c = *buffer_pointer++;
*buffer++ = c;
nleft--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
这个程序的主循环在第8行,通过对length变量的判断,试图解决缓冲区长度溢出问题;第9行是判断临时缓冲区的字符有没有被全部拷贝完,如果被全部拷贝完,就会再次尝试读取最多512字节;第20-21行在读取字符成功之后,重置了临时缓冲区读指针、临时缓冲区待读的字符个数;第23-25行则是在拷贝临时缓冲区字符,每次拷贝一个字符,并移动临时缓冲区读指针,对临时缓冲区待读的字符个数进行减1操作。在程序的26-28行,判断是否读到换行符,如果读到则将应用程序最终缓冲区截断,返回最终读取的字符个数。
这个程序运行起来可能很久都没有问题,但是,它还是有一个微小的瑕疵,这个瑕疵很可能会造成线上故障。
为了讲清这个故障,我们假设这样调用, 输入的字符为 012345678\n
。
//输入字符为: 012345678\n
char buf[10]
readline(fd, buf, 10)
当读到最后一个\n字符时,length为1,问题是在第26行和27行,如果读到了换行符,就会增加一个字符串截止符,这显然越过了应用程序缓冲区的大小。
这是正确的程序,这里最关键的是需要先对length进行处理,再去判断length的大小是否可以容纳下字符。
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
static char *buffer_pointer;
int nleft = 0;
static char read_buffer[512];
char c;
while (--length> 0) {
if (nleft <= 0) {
int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
if (nread < 0) {
if (errno == EINTR) {
length++;
continue;
}
return -1;
}
if (nread == 0)
return 0;
buffer_pointer = read_buffer;
nleft = nread;
}
c = *buffer_pointer++;
*buffer++ = c;
nleft--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
18 如何理解TCP的四次挥手
如何理解TCP四次挥手?
TCP建立一个连接需3次握手,而终止一个连接则需要四次挥手。四次挥手的整个过程是这样的:
首先,一方应用程序调用close,我们称该方为主动关闭方,该端的TCP发送一个FIN包,表示需要关闭连接。之后主动关闭方进入FIN_WAIT_1状态。
接着,接收到这个FIN包的对端执行被动关闭。这个FIN由TCP协议栈处理,我们知道,TCP协议栈为FIN包插入一个文件结束符EOF到接收缓冲区中,应用程序可以通过read调用来感知这个FIN包。一定要注意,这个EOF会被放在 已排队等候的其他已接收的数据之后,这就意味着接收端应用程序需要处理这种异常情况,因为EOF表示在该连接上再无额外数据到达。此时,被动关闭方进入CLOSE_WAIT状态。
接下来,被动关闭方将读到这个EOF,于是,应用程序也调用close关闭它的套接字,这导致它的TCP也发送一个FIN包。这样,被动关闭方将进入LAST_ACK状态。
最终,主动关闭方接收到对方的FIN包,并确认这个FIN包。主动关闭方进入TIME_WAIT状态,而接收到ACK的被动关闭方则进入CLOSED状态。经过2MSL时间之后,主动关闭方也进入CLOSED状态。
你可以看到,每个方向都需要一个FIN和一个ACK,因此通常被称为四次挥手。
当然,这中间使用shutdown,执行一端到另一端的半关闭也是可以的。
当套接字被关闭时,TCP为其所在端发送一个FIN包。在大多数情况下,这是由应用进程调用close而发生的,值得注意的是,一个进程无论是正常退出(exit或者main函数返回),还是非正常退出(比如,收到SIGKILL信号关闭,就是我们常常干的kill -9),所有该进程打开的描述符都会被系统关闭,这也导致TCP描述符对应的连接上发出一个FIN包。
无论是客户端还是服务器,任何一端都可以发起主动关闭。大多数真实情况是客户端执行主动关闭,你可能不会想到的是,HTTP/1.0却是由服务器发起主动关闭的。
最大分组 MSL是TCP 分组在网络中存活的最长时间吗?
MSL是任何IP数据报能够在因特网中存活的最长时间。其实它的实现不是靠计时器来完成的,在每个数据报里都包含有一个被称为TTL(time to live)的8位字段,它的最大值为255。TTL可译为“生存时间”,这个生存时间由源主机设置初始值,它表示的是一个IP数据报可以经过的最大跳跃数,每经过一个路由器,就相当于经过了一跳,它的值就减1,当此值减为0时,则所在的路由器会将其丢弃,同时发送ICMP报文通知源主机。RFC793中规定MSL的时间为2分钟,Linux实际设置为30秒。
关于listen函数中参数backlog的释义问题
我们该如何理解listen函数中的参数backlog?如果backlog表示的是未完成连接队列的大小,那么已完成连接的队列的大小有限制吗?如果都是已经建立连接的状态,那么并发取决于已完成连接的队列的大小吗?
backlog的值含义从来就没有被严格定义过。原先Linux实现中,backlog参数定义了该套接字对应的未完成连接队列的最大长度 (pending connections)。如果一个连接到达时,该队列已满,客户端将会接收一个ECONNREFUSED的错误信息,如果支持重传,该请求可能会被忽略,之后会进行一次重传。
从Linux 2.2开始,backlog的参数内核有了新的语义,它现在定义的是已完成连接队列的最大长度,表示的是已建立的连接(established connection),正在等待被接收(accept调用返回),而不是原先的未完成队列的最大长度。现在,未完成队列的最大长度值可以通过 /proc/sys/net/ipv4/tcp_max_syn_backlog完成修改,默认值为128。
至于已完成连接队列,如果声明的backlog参数比 /proc/sys/net/core/somaxconn的参数要大,那么就会使用我们声明的那个值。实际上,这个默认的值为128。注意在Linux 2.4.25之前,这个值是不可以修改的一个固定值,大小也是128。
设计良好的程序,在128固定值的情况下也是可以支持成千上万的并发连接的,这取决于I/O分发的效率,以及多线程程序的设计。在后面的性能篇里,我们的目标就是设计这样的程序。
UDP连接和断开套接字的过程是怎样的?
UDP连接套接字不是发起连接请求的过程,而是记录目的地址和端口到套接字的映射关系。
断开套接字则相反,将删除原来记录的映射关系。
在UDP中不进行connect,为什么客户端会收到信息?
UDP只有connect才建立socket和IP地址的映射,那么如果不进行connect,收到信息后内核又如何把数据交给对应的socket?
其实呢,这对应了两个不同的API场景。
第一个场景就是我这里讨论的connect场景,在这个场景里,我们讨论的是ICMP报文和socket之间的定位。我们知道,ICMP报文发送的是一个不可达的信息,不可达的信息是通过 目的地址和端口 来区分的,如果没有connect操作, 目的地址和端口 就没有办法和socket套接字进行对应,所以,即使收到了ICMP报文,内核也没有办法通知到对应的应用程序,告诉它连接地址不可达。
那么为什么在不connect的情况下,我们的客户端又可以收到服务器回显的信息了?
这就涉及到了第二个场景,也就是报文发送的场景。注意服务器端程序,先通过recvfrom函数调用获取了客户端的地址和端口信息,这当然是可以的,因为UDP报文里面包含了这部分信息。然后我们看到服务器端又通过调用sendto函数,把客户端的地址和端口信息告诉了内核协议栈,可以肯定的是,之后发送的UDP报文就带上了 客户端的地址和端口信息,通过客户端的地址和端口信息,可以找到对应的套接字和应用程序,完成数据的收发。
//服务器端程序,先通过recvfrom函数调用获取了客户端的地址和端口信息
int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &client_addr, &client_len);
message[n] = 0;
printf("received %d bytes: %s\n", n, message);
char send_line[MAXLINE];
sprintf(send_line, "Hi, %s", message);
//服务器端程序调用send函数,把客户端的地址和端口信息告诉了内核
sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &client_addr, client_len);
从代码中可以看到,这里的connect的作用是记录 客户端目的地址和端口–套接字 的关系,而之所以能正确收到从服务器端发送的报文,那是因为系统已经记录了 客户端源地址和端口–套接字 的映射关系。
我们是否可以对一个 UDP套接字进行多次connect的操作?
我们知道,对于TCP套接字,connect只能调用一次。但是,对一个UDP套接字来说,进行多次connect操作是被允许的,这样主要有两个作用。
第一个作用是可以重新指定新的IP地址和端口号;第二个作用是可以断开一个已连接的套接字。为了断开一个已连接的UDP套接字,第二次调用connect时,调用方需要把套接字地址结构的地址族成员设置为AF_UNSPEC。
第11讲中程序和时序图的解惑
在11讲中,我们讲了关闭连接的几种方式,有同学对这一篇文章中的程序和时序图存在疑惑,并提出了下面几个问题:
- 代码运行结果是先显示hi data1,之后才接收到标准输入的close,为什么时序图中画的是先close才接收到hi data1?
- 当一方主动close之后,另一方发送数据的时候收到RST。主动方缓冲区会把这个数据丢弃吗?这样的话,应用层应该读不到了吧?
- 代码中SIGPIPE的作用不是忽略吗?为什么服务器端会退出?
- 主动调用socket的那方关闭了写端,但是还没关闭读端,这时候socket再读到数据是不是就是RST?然后再SIGPIPE?如果是这样的话,为什么不一次性把读写全部关闭呢?
我还是再仔细讲一下这个程序和时序图。
首先回答问题1。针对close这个例子,时序图里画的close表示的是客户端发起的close调用。
关于问题2,“Hi, data1”确实是不应该被接收到的,这个数据报即使发送出去也会收到RST回执,应用层是读不到的。
关于问题3中SIGPIPE的作用,事实上,默认的SIGPIPE忽略行为就是退出程序,什么也不做,当然,实际程序还是要做一些清理工作的。
问题4的理解是错误的。第二个例子也显示了,如果主动关闭的一方调用shutdown关闭,没有关闭读这一端,主动关闭的一方可以读到对端的数据,注意这个时候主动关闭连接的一方是在使用read方法进行读操作,而不是write写操作,不会有RST的发生,更不会有SIGPIPE的发生。