TCP建立连接时socket的epoll态及一个可能的状态不一致问题

摘要:
零原因实际上是为了查看客户端和服务器套接字在三次TCP握手期间何时返回epoll状态。然而,随后出现了一个有趣的问题:客户端和服务器在三次握手方面不一致。sk_ack_backlog队列包含已完成握手的连接,但服务器端的守护进程尚未通过accept接收到已完成三次握手的连接。因此,此时该连接可用,但用户模式程序未使用,因此有一个listensocket
零、原因
其实本来是在看TCP三次握手时客户端和服务器端socket对于epoll状态何时返回何种状态,不过后来引出了一个另有意思的问题:就是客户端和服务器双方对于三次握手的状态出现了不一致。我们知道,在三次握手中,客户端在发送最后一个ack之后进入ESTABLISHED状态,并没有要求服务器对于这个ACK再次ACK(当然也没有办法要求ACK,否则这样就是没完没了的ACK了),所以通常我们认为ACK是对于数据的ACK,而ACK本身并不需要被再次ACK。这个一致性问题可以用下面的图片来表示,可以将其中的男吊看作是客户端,它认为自己进入的ESTABLISHED状态,而女神可以认为是服务器,其实这个连接在服务器端并没有建立。
TCP建立连接时socket的epoll态及一个可能的状态不一致问题 - Tsecer - Tsecer的回音岛
 
一、listen方对于状态的判断
unsigned int tcp_poll(struct file *file, struct socket *sock, poll_table *wait)
{
……
if (sk->sk_state == TCP_LISTEN)
return inet_csk_listen_poll(sk);
可以看到对于服务器端正在侦听的套接口,对于侦听状态做了特殊处理,而没有执行下面通用的poll状态判断
 
/*
 * LISTEN is a special case for poll..
 */
static inline unsigned int inet_csk_listen_poll(const struct sock *sk)
{
return !reqsk_queue_empty(&inet_csk(sk)->icsk_accept_queue) ?
(POLLIN | POLLRDNORM) : 0;
}
二、侦听套接口icsk_accept_queue队列的填充
tcp_v4_hnd_req==>>tcp_check_req==>>inet_csk_reqsk_queue_add
static inline void inet_csk_reqsk_queue_add(struct sock *sk,
    struct request_sock *req,
    struct sock *child)
{
reqsk_queue_add(&inet_csk(sk)->icsk_accept_queue, req, sk, child);
}
在这个路径中,其中tcp_check_req函数的注释中说明了这个处理的是SYN_RECV状态下的socket(Process an incoming packet for SYN_RECV sockets represented as a request_sock),也就是三次握手中最后一次交互。那么三次握手中的第一个包的处理流程呢?
三、服务器端对于第一次握手报文的处理
tcp_v4_do_rcv==>>tcp_rcv_state_process==>>tcp_v4_conn_request
/* TW buckets are converted to open requests without
 * limitations, they conserve resources and peer is
 * evidently real one.
 */
if (inet_csk_reqsk_queue_is_full(sk) && !isn) {
#ifdef CONFIG_SYN_COOKIES
if (sysctl_tcp_syncookies) {
want_cookie = 1;
} else
#endif
goto drop;
}
 
/* Accept backlog is full. If we have already queued enough
 * of warm entries in syn queue, drop request. It is better than
 * clogging syn queue with openreqs with exponentially increasing
 * timeout.
 */
if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1)
goto drop;
 ……
 drop:
TCP_INC_STATS_BH(TCP_MIB_ATTEMPTFAILS);
return 0;
}
在tcp_rcv_state_process函数中
case TCP_LISTEN:
if(th->ack)
return 1;
 
if(th->rst)
goto discard;
 
