tcp三次握手 --- 逐渐消失的tcp半链接队列

概要:十年演进对比

历史实现(v4.3)

网上大部分文档这部分内容讲的比较多,简单来说,可以把半链接队列的逻辑概括如下:

  • 收到syn报文,查找listen socket。
  • 创建一个半链接socket(req socket),并放入listen socket的半链接队列里,发送syn+ack报文
  • 收到client回复的ack报文,查找到对应的半链接socket(req socket)
  • 基于半链接socket和ack报文创建child socket
  • 把child socket(req)加入到accept队列中,等待accept系统调用/激活

当前实现(v6.14)

在v6.14内核里,三次握手的处理流程如下:

  • step1:收到一个syn请求报文:查找到对应的listen socket
  • step2:创建一个半链接socket(req socket),记录 syn 请求的一些字段信息,如seq, mss等。
  • step3:构造并发送syn+ack报文
  • step4: 把半链接socket(req socket)放入全局(netns)的ehash链表中,并统计半链接socket个数。通常说的半链接队列长度。
    + 全局:确切说是,当前netns(net namesapce)。一般默认是指当前物理机的内核协议栈。当有 docker实例(启用网络隔离)时候,全局则是指当前 docker 实例的内核协议栈。docker如何使用netsns隔离,不展开。为了理解方便,这里我们以没有docker 隔离的场景为例。
    + ehash链表:一个hash 链表,存放有全部establish状态的socket,当然tw状态的也在这个链表里。关联度不高,不展开。
    不同点 :使用全局ehash链表,而不是 listen socket的半链接队列
  • step5:收到client回复的ack报文,查找到对应的半链接socket(req socket)
  • step6:基于半链接socket 和ack报文创建child socket
  • step7:把新创建的child socket 插入到 ehash链表中。同时把req socket从链上摘掉。
  • step8:把req socket加入到listen socket的accept队列中,同时让req下的关联socket指针指向child。
  • step9: 发送listen socket的data_ready消息给应用程序, 等待listen socket上的accept系统调用/激活。
  • step10: accept系统调用把req socket从accept队列上拆除。同时,封装child socket,给用户返回其对应的fd.

tcp三次握手概要图

差异点

  • 使用全局ehash 链表而不是listen socket 下的半链接队列, 来存放半链接(req) socket。
    队列只是一个习惯性的叫法,严格意义上说不合适。很早(20+年)之前,内核就已经使用hash链接替换队列存放半链接socket了。
  • 长度检查:半链接个数(队列长度)和accept队列长度的检查
  • ack报文处理逻辑:查找半链接socket,减少锁listen socket的时长。

对应内核patch

2015年(没错十年前)内核加入了组性能优化的patch,将半链接socket(req socket)放到了一个全局的hash链表里。之前半链接socket是存放到对应listen socket下的半链接hash链表里(syn_table)。
这个系列的patch的主要目的是为了优化性能,但在patch里也改变了长度判断。

其中 在patch里明确提到半链接socket的变化tcp/dccp: install syn_recv requests into ehash table

1
2
3
4
In this patch, we insert request sockets into TCP/DCCP regular ehash table (where ESTABLISHED and TIMEWAIT sockets are) instead of using the per listener hash table.
ACK packets find SYN_RECV pseudo sockets without having to find and lock the listener. In nominal conditions, this halves pressure on listener lock.
Note that this will allow for SO_REUSEPORT refinements, so that we can select a listener using cpu/numa affinities instead of the prior 'consistent hash',
since only SYN packets will apply this selection logic. We will shrink listen_sock in the following patch to ease code review.

详见链接
内核patch:tcp lockless
tcp lockless patch 截图

内核实现

内核实现里,相关数据结构和函数总结如下
数据结构:

  • 一个 hash 链表: 全局(per ns)ehash 链表
  • 三个 socket:半链接 socket(req), child socket,listen socket。后两个都是tcp sock
  • 每个listensocket 下:
    • 一个 accept 队列
    • 一个半链接socket 个数统计

关键函数:

  • tcp_v4_rcv/tcp_v4_do_rcv: 协议栈里 tcp 报文入口函数
  • tcp_connet_requst:SYN 报文处理核心函数
  • tcp_v4_syn_recv_sock: ACK报文处理核心函数

数据结构

ehash链表

一个全局的 hash 链表,存放有全部establish状态的socket,当然tw状态的也在这个链表里。关联度不高,不展开。
tcp ehash 链表

tcp_sock

