概要:十年演进对比
历史实现(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.
差异点
- 使用全局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 | 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. |
内核实现
内核实现里,相关数据结构和函数总结如下
数据结构:
- 一个 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_sock
tcp socket 可以通过两种方式创建,
- 系统调用:
socket(AF_INET, SOCK_STREAM, 0)
- 系统调用accept:
系统调用返回给用户空间的fd是一个整数。每个fd在内核都对应一个结构体struct tcp_sock
, 我们称之为 tcp socket。从 fd 到tcp_sock
的映射比较繁琐,这里不展开介绍。
tcp_sock 结构体定义
具体内容如下图所示:
tcp_sock的套娃式变形
1 | 194 struct tcp_sock { |
在内核协议栈这部分代码的各类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.sk
,sock
–>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)
SYN报文的处理流程
tcp_v4_rcv调用栈
1 | tcp_v4_rcv(struct sk_buff *skb) |
tcp_v4_conn_request调用栈
1 | tcp_v4_conn_request |
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
统一处理。
- tw(
备注:
如果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_ESTABLISHED
和TCP_LISTEN
两类状态下的 tcp socket, 其他状态的 socket会被tcp_rcv_state_process
处理。
- TCP_ESTABLISHED:tcp_rcv_established处理数据报文,超时、乱序等。不展开
- TCP_LISTEN:
tcp_v4_cookie_check
和tcp_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
的半链接 socketreq
, 并设置状态为***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 | tcp_v4_rcv(struct sk_buff *skb) |
tcp_v4_syn_recv_sock
调用栈
1 | tcp_v4_syn_recv_sock |
函数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 的区别, 一个只增加, 一个要删除旧的再加新的