if(th->syn) {
if (icsk->icsk_af_ops->conn_request(sk, skb) < 0)
return 1;
 
/* Now we have several options: In theory there is 
 * nothing else in the frame. KA9Q has an option to 
 * send data with the syn, BSD accepts data with the
 * syn up to the [to be] advertised window and 
 * Solaris 2.1 gives you a protocol error. For now 
 * we just ignore it, that fits the spec precisely 
 * and avoids incompatibilities. It would be nice in
 * future to drop through and process the data.
 *
 * Now that TTCP is starting to be used we ought to 
 * queue this data.
 * But, this leaves one open to an easy denial of
   * service attack, and SYN cookies can't defend
 * against this problem. So, we drop the data
 * in the interest of security over speed.
 */
goto discard;
}
goto discard;
当超过限量之后,这个地方就会出现报文被丢弃,此时也就是意味着客户端的首次握手报文不会收到回包,注意,这是一次非常不友好的第一印象。
四、服务器端对于连接数的处理
我们再回过头来看下tcp_v4_conn_request函数对于首次握手的处理,在这个函数中分别进行了两次判断,一个是inet_csk_reqsk_queue_is_full,它使用的是icsk_accept_queue队列是否为空;然后是sk_acceptq_is_full,它使用的是sk_ack_backlog。这里的两个队列可以大致认为icsk_accept_queue是只收到了第一次握手的socket,这里的accept其实只是越过了listen socket的第一次检测,说明服务器“有意向”(而不是拒绝)接受客户端的三次握手,三次握手的流程可以继续,但是并没有完成。而sk_ack_backlog队列则是包含了已经完成了握手,但是服务器端的守护程序并没有通过accept将这个已经完成三次握手的连接接收过去,所以此时是可用但是用户态程序没有使用,所以暂存在了listen socket的sk_ack_backlog队列中。
在判断backlog队列sk_acceptq_is_full时,有一个额外的判断条件inet_csk_reqsk_queue_young(sk) > 1,这里的young是指ack报文没有被重传过的socket。而inet_csk_reqsk_queue_young会在tcp_synack_timer==>>inet_csk_reqsk_queue_prune中定时清除,所以理论上来说,一个socket可以接收的最多连接数量是acceptq和backlogqueue的长度之和。但是,当三次握手真正完成之后tcp_v4_syn_recv_sock函数的开始就会判断这个backlog队列是否已经满了,如果满了之后就直接丢弃连接,也就是功亏一篑,三次握手在最后一次我手上收到之后并没有建立,所以此时客户端就需要再次(多次重传)。
struct sock *tcp_v4_syn_recv_sock(struct sock *sk, struct sk_buff *skb,
  struct request_sock *req,
  struct dst_entry *dst)
{
……
if (sk_acceptq_is_full(sk))
goto exit_overflow;
五、backlog队列在什么时候会满
看了这么多,这个backlog队列的长度是连接是否可以建立的关键。
static inline int sk_acceptq_is_full(struct sock *sk)
{
return sk->sk_ack_backlog > sk->sk_max_ack_backlog;
}
这个值就是listen系统调用的第二个参数,这是一个严格的用户传入数值,也就是最多有这么多个完成了三次握手但是并没有被用户进程accept的socket。和这个对应的是可以完成一次握手socket的数量,这个在inet_csk_listen_start===>>>reqsk_queue_alloc创建,
在3.12.6内核版本中,这个真正生效的上限值由reqsk_queue_alloc函数中的下面代码控制,其中传入的原始nr_table_entries也就是listen中的backlog数值。
nr_table_entries = min_t(u32, nr_table_entries, sysctl_max_syn_backlog);
nr_table_entries = max_t(u32, nr_table_entries, 8);
nr_table_entries = roundup_pow_of_two(nr_table_entries + 1);
        ……
for (lopt->max_qlen_log = 3;
     (1 << lopt->max_qlen_log) < nr_table_entries;
     lopt->max_qlen_log++);
     也就是说,这个可以同时接受的首次握手请求通常是用户设置值向上取整之后的数量。
六、客户端的connect何时返回
inet_stream_connect==>>inet_wait_for_connect
while ((1 << sk->sk_state) & (TCPF_SYN_SENT | TCPF_SYN_RECV)) {
release_sock(sk);
timeo = schedule_timeout(timeo);
lock_sock(sk);
if (signal_pending(current) || !timeo)
break;
prepare_to_wait(sk->sk_sleep, &wait, TASK_INTERRUPTIBLE);
}
这意味着在没有收到服务器的ACK之前,connect将会一直阻塞在connect上(状态始终为TCPF_SYN_SENT),所以客户端有可能在connect时产生阻塞,对于异步框架来说,在connect的时候最好非阻塞来连接服务器,然后通过poll来查询状态,在前面的代码中可以看到,tcp_poll直接排除了SYN_SENT和SYN_RECV两个状态对结果的影响,它们不会生成有效的poll输出结果:
if ((1 << sk->sk_state) & ~(TCPF_SYN_SENT | TCPF_SYN_RECV)) {
……
}
return mask;
七、客户端对于connect重传的处理
当客户端的connect没有收到服务器回包时,此时会发生超时,进入tcp_write_timeout函数,这里对于同步连接的时候使用了特殊的配置值,也就是sysctl_tcp_syn_retries变量作为重试次数的上限,默认值为5次。
if ((1 << sk->sk_state) & (TCPF_SYN_SENT | TCPF_SYN_RECV)) {
if (icsk->icsk_retransmits)
dst_negative_advice(&sk->sk_dst_cache);
retry_until = icsk->icsk_syn_retries ? : sysctl_tcp_syn_retries;
}
这意味这一个问题,如果服务器发生拥塞,长时间不accept操作系统已经完成三次握手的backlog连接,后来的客户端connect的时候将会在阻塞的情况下进行重传。   
这个重传时间以     TCP_TIMEOUT_INIT,也就是3秒为基准,按照倍增方法增加,
#define TCP_TIMEOUT_INIT ((unsigned)(1*HZ)) /* RFC6298 2.1 initial RTO value */
尝试次数为sysctl_tcp_syn_retries,其中该值默认为5
#define TCP_SYN_RETRIES  5 /* number of times to retry active opening a
 * connection: ~180sec is RFC minimum */
所以在客户端第5次尝试超时之后认为连接失败,所以假设初始超时时间为1s,在第5次超时之后,总共尝试时间为大致为2^6=64s。奇怪的是好像不同版本中TCP_TIMEOUT_INIT这个值不同,早期版本初始值为3,新的版本为1。
八、服务器端accept何时返回      
1、阻塞态下accept
inet_accept===>>inet_csk_accept===>>inet_csk_wait_for_connect
for (;;) {
                ……
if (!reqsk_queue_empty(&icsk->icsk_accept_queue))
break;
                ……
 }                   
2、poll
tcp_poll===>>inet_csk_listen_poll
/*
 * LISTEN is a special case for poll..
 */
static inline unsigned int inet_csk_listen_poll(const struct sock *sk)
{
return !reqsk_queue_empty(&inet_csk(sk)->icsk_accept_queue) ?
(POLLIN | POLLRDNORM) : 0;
}
根据前面的分析,之后完成了三次握手的连接才会被放入到request_sock_queue::rskq_accept_head队列中,所以只完成了一次握手的连接不会将accept唤醒。这个直观上理解是自然而然的,不过还是看到这个代码实现更踏实些。
3、reqsk_queue_empty
函数实现为
static inline int reqsk_queue_empty(struct request_sock_queue *queue)
{
return queue->rskq_accept_head == NULL;
}
这个地方使用的并不是request_sock_queue::listen_sock::qlen,而是判断了queue->rskq_accept_head 队列是否为空。再次强调request_sock_queue::listen_sock::qlen表示已经接受的首次握手的连接,它们保存在listen_sock::syn_table[]数组中。
九、服务器端backlog拥塞之后
1、测试代码
非常简单的逻辑,就是服务器值listen,但是不执行任何的accept操作,并且设置listen的backlog参数为1,客户端同时发起多个连接,试图将服务器中的backlog耗光。
tsecer@harry: cat svr.cpp 
       #include <sys/socket.h>
       #include <netinet/in.h>
       #include <stdlib.h>
       #include <stdio.h>
       #include <string.h>
      #include <sys/types.h>
       #include <sys/socket.h>
       #include <netdb.h>
       #include <stdio.h>
       #include <stdlib.h>
       #include <unistd.h>
       #include <string.h>
 
 
       #define handle_error(msg)
           do { perror(msg); exit(EXIT_FAILURE); } while (0)
 
