TCP半连接队列和全连接

概述

 
  如上图所示, 在TCP三次握手中,服务器维护一个半连接队列(sync queue) 和一个全连接队列(accept queue)。 
当服务端接收到客户端第一次SYN握手请求时,将创建的request_sock结构,存储在半连接队列中(向客户端发送SYN+ACK,并期待客户端响应ACK),此时的连接在服务器端出于SYN_RECV状态。当服务端收到客户端最后的ACK确认时,将半连接中的相应条目删除,然后将相应的连接放入 全连接队列中, 此时服务端连接状态为ESTABLISHED。 进入全连接队列中的连接等待accept()调用取用。 

  既然是队列,肯定就有大小,那么当这两个队列满了没有空间了怎么办呢? 例如如果我们listen()后不去accept() ,那么全连接队列肯定会满的。 我们下面分别对于这两个队列结合试验进行描述。

试验环境: 

CentOS Linux release 7.5.1804 (Core)
Linux version 3.10.0-229.4.2.el7.x86_64

syns queue 半连接队列

  首先说一下 SYN flooding攻击,为了应对SYN flooding(即客户端只发送SYN包发起握手而不回应ACK完成连接建立,快速填满server端的半连接队列,让它无法处理正常的握手请求),Linux实现了一种称为SYNcookie的机制,通过net.ipv4.tcp_syncookies控制,设置为1表示开启。简单说SYNcookie就是将连接信息编码在ISN(initialsequencenumber)中返回给客户端,这时server不需要将半连接保存在队列中,而是利用客户端随后发来的ACK带回的ISN还原连接信息,以完成连接的建立,避免了半连接队列被攻击SYN包填满。 也就是说,如果开启了syncookies的话(通过 TCP参数 net.ipv4.tcp_syncookies配置 ),半连接队列就相当于是无限大的了。在我的环境中就是默认开启的。 

  如果我们将syncookies关闭的话,半连接队列的长度将为 max(64, /proc/sys/net/ipv4/tcp_max_syn_backlog) ,,此时对半连接填满时的处理策略是 server将 丢弃请求连接的SYN,不回复SYN+ACK,这样就会造成client收不到握手响应,始终处在SYN_SENT状态,经过几次重传后,客户端 connect() 调用失败。

accept queue 全连接队列

  全连接队列的长度为 min(backlog, somaxconn),默认情况下,somaxconn 的值为 128(/proc/sys/net/core/somaxconn),表示最多有 129 的 ESTAB 的连接等待 accept(),而 backlog 的值则是由 int listen(int sockfd, int backlog) 中的第二个参数指定,listen 里面的 backlog 可以有我们的应用程序去定义。 当全连接队列满了后的处理策略基于TCP参数net.ipv4.tcp_abort_on_overflow,在我的机器上默认为0。 

  1.tcp_abort_on_overflow 关闭时

  当server收到最后一次ACK时,希望将连接从半连接队列中取出放入全连接队列,但是此时全连接队列已满,此时的策略是 将最后接收到的ACK丢弃,并且根据net.ipv4.tcp_synack_retries定义的次数重新向client发送SYN+ACK, client在接收到重传的SYN+ACK后会认为之前的ACK丢失了进而重传ACK,这样在下次重新接收到ACK后,如果全连接队列有空间了,连接就可以正确完成建立。 如果重传了规定次数后全连接队列中依旧没有空间,那么server会简单终止这次连接。这里简单终止的意思是server并没有像client发送RST表明连接无法建立,而是直接丢弃了,这样就会导致在client中的连接处在ESTABLISHED状态,并一直如此。如果client端在此之后发送数据到server端,才会引起server响应RST。

  2.tcp_abort_on_overflow 开启时

  在收到握手的最后一次ACK后,在全连接中如果没有空间,直接向client回复RST,表示连接无法建立。 

实验

  首先我们看看我实验环境的默认配置:

    

  这些配置都是linux的默认配置,开启syncookies,当全连接队列满了后不会abort,而是采用重传syn + ack, 重传次数为5。下面我们结合代码进行实验。

  1.server端 我们故意将backlog的值设置为1,那么全连接的长度就为2。我们在server代码中永远不调用accept,也就是全连接中的连接永远不会被取走,因此真正会建立的连接(server端显示ESTABLISHED的连接)最多就2个。

 1 #include<stdio.h>
 2 #include<stdlib.h>
 3 #include<errno.h>
 4 #include<string.h>
 5 #include<sys/types.h>
 6 #include<netinet/in.h>
 7 #include<sys/socket.h>
 8 #include<sys/wait.h>
 9  