tcp socket 可以通过两种方式创建,

  • 系统调用:socket(AF_INET, SOCK_STREAM, 0)
  • 系统调用accept:
    系统调用返回给用户空间的fd是一个整数。每个fd在内核都对应一个结构体struct tcp_sock, 我们称之为 tcp socket。从 fd 到tcp_sock的映射比较繁琐,这里不展开介绍。
tcp_sock 结构体定义

具体内容如下图所示:
tcp_sock

tcp_sock的套娃式变形

俄罗斯套娃

1
2
3
4
5
6
7
194 struct tcp_sock {
...
200 /* inet_connection_sock has to be the first member of tcp_sock */
201 struct inet_connection_sock inet_conn;
202
203 /* TX read-mostly hotpath cache lines */
204 __cacheline_group_begin(tcp_sock_read_tx);

在内核协议栈这部分代码的各类socket结构体定义里,套娃模式被广泛使用。以tcp_sock为例, 大部分的tcp处理函数,传递参数时候并不是使用的tcp_sock这个类型,而是使用起下面的子类型,struct sock *等. 比如 tcp 协议处理的主函数 tcp_v4_do_rcv

1
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)

当需要读写tcp_sock下数据时候,才会通过宏换到tcp_sock.

  • tcp_sk(ptr):
  • inet_csk(ptr): 借助icsk_inet.sksock –> inet_connection_sock.
  • req_to_sk(req):
  • 1
    #define tcp_sk(ptr) container_of_const(ptr, struct tcp_sock, inet_conn.icsk_inet.sk)

半链接 socket(req socket)

tcp半链接socket

SYN报文的处理流程

tcp_v4_rcv调用栈

1
2
3
4
5
6
tcp_v4_rcv(struct sk_buff *skb)
|--> sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
|--> tcp_v4_do_rcv(sk, skb);
|--> struct sock *nsk = tcp_v4_hnd_req(sk, skb)
|--> tcp_rcv_state_process
|--> if (icsk->icsk_af_ops->conn_request(sk, skb) < 0) 相当 tcp_v4_conn_request

tcp_v4_conn_request调用栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
tcp_v4_conn_request
|--> tcp_conn_request(&tcp_request_sock_ops, &tcp_request_sock_ipv4_ops, sk, skb);
|--> tcp_v4_conn_request(&tcp_request_sock_ops, &tcp_request_sock_ipv4_ops, sk, skb);
|--> sk_acceptq_is_full(sk)
|--> req = inet_reqsk_alloc(rsk_ops, sk, !want_cookie);
|--> struct request_sock *req = reqsk_alloc(ops, sk_listener, attach_listener);
|--> ireq->ireq_state = TCP_NEW_SYN_RECV; //ireq = inet_rsk(req); #define ireq_state req.__req_common.skc_state
|--> tcp_openreq_init(req, &tmp_opt, skb);
|--> inet_csk_reqsk_queue_hash_add(sk, req, req->timeout)
|--> reqsk_queue_hash_req(req, timeout)
|--> inet_ehash_insert(req_to_sk(req), NULL, &found_dup_sk) //将 req sock放到 establish hash链表里
|--> inet_csk_reqsk_queue_added
|--> reqsk_queue_added(&inet_csk(sk)->icsk_accept_queue); //半链接队列,真实名字叫 accept。功能也只有计数能力了,没有队列了。
|--> atomic_inc(&queue->qlen);
|--> af_ops->send_synack //发送synack报文

tcp的入口函数: tcp_v4_rcv

tcp_v4_rcv是协议栈的处理tcp协议(ipv4)的入口函数。类似,ipv6的tcp处理函数tcp_v6_rcv。所有的发往本地的ipv4+tcp报文,在IP协议处理完成后,都会走到tcp_v4_rcv 这个入口函数里。
这个函数代码内容比较多,三次握手逻辑,重点关注step1 和 step3:

  • step1:根据syn报文五元组,查找到对应的socket,初次 syn 报文应该是listen socket。简单起见,不考虑SYN
    报文重传。与 ACK 报文不一样
  • step2:基本检查:按流程协议栈首先做一些基本的校验和检查,比如,报文长度,tcp checksum等。不展开跳过。
  • step3:调用tcp_v4_do_rcv 处理 syn 报文。

几点解释:

  • 根据 tcp 报文里的五元组信息的不同组合查找对应的 sk,如果找不到 sk,则丢弃报文。
  • *** sk_state*** socket的状态。根据 sk 的状态sk_state又区分为几个场景,
    • tw(TCP_TIME_WAIT) socket : 与主题无关,略过。
    • 半链接(TCP_NEW_SYN_RECV) socket : 三次握手时候ACK报文场景, 后面展开。
    • 其他(如 listen, establish):做一些通用和统计后,调用tcp_v4_do_rcv 统一处理。

