前一阵子遇到一个奇怪的问题,分析了很久,最后查阅了一些资料,找到了问题的原因,是TCP的backlog机制的原因。首先描述一下重现问题的现象和过程: 构建一个TCP的服务端,监听端口4321,只监听请求,不accept,客户端不断发起连接,观察TCP连接建立的情况。服务端程序代码如下:
#include #include #include #include #include #define PORT 4321 #define BACKLOG 6 #define MAXRECVLEN 1024 int main( int argc, char *argv[] ) { char buf[MAXRECVLEN]; int listenfd, connectfd; /* socket descriptors */ struct sockaddr_in server; /* server's address information */ struct sockaddr_in client; /* client's address information */ socklen_t addrlen; int i = 0; /* Create TCP socket */ if ( (listenfd = socket( AF_INET, SOCK_STREAM, 0 ) ) == -1 ) { /* handle exception */ perror( "socket() error. Failed to initiate a socket" ); exit( 1 ); } /* set socket option */ int opt = SO_REUSEADDR; setsockopt( listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt) ); bzero( &server, sizeof(server) ); server.sin_family = AF_INET; server.sin_port = htons( PORT ); server.sin_addr.s_addr = htonl( INADDR_ANY ); if ( bind( listenfd, (struct sockaddr *) &server, sizeof(server) ) == -1 ) { /* handle exception */ perror( "Bind() error." ); exit( 1 ); } if ( listen( listenfd, BACKLOG ) == -1 ) { perror( "listen() error. \n" ); exit( 1 ); } printf( "started.\n" ); getchar(); return(0); }
验证环境信息如下: 服务端信息:172.16.128.105,监听端口 4321
客户端信息:22.22.22.6 端口 49992,因为客户端与服务端不在一个网络下,因此在路由器上面配置目的端穿透,从22.22.22.4:4321 转发到172.16.128.105:4321
客户端不断使用并发的telnet命令发起多个请求,相当于不断建立TCP连接,消耗服务端的连接资源。当正常的ESTABLISHED的连接数达到一定数目之后,会出现SYN_RECV状态的连接。如下图所示: 服务端连接状态,可以看到49992端口的连接处于半连接的状态:SYN_RECV,查阅了此状态,表明服务端收到了SYN,但是还未回复SYN_ACK。
再看客户端的连接状态,客户端认为该连接处于ESTABLISHED状态,但是发送队列中有79字节的待发送数据。那就是说客户端认为建立TCP连接的3次握手已经完成了。
继续通过抓包分析:
先看服务端抓包,1、2、3号包显示服务端已经完成了3次握手,并且还收到了客户端发来的HTTP请求的数据。但是没有给客户端回复ACK,因此马上就收到了客户端重传的145字节的数据,也就是包5。紧接着,看6号包,服务端重传SYN_ACK包,分析这个包,发现重传的就是2号包,3次握手不是已经正常完成了么?这里却还在重传握手包。无法解释。
再看客户端的抓包数据,与服务端无差异。因此可以排除路由丢包的问题。网络是通畅的。
分析服务端应用程序代码,发现能够影响TCP连接建立的参数只有listen函数的backlog值,调整此参数,问题非常容易重现。查阅《Unix 网络编程卷1》关于backlog的解释,卷1中用了几页篇幅讲解了backlog的基本原理,大意是内核维持了两个连接队列,一个是已经完成3次握手但还没有被应用程序取走(调用accept)的连接的队列,我们先称之为队列1,还有一个是当队列1满了之后,内核还会继续维持另外一个队列,在此队列中的请求处于半连接状态,我们称为队列2。也就是上面连接状态中的SYN_RECV状态的连接。不同的系统,队列长度的控制机制不一样,但是几乎所有的系统都实现了这样的两个队列。 处于队列2中的连接,会根据内核参数指定的行为来决定如何处理这个队列中的连接的进一步行为:
root@ceph1:~/testc# cat /proc/sys/net/ipv4/tcp_abort_on_overflow 0
0表示忽略客户端发过来的3次握手的最后一个ACK,1表示直接回复RST。因此这样就可以解释上面的抓包数据了,服务端忽略了ACK,内核对于没有收到ACK的包会有一个重传的机制,因此服务端在不断重传SYN_ACK,这就能解释服务端的抓包行为了。那么对于客户端呢,客户端认为最后一个ACK服务端已经收到了,3次握手完成,开始发送数据,但是由于服务端一直没有回复此数据包的ACK,因此由TCP的慢启动机制,客户端不会发送更多的数据,只不断重传该包。
服务端的内核这样做有什么好处呢?我们知道一般来说服务端应用程序会不断取走队列1中的连接,即调用accept函数,这样连接1就会空出位置来,连接2中的请求就可以在下一次重传SYN_ACK,并且收到客户端的ACK后,将该连接移入队列1中。进而该请求继续正常运行。利用TCP的重传机制来等待一段时间,不至于让连接中断。 继续查阅其他资料,对于两个队列的长度,一般认为: listen函数的backlog,指定了已经完成3次握手,但是还没有被accept的连接的队列长度。
内核参数:/proc/sys/net/ipv4/tcp_max_syn_backlog指定了半连接队列的长度,即在全连接队列满的情况下继续维持收到SYN状态的连接的数目。
实测,二者的数值都有一些出入,但是相差不大。也有文章从内核源码分析上面给出了这两个队列的具体的控制原理,见参考资料中的第三个连接的内容。