10 #define PORT 8888
11 //故意将backlog设置为1,全连接的长度为2
12 #define BACKLOG 1
13  
14 int main(){
15     int sockfd,new_fd;
16     struct sockaddr_in my_addr;
17     struct sockaddr_in their_addr;
18     int sin_size;
19  
20     sockfd=socket(AF_INET,SOCK_STREAM,0);
21 
22     if(sockfd==-1){
23         printf("socket failed:%d\n",errno);
24         return -1;
25     }
26     my_addr.sin_family=AF_INET;
27     my_addr.sin_port=htons(PORT);
28     my_addr.sin_addr.s_addr=htonl(INADDR_ANY);
29     bzero(&(my_addr.sin_zero),8);
30     if(bind(sockfd,(struct sockaddr*)&my_addr,sizeof(struct sockaddr))<0){
31         printf("bind error\n");
32         printf("%s\n", strerror(errno));
33         return -1;
34     }
35 
36     listen(sockfd,BACKLOG);
37     //简单的休眠,永远不去调用accept从全连接中取走连接
38     sleep(10000000);
39 }

   2.客户端代码中我们在连接建立完成后,首先执行一个写操作,然后执行一个读操作,因为server端始终没有accept,自然客户端的读操作将阻塞。

 1 #include <stdio.h>
 2 #include <unistd.h>
 3 #include <stdlib.h>
 4 #include <errno.h>
 5 #include <string.h>
 6 #include <sys/types.h>
 7 #include <netinet/in.h>
 8 #include <sys/socket.h>
 9 #include <sys/wait.h>
10  
11  
12 #define DEST_PORT 8888
13 #define DEST_IP "192.168.4.14"
14 #define MAX_DATA 100
15  
16 int main(){
17     int sockfd;
18     struct sockaddr_in dest_addr;
19     char buf[MAX_DATA];
20  
21     sockfd=socket(AF_INET,SOCK_STREAM,0);
22     if(sockfd==-1){
23         printf("socket failed:%d\n",errno);
24     }
25  
26  
27     dest_addr.sin_family=AF_INET;
28     dest_addr.sin_port=htons(DEST_PORT);
29     dest_addr.sin_addr.s_addr=inet_addr(DEST_IP);
30     bzero(&(dest_addr.sin_zero),8);
31     
32     if(connect(sockfd,(struct sockaddr*)&dest_addr,sizeof(struct sockaddr))==-1){
33         printf("connect failed:%d\n",errno);
34     } else{
35         printf("connect success\n");
36         //客户端连接建立后,首先进行一次写操作,对于没有真正建立起的连接,这个写的数据包将会被server端直接忽略
37         size_t t = send(sockfd,"Hello World!",12, MSG_DONTWAIT);
38         if (t == -1) {
39             printf("send failed: %s\n", strerror(errno));
40             exit(-1);
41         } else {
42             printf("send successfully !");
43         }
44 
45         printf("the client will try to read from server and then block !  \n");
46         //写操作后进行一次阻塞的读操作
47         size_t rt = recv(sockfd,buf,MAX_DATA,0);
48         if(rt == -1) {
49             printf("recv return error: %s\n", strerror(errno));
50             exit(-1);
51         }
52     }
53 }

  我们会分别启动三个客户端来建立连接,根据上面所讲,这三个连接中将有两个真正的和server端完全建立连接,在双方我们都将看到处于ESTABLISHED状态的连接。但是对于剩下的那一个,在server端由于永远都无法加入的全连接中来,所以将处于SYN_RECV的状态,而在客户端,则将处于ESTABLISHED状态,然后server端就会重传5次syn + ack,最后直接结束这次连接。在我们的代码中,客户端建立连接后会有一个写操作,对于这个没有真正建立的连接,写操作所发送的数据包到达服务端后同样会被直接忽略掉,那么客户端也就收不到ack确认,然后会开启数据包的重传。 当服务端彻底关闭了之前那个SYN_RECV状态的连接后,等到再收到重传的数据包后,就会发送RST,客户端收到这个RST后,关闭连接,然后阻塞的读操作就会出错返回。 

  在分别运行server端和三个client端后双方的连接情况如下:

   server端:

  server端最开始有两个ESTABLISHED的连接,并且在 接收队列(Recv-Q)中都有12个字节的数据,这说明它们和客户端已经正常连接了。剩下的那个处于SYN_RECV状态的连接接收队列为0,证明了上面说的 此时客户端发来的数据包将被丢弃,也不会发送任何ack,从而客户端会重传这12个字节的数据。过一段时间后,这个处于SYN_RECV的连接将消失。

    client端:

  client端的三个连接全部处于ESTABLISHED状态,通过对比端口号,我们知道37784那个连接此时在服务端处于SYN_RECV状态,而且与服务端对应的,此时这个连接的发送队列(Send-Q)中有12个字节的数据,说明此时发送并没有成功,从而会开启数据包的重传。

  在server端的那个处于SYN_RECV状态的连接消失很短一段时间后,上面37784那个连接对应的客户端会读操作出错返回,如下图:

  从出错信息中,我们很容易的看出来,是由于客户端收到了服务端发来的RST导致的。而这个RST之所以会从服务端发出,自然就是由于那12个字节的数据包在服务端彻底关闭了对应的连接后再次重传导致的。

  下面我们看看这整个过程中,在server端抓取的数据包。

  下面是端口号为37784的那个没有真正建立起来的连接的数据包

  首先的11-13代表着最初建立连接的三次握手,然后14代表着客户端向服务端发送了一个数据包。在此之后,我们看到从server(4.14)发向client(108.180)的除了最后面那个RST外,一共有5次SYN+ACK的重传,在每次收到server发来的SYN+ACK重传后,客户端都会响应一次ACK,除此之外