备注:

  • 如果socket 被用户空间调用临时锁住,会放到待处理队列backlog里,等释放sk锁后再处理。

  • socket 查找: 单独展开讲述。

    • 按五元组查找:establish、tw、半链接
    • listensocket 的查找:对应 listen 调用时候的两种使用方式。
      • 按 ip/port:指定 IP+port。
      • 按port: 不指定 IP(参数里使用 any 或0),仅指定port

    【备注 1】通用检查包括:
    + xfrm4_policy_check:vpn相关的场景。当前 socket有ipsec规则和sa配置的场景,或者支持xfrm 卸载场景等。
    + tcp_inbound_hash:MD5类检查, 具体没展开看。
    + tcp_filter: 运行附着在 socket 上的 ebfp程序,做 socket 过滤。
    + tcp_segs_in:统计socket 收到的 tso 报文个数。

tcp_v4_do_rcv

先处理TCP_ESTABLISHEDTCP_LISTEN两类状态下的 tcp socket, 其他状态的 socket会被tcp_rcv_state_process处理。

  • TCP_ESTABLISHED:tcp_rcv_established处理数据报文,超时、乱序等。不展开
  • TCP_LISTEN: tcp_v4_cookie_checktcp_child_process。对应场景是,启用了 cookie下的 ack报文,这时候没有半链接 socket,所以会走到listen socket场景下处理。并产生一个 child socket。关联低,不展开。 我们这个场景下,SYN 报文不会进入这个逻辑。这里代码比较绕,
    小心掉坑。 cookie检查只针对非 SYN 报文,所以 SYN 报文在tcp_v4_cookie_check返回的还是原来的 listen socket。只有返回的 socket 不是 listen socket 的 场景才会调用tcp_child_process
tcp_rcv_state_process

这个函数,根据报文对应socket 的状态走不同分支。场景非常多,不逐一展开。 SYN 报文对应listen socket,因此走TCP_LISTEN分支。调用

1
icsk->icsk_af_ops->conn_request(sk, skb);

这里等同于调用tcp_v4_conn_request(sk, skb)

核心函数: tcp_v4_conn_request & tcp_conn_request