       int
       main(int argc, char *argv[])
       {
           struct addrinfo hints;
           struct addrinfo *result, *rp;
           int sfd, s, j;
           size_t len;
           ssize_t nread;
        sockaddr_in stpeer;
 
           memset(&hints, 0, sizeof(struct addrinfo));
           hints.ai_family = AF_INET;    /* Allow IPv4 or IPv6 */
           hints.ai_socktype = SOCK_STREAM; /* Datagram socket */
           hints.ai_flags = 0;
           hints.ai_protocol = 0;          /* Any protocol */
 
           s = getaddrinfo(argv[1], argv[2], &hints, &result);
           if (s != 0) {
               fprintf(stderr, "getaddrinfo: %s ", gai_strerror(s));
               exit(EXIT_FAILURE);
           }
           for (rp = result; rp != NULL; rp = rp->ai_next) {
               sfd = socket(rp->ai_family, rp->ai_socktype,
                            rp->ai_protocol);
               if (sfd == -1)
                   continue;
           if (bind(sfd, (struct sockaddr *) rp->ai_addr,
                   rp->ai_addrlen) == -1)
               handle_error("bind");
 
           if (listen(sfd, 1) == -1)
               handle_error("listen");
 
sleep(100000);
}
       }
tsecer@harry: cat cli.cpp 
       #include <sys/types.h>
       #include <sys/socket.h>
       #include <netdb.h>
       #include <stdio.h>
       #include <stdlib.h>
       #include <unistd.h>
       #include <string.h>
 