client(108.180)向server(4.14)发送的所有数据包都是最开始那次PSH的重传,在server彻底关闭了连接后,最后的那个重传导致server响应了RST。

 

  总结:我们考虑在一个高负载的服务器上,当server端没有能力立即accept到来的tcp请求时,就会将这些请求缓存到 连接队列中来,当连接队列也满了的时候,不会立即RST,而是采用重传的方式。实际上就是如何相对平缓的应对此时高负载的请求,而不是硬性的拒绝。

       在《TCP/IP详解 卷一》  13.7.4 节讲到连接队列时,有类似的一段话:

 

后记

 在前面的例子中,除了能够进入全连接的客户端请求外只有一个请求无法进入全连接,当我把并发请求的数量调整到100个时,发生的情况却很让我意外,我们先看看客户端的并发请求的代码:

 1 #include <unistd.h>
 2 #include <stdio.h>
 3 #include <unistd.h>
 4 #include <stdlib.h>
 5 #include <errno.h>
 6 #include <string.h>
 7 #include <sys/types.h>
 8 #include <netinet/in.h>
 9 #include <sys/socket.h>
10 #include <sys/wait.h>
11 #include <pthread.h>
12  
13  
14 #define DEST_PORT 8888
15 #define DEST_IP "192.168.104.60"
16 #define MAX_DATA 100
17  
18 
19 void *threadFunc(void *arg)
20 {
21     int sockfd;
22     struct sockaddr_in dest_addr;
23     char buf[MAX_DATA];
24  
25     sockfd=socket(AF_INET,SOCK_STREAM,0);
26     if(sockfd==-1){
27         printf("socket failed:%d\n",errno);
28     }
29  
30  
31     dest_addr.sin_family=AF_INET;
32     dest_addr.sin_port=htons(DEST_PORT);
33     dest_addr.sin_addr.s_addr=inet_addr(DEST_IP);
34     bzero(&(dest_addr.sin_zero),8);
35     
36     if(connect(sockfd,(struct sockaddr*)&dest_addr,sizeof(struct sockaddr))==-1){
37         printf("connect failed:%d\n",errno);
38         return NULL;
39     } else{
40         printf("connect success\n");
41         //客户端连接建立后,首先进行一次写操作,对于没有真正建立起的连接,这个写的数据包将会被server端直接忽略
42         size_t t = send(sockfd,"Hello World!",12, 0);
43         if (t == -1) {
44             printf("send failed: %s\n", strerror(errno));
45             return NULL;
46         } else {
47             printf("send successfully !");
48         }
49 
50         printf("the client will try to read from server and then block !  \n");
51         //写操作后进行一次阻塞的读操作
52         size_t rt = recv(sockfd,buf,MAX_DATA,0);
53         if(rt == -1) {
54             printf("recv return error: %s\n", strerror(errno));
55             return NULL;
56         }
57     }
58 
59     return NULL;
60 }
61 
62 int main(void)
63 {
64     pthread_t pth[100];  
65     int i = 0;
66 
67     for(i = 0; i < 100; i++) {
68         pthread_create(&pth[i],NULL,threadFunc,"foo");
69     }
70     
71     printf("main waiting for thread to terminate...\n");
72     sleep(10000000);
73 
74     return 0;
75 }

 

我们通过多线程从客户端发起了100个请求,除了2个进入全连接的请求正常建立起来以外,其他的请求并不是所有都同上面所讲的那个无法进入全连接的请求一样,主要要以下几种情况:

1. 始终无法建立连接,客户端始终处于SYN_SENT状态,最终syn重传超时,客户端显示 connect failed。 从服务端抓取的包来看就是收到了多个syn,却始终没有响应syn + ack。

 

2. 客户端经过几次重传syn后,服务端响应了syn+ack,但是并没有重传五次 syn+ack,有的重传了少于5次有的干脆一次都没有重传。

一次都没有重传:

重传了2次:

 

 至于为什么当并发请求数量较多时会出现上面的情况,暂时还搞不清楚,如果你知道,也希望你能告诉我^_^。我从直观感受上猜测的原因可能是,当请求压力骤然增大时,对于这些并发的请求中的某些请求减少 重传syn+ack的次数或者干脆忽略syn,能相对的减小服务端的压力。

但是能确定的一点是通过合理的设置backlog的大小,我们可以缓存的established的连接数量是确定的。当服务端程序由于种种原因无法及时执行accept处理新请求时,更多的新请求将首先在全连接中缓存,如果全连接也用光了,那么新请求最有可能的是通过重传syn+ack延迟进入全连接的时机,再就是如果压力特别大的时候,也可能像我们最后分析的那样,直接忽略syn,或者缩短重传的次数。

 

posted @ 2018-11-23 17:40  zh1164  阅读(1445)  评论(0编辑  收藏  举报