tcp_v4_conn_request&tcp_conn_request` 是SYN报文处理的核心, 我们按代码顺序讲解

  • step1:半链接和accpet 队列长度检查,这部分确保队列不溢出。 后面展开讲。
  • step2:创建一个类型为struct request_sock的半链接 socket req, 并设置状态为***TCP_NEW_SYN_RECV***
  • step3:初始化半链接 socket req。 包括 SYN 报文里携带的信息,如seq(client侧)、tcp mss、时间戳、win scale等。
  • step4: 初始化req的路由。根据五元组、网口等信息,查找路由并缓存,为后续SYN_ACK准备。
  • step5:req的其他初始化。如时间戳、seq、tos、window大小、record_syn等。关联度低,跳过。
  • step6:*【核心】req插入到 ehash 队列里,并且把统计个数+1。对应实现函数inet_csk_reqsk_queue_hash_add
  • step7: 发送 SYN_ACK报文。af_ops->send_synack, 关联度低,跳过。

备注

  • fastopen场景:代码里会考虑对 fastopen 场景的支持,因为关联度低,简单起见,解释代码时跳过这部分逻辑。

inet_csk_reqsk_queue_hash_add

两个核心功能

  • reqsk_queue_hash_req: 插入 ehash 队列
    • inet_ehash_insert
    • 更新半链接的定时器及引用计数
  • inet_csk_reqsk_queue_added: 统计数增加。
    1
    2
    |--> reqsk_queue_added(&inet_csk(sk)->icsk_accept_queue); 
    |--> atomic_inc(&queue->qlen);
    备注
  • reqsk_queue_added: 函数名字还保留半链接队列,真正操作的其实是accept队列结构体里的字段。功能也保留统计计数的能力,没有队列了。
  • 此处要回顾下accept队列在 listensocket 中的位置。

ACK握手报文的处理流程

tcp_v4_rcv调用栈

1
2
3
4
5
6
7
8
9
10
tcp_v4_rcv(struct sk_buff *skb)
|--> sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
|--> if (sk->sk_state == TCP_NEW_SYN_RECV) //SYN报文创建req时设置的
|--> nsk = tcp_check_req(sk, skb, req, false, &req_stolen); //前置条件:通过tcp_filter
|--> child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL); //tcpv4对应函数tcp_v4_syn_recv_sock
|--> inet_csk_complete_hashdance(sk, child, req, own_req);
|--> tcp_child_process(sk, nsk, skb)
|--> tcp_rcv_state_process(child, skb, tcp_hdr(skb),
|--> tcp_set_state(sk, TCP_ESTABLISHED);
|--> parent->sk_data_ready(parent);//唤醒accept等待进程

tcp_v4_syn_recv_sock调用栈

1
2
3
4
5
6
7
8
9
10
11
tcp_v4_syn_recv_sock
|--> sk_acceptq_is_full //检查accept队列长度,如果满了,则丢弃。
|--> sk->sk_ack_backlog > sk->sk_max_ack_backlog ;
|--> newsk = tcp_create_openreq_child(sk, req, skb); //newsk是新创建的child socket。
|--> newsk = inet_csk_clone_lock(sk, req, GFP_ATOMIC); //复制一个socket并加锁
|--> __TCP_INC_STATS(sock_net(sk), TCP_MIB_PASSIVEOPENS);
|--> __inet_inherit_port
|--> inet_ehash_nolisten(newsk, req_to_sk(req_unhash), &found_dup_sk)
|--> inet_ehash_insert(sk, osk, found_dup_sk);//在ehash表里,插入新的child socket, 并删去原有req sock
|--> sk_nulls_del_node_init_rcu(osk); //将req从ehash链里摘除
|--> __sk_nulls_add_node_rcu(sk, list); //将child socket插入到ehash链里

函数tcp_v4_rcv处理握手ACK

与 SYN 报文一样,ACK报文进入首先进入tcp协议栈入口函数tcp_v4_rcv。针对 ack 报文的主要处理步骤

  • step1:根据ACK 报文五元组,查找到ehash 链表里的半链接 socket(req socket)。
    这部分跟之前逻辑变化比较大。4.3 以前的内核,比如3.10,内核协议栈根据ACK 报文先找到 listen socket,然后在listen socket 下的半链接队列里。
    而现在代码,req socket 被放到 ehash 链表中,所以直接查找到 req socket,而不需要像以前一样需要对 listen socket 进行加锁等操作。
    为了跟以前 req socket 的状态TCP_SYN_RECV进行区别,协议栈还特意为ehash里的req socket增加了一个新的 tcp状态编码TCP_NEW_SYN_RECV.
  • step2: 进入TCP_NEW_SYN_RECV处理分支半链接 socket。
  • step3: 调用tcp_check_req,创建一个child socket。把child socket插入到ehash链表,把req从 ehash 链表删除,放入accept 队列。后面在函数tcp_v4_syn_recv_sock里详述。
  • step4:处理 child socket 状态转换,并激活在 listen socket 等待队列上等待的 accept调用进程。

核心函数tcp_v4_syn_recv_sock

握手ACK报文的核心处理函数是tcp_v4_syn_recv_sock,其主要处理步骤有三步:

  • accept 队列检查it:确保队列没有溢出。具体判断条件,后续专门展开解释。
  • 创建 child socket:
  • 调整 req、child socket:
    在ehash表里,插入新的child socket, 并删去原有req socket。
    通过 req socket 下的 socket 指针指向 child,确保将来 accept时候,根据req 找到 child。
    把 req socket 挂载到 accept 队列尾部,等待 accept 系统调用。

至此,其实针对 tcp 报文来说,child socket 已经建立完成了,并处于established状态,
所以即使没有accept,发往child socket的数据也不会丢失。

放在 accept 队列里的其实是半链接 req socket。

查找skb的socket:__inet_lookup_skb

当 tcp 协议栈收到一个报文时候,首先就要做的一件事就就是查找这个 skb 报文对应的 socket。
这个功能是在__inet_lookup_skb里实现的。这个查找是有两类查找组成。

  • step1: 在ehash链表里查。查找的KEY是五元组。
    尽管这个名字叫 ehash,但是存放的 socket 不仅有established状态的,
    还有 Timewait状态的socket,
    还有我们刚讨论的TCP_NEW_SYN_RECV状态的半链接req socket。
  • step2: 如果没有ehash 里没有找到,继续在listen socket 里查找。这里我们回忆下bind调用时的两种bind形式
    • 绑定到指定IP+port:查找时候KEY是IP+PORT。
    • 绑定到指定port, IP任意:查找时候KEY是PORT。IP是0.

解释:
inet_ehash_insert 对 req 和 child 的区别, 一个只增加, 一个要删除旧的再加新的

accpet队列和半链接个数溢出的新标准

tcp 队列长度检查