       #define BUF_SIZE 500
 
       int
       main(int argc, char *argv[])
       {
           struct addrinfo hints;
           struct addrinfo *result, *rp;
           int sfd, s, j;
           size_t len;
           ssize_t nread;
          char buf[BUF_SIZE];
 
           if (argc < 3) {
               fprintf(stderr, "Usage: %s host port msg... ", argv[0]);
               exit(EXIT_FAILURE);
           }
 
           /* Obtain address(es) matching host/port */
 
           memset(&hints, 0, sizeof(struct addrinfo));
           hints.ai_family = AF_INET;    /* Allow IPv4 or IPv6 */
           hints.ai_socktype = SOCK_STREAM; /* Datagram socket */
           hints.ai_flags = 0;
           hints.ai_protocol = 0;          /* Any protocol */
 
           s = getaddrinfo(argv[1], argv[2], &hints, &result);
           if (s != 0) {
               fprintf(stderr, "getaddrinfo: %s ", gai_strerror(s));
               exit(EXIT_FAILURE);
           }
           for (rp = result; rp != NULL; rp = rp->ai_next) {
int itry = atoi(argv[3]);
for (int t = 0; t < itry ; t++)
{
               sfd = socket(rp->ai_family, rp->ai_socktype,
                            rp->ai_protocol);
               if (sfd == -1)
                   continue;
               if (connect(sfd, rp->ai_addr, rp->ai_addrlen) != -1)
                {
printf("connection %d ", t);
                }
else
{
perror("connect failed");
}
}
           }
sleep(10000);
}           
2、原始测试
[root@localhost listenfull]# export PS1="tsecer@harry: "
tsecer@harry: ./svr 127.0.0.1 1234
tsecer@harry: ./cli 127.0.0.1 1234 10
connection 0
connection 1
connection 2
connection 3
connection 4
connection 5
connection 6
connection 7
connection 8
connection 9
客户端认为自己成功的完成了所有的10次握手,全部进入ESTABLISHED状态。通过netstat可以看到,svr只有两个连接进入了ESTABLISHED(第四列为127.0.0.1:1234,第六列为ESTABLISHED的列),而cli则有10项进入ESTABLISHED,此时客户端和服务器的状态已经出现不一致:客户端认为三次握手已经完成,而服务器认为还没有
tsecer@harry: netstat -anp | grep 1234
tcp        2      0 127.0.0.1:1234          0.0.0.0:*               LISTEN      10796/./svr         
tcp        0      0 127.0.0.1:1234          127.0.0.1:44839         SYN_RECV    -                   
tcp        0      0 127.0.0.1:1234          127.0.0.1:44838         SYN_RECV    -                   
tcp        0      0 127.0.0.1:1234          127.0.0.1:44841         SYN_RECV    -                   
tcp        0      0 127.0.0.1:1234          127.0.0.1:44842         SYN_RECV    -                   
tcp        0      0 127.0.0.1:1234          127.0.0.1:44836         SYN_RECV    -                   
tcp        0      0 127.0.0.1:1234          127.0.0.1:44837         SYN_RECV    -                   
tcp        0      0 127.0.0.1:1234          127.0.0.1:44840         SYN_RECV    -                   
tcp        0      0 127.0.0.1:44835         127.0.0.1:1234          ESTABLISHED 10800/./cli         
tcp        0      0 127.0.0.1:1234          127.0.0.1:44833         ESTABLISHED -                   
tcp        0      0 127.0.0.1:44841         127.0.0.1:1234          ESTABLISHED 10800/./cli         
tcp        0      0 127.0.0.1:44840         127.0.0.1:1234          ESTABLISHED 10800/./cli         
tcp        0      0 127.0.0.1:44837         127.0.0.1:1234          ESTABLISHED 10800/./cli         
tcp        0      0 127.0.0.1:44833         127.0.0.1:1234          ESTABLISHED 10800/./cli         
tcp        0      0 127.0.0.1:44839         127.0.0.1:1234          ESTABLISHED 10800/./cli         
tcp        0      0 127.0.0.1:44834         127.0.0.1:1234          ESTABLISHED 10800/./cli         
tcp        0      0 127.0.0.1:1234          127.0.0.1:44834         ESTABLISHED -                   
tcp        0      0 127.0.0.1:44838         127.0.0.1:1234          ESTABLISHED 10800/./cli         
tcp        0      0 127.0.0.1:44836         127.0.0.1:1234          ESTABLISHED 10800/./cli         
tcp        0      0 127.0.0.1:44842         127.0.0.1:1234          ESTABLISHED 10800/./cli         
tsecer@harry: 
3、为什么可以绕过tcp_v4_conn_request的检测
int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
 
if ((sysctl_tcp_syncookies == 2 ||
     inet_csk_reqsk_queue_is_full(sk)) && !isn) {
want_cookie = tcp_syn_flood_action(sk, skb, "TCP");
if (!want_cookie)
goto drop;
}
如果配置了tcp_syncookies,那么请求队列满的限制将不会生效,对于后一个判断
if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
goto drop;
}
当服务器对首次握手的syn发送ack之后会启动保活定时器,这个定时的执行函数在tcp_synack_timer===>>>inet_csk_reqsk_queue_prune,该定时器每隔一段时间(#define TCP_SYNQ_INTERVAL (HZ/5) /* Period of SYNACK timer */)启动一次,对于未三次握手的半连接进行清除,所以随着系统时间的推进,定时器清空了request_sock_queue.listen_opt队列中的半连接,并且将inet_csk_reqsk_queue_young清除,所以之后客户端重传过来的syn请求会被服务区逐渐接受。从效果上看,就是我们看到客户端陆续完成了任意多次连接,但是通过netstat看服务器端并没有。
4、如何避免这种情况
在三次握手的最后一次确认中,
struct sock *tcp_check_req(struct sock *sk, struct sk_buff *skb,
   struct request_sock *req,
   struct request_sock **prev,
   bool fastopen)
……
child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL);
if (child == NULL)
goto listen_overflow;                       
……
listen_overflow:
if (!sysctl_tcp_abort_on_overflow) {
inet_rsk(req)->acked = 1;
return NULL;
}
 
embryonic_reset:
if (!(flg & TCP_FLAG_RST)) {
/* Received a bad SYN pkt - for TFO We try not to reset
 * the local connection unless it's really necessary to
 * avoid becoming vulnerable to outside attack aiming at
 * resetting legit local connections.
 */
req->rsk_ops->send_reset(sk, skb);
}
其中syn_recv_sock调用的是                           
struct sock *tcp_v4_syn_recv_sock(struct sock *sk, struct sk_buff *skb,
  struct request_sock *req,
  struct dst_entry *dst)
if (sk_acceptq_is_full(sk))
goto exit_overflow;
……
exit_overflow:
NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
exit_nonewsk:
dst_release(dst);
exit:
NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENDROPS);
return NULL;
所以设置了sysctl_tcp_abort_on_overflow标志位之后,则之后的连接会被reset掉。
tsecer@harry: cat /proc/sys/net/ipv4/tcp_abort_on_overflow 
0
tsecer@harry: echo 1 > /proc/sys/net/ipv4/tcp_abort_on_overflow 
tsecer@harry: ./cli 127.0.0.1 1234 10 
connection 0
connection 1
connect: Connection reset by peer
connect: Connection reset by peer
connect: Connection reset by peer
connect: Connection reset by peer
connect: Connection reset by peer
connect: Connection reset by peer
connect: Connection reset by peer
connect: Connection reset by peer

免责声明:文章转载自《TCP建立连接时socket的epoll态及一个可能的状态不一致问题》仅用于学习参考。如对内容有疑问,请及时联系本站处理。

上篇Ubuntu18.04安装RabbitMQ使用zabbix-java-gateway可以通过该网关来监听多个JVM下篇

宿迁高防,2C2G15M,22元/月;香港BGP,2C5G5M,25元/月 雨云优惠码:MjYwNzM=

相关文章

[批处理]NetstatFilter快速查找端口被占用问题

前言 准确的说,他是一个网络连接端口查看器,可以根据进程查端口,也可以根据端口查进程。期初是因在使用Fiddler的时候发现无法启动,提示端口被占用,但是由不知道用什么方法才能找到是哪个程序占用的Fiddler的端口,遂使用命令行的netstat命令配合find命令才找到,遂想写这样一个类似的工具帮助我们速度定位类似端口问题的所在。 PS:可预见很多人会说...

SIGPIPE

send或者write socket遭遇SIGPIPE信号 当服务器close一个连接时,若client端接着发数据。根据TCP协议的规定,会收到一个RST响应,client再往这个服务器发送数据时,系统会发出一个SIGPIPE信号给进程,告诉进程这个连接已经断开了,不要再写了。 又或者当一个进程向某个已经收到RST的socket执行写操作是,内核向...

容器网络(二)docker容器访问外部网络及对外提供服务

由于docker容器访问外部网络及对外提供服务都使用到iptable,我们先了解下iptable的基础知识。 一、Iptables 1、iptables的链 iptables有5条默认的链,分别为: INPUT OUTPUT PREROUTING FORWARD POSTROUTING 2、iptables的表 iptables有4张表,分别为: f...

网络编程(InetAddress类、UDP、TCP)

1、InetAddress类 (1)IP和端口号: IP(InternetProtocol,IP)互联网协议地址:唯一标识一台计算机。 端口号:用于区分不同的应用程序。取值范围是0~65535,其中0~1023被系统保留。 在计算机A访问计算机B是通过IP地址进行查找的,接着在计算机B上通过应用程序的端口号找到相应的程序。 (2)InetAddress类...

【山外笔记-工具框架】Netperf网络性能测试工具详解教程

本文下载链接: 【学习笔记】Netperf网络性能测试工具.pdf 一、Netperf工具简介 1、什么是Netperf ? (1)Netperf是由惠普公司开发的一种网络性能测量工具,主要针对基于TCP或UDP的传输。 (2)Netperf根据应用的不同,可以进行不同模式的网络性能测试,即批量数据传输(bulk data transfer)模式和请求/应...

[Python之路] 多种方式实现并发Web Server

下面我们使用Python来实现并发的Web Server,其中采用了多进程、多线程、协程、单进程单线程非阻塞的方式。 一、使用子进程来实现并发Web Server 参照 https://www.cnblogs.com/leokale-zz/p/11949208.html 中的代码,我们将其修改为支持并发的简单Web Server: import socke...