三次握手:双方内核各改了哪些字段

难度 ★★★★★ · 前置:socket 是什么:从 fd 到四元组 | 一句话结论:三次握手的每一次都在改动双方内核里的状态——第一次让服务端建一个半成品,第二次让服务端确认对方能收,第三次让服务端确认对方收到了我的确认。少一次,就有一方不知道自己该不该信。

两边的状态都健康,就是通不了

2024 年冬天我碰到过一次特别安静的故障,客户端和服务端之间隔了一层防火墙,端口是隔壁组开的,他们在群里拍着胸脯说都开了,可我拿 curl 打过去就是一行超时,五秒整,一秒不多一秒不少,连个 RST 都没捞着。

1
curl -v --max-time 5 http://10.0.5.9:8080/health
1
2
*   Trying 10.0.5.9:8080...
* Connection timed out after 5000 milliseconds

注意报的是超时,不是 connection refused,这两个词的区别比看上去重要得多,refused 说明包到了只是没人听,超时说明要么包没到、要么回了但被中途吃掉。这句话是当时带我的人随口说的,我记到现在,因为 refused 和 timeout 的差别直接决定了你下一步该去查防火墙还是该去查服务有没有起来,方向错一次,半天就没了。服务端那边我登上去看了一眼监听队列。

1
ss -lnt 'sport = :8080'
1
2
State   Recv-Q  Send-Q  Local Address:Port  Peer Address:Port
LISTEN 0 128 0.0.0.0:8080 0.0.0.0:*

端口在听,队列没积压,两边的状态都健康,就是通不了。(那次我在群里说了一句这也太玄了吧,被要求先去把两端的包抓一遍再说话)后来是网络组的人过来看了一眼,八成是中间某台设备对 SYN 做了处理,有的防火墙要求源端口落在某个范围内,有的 NAT 会话表满了,有的是 iptables 里那条 -j DROP 而不是 -j REJECT,DROP 是静默丢弃,客户端那边除了超时什么线索都拿不到。这个场景的意思是,握手的每一步都有具体的、能在 ss 里看到的状态,理解了那些状态怎么来的,排查才有方向。

双向确认,而且不对称

三次握手本质上是一个双向的确认过程,中间有个很关键的不对称,一个主机能收到数据不代表它能发出数据,反过来能发出也不代表对方能收到,所以确认必须在两个方向上各来一次,再加上我知道你收到了我的确认这一层。三个 T 字路口。

关键点在第 2 步之后,客户端就已经认为连接建立了,服务端要等到第 3 步。这个不对称是很多线上问题的根源,客户端 connect 返回成功,服务端可能根本还没收到最后的 ACK。(我刚开始写服务端的时候完全没意识到这一点,默认对面 connect 成功就等于一切就绪,后来被线上的一次 RST 教了做人)

为什么不是两次

两次的话,服务端回完 SYN+ACK 就直接认为连接建立,这时它没办法确认客户端收到了自己的 SYN+ACK,而没确认的那一半恰好是最容易出事的一半。具体会出什么错。假设有个历史上失效的 SYN 在网络里漂了 30 秒才到服务端,服务端回 SYN+ACK,客户端一看这个 seq 对不上,它根本没发过这个 SYN,于是回一个 RST,这个 RST 是第二次握手里客户端唯一能表达我不要的方式。如果只握两次,服务端在发出 SYN+ACK 的那一刻就建好了连接、分配了资源,然后干等客户端来用,一个 30 秒前就该作废的请求白白占掉一个 socket,这种人畜无害的占位在流量大的时候能把 accept 队列填满。所以第三次握手的本质是服务端确认客户端确实还在、确实要这个连接。四次行不行,技术上可以,因为 SYN 和 ACK 可以分开发,三次是最优的,SYN+ACK 合并了,没有多余的那一次。

双方到底改了哪些字段

握手不是抽象的,它有具体的字段,把内核里那几个结构的初值翻一遍,比对着十张时序图猜有用得多,我第一次把 ss -ant 里那几列和内核字段对上,是在一次排查里被逼出来的。客户端 connect() 之后。

状态 值
sk_state TCP_SYN_SENT
write_seq 随机初始序列号(ISN),通常基于时钟
rcv_nxt 0(还不知道对方的)
snd_wnd 0(还没收到对方的窗口)
重传定时器 启动,初始 RTO
icsk_retransmits 0

服务端收到 SYN 之后,在 tcp_v4_conn_request() 里做这几件事,注意它分配的是半成品,不是完整 socket。

动作 说明
分配一个 request_sock(不是完整 sock) 半连接的开销比完整连接小得多
生成服务端 ISN 随机
把客户端信息塞进 SYN 队列 ss 里看不到它的长度,LISTEN 的 Recv-Q 数的是 accept 队列
回 SYN+ACK
TCP_SYN_RECV 状态 注意这是 request_sock 的状态,不是完整 socket 的

服务端收到第三次 ACK 之后才动真格。

动作 说明
从 SYN 队列取出 request_sock
tcp_create_openreq_child() 创建完整 sock 这一步才真正分配全部资源
放进 accept 队列 这就是 ss 里 LISTEN 状态的 Recv-Q(长度),Send-Q 是它的上限
socket 状态变 TCP_ESTABLISHED
唤醒 accept() 的调用者

所以服务器并发能力这件事,在握手阶段就分成了两个不同的队列,一个存半连接、一个存完整连接,这两个队列的溢出行为完全不同,混为一谈的时候你会看到特别矛盾的现象,ss 里 Recv-Q 一直是 0,客户端却在成片超时。

序列号为什么是随机的

早期 TCP 的 ISN 是 32 位计数器,每秒加 1,这个设计在 1985 年被 Robert Morris 指出会导致序列号预测攻击,攻击者只要猜到你说的下一个 ISN,就能伪造数据包塞进连接,而当时没人觉得这是个问题,因为那会儿网络里基本全是好人。现在 Linux 的做法换了,代码在 net/ipv4/tcp_ipv4.c 的 secure_tcp_seq() 里。

1
ISN = 一个基于 SipHash(四元组 + 密钥 + 时钟) 的哈希

密钥在开机时随机生成一次,之后不再轮换(net_get_random_once()),但每次连接还掺了时间戳,所以同一对 IP:port 重新连接,ISN 完全不同。这就是不能靠抓包猜下一个连接的原因。

第一次:客户端发出 SYN

一切从这两行开始。

1
2
int fd = socket(AF_INET, SOCK_STREAM, 0);
connect(fd, &addr, sizeof addr);

connect() 在这背后做四件事。

  1. 分配一个 struct sock,状态 TCP_CLOSE;
  2. 选源端口(从 ip_local_port_range 里挑,inet_hash_connect() 保证唯一);
  3. 调 tcp_connect(),生成 ISN,构造 SYN 包;
  4. 挂上重传定时器,返回 EINPROGRESS(阻塞 socket 下内核会等,看起来像阻塞)。

注意第 2 步,源端口是在 connect 的时候选的,不是 bind 的时候,所以端口耗尽发生在 connect 阶段,报错长这样。

1
connect: Cannot assign requested address

EADDRNOTAVAIL,意思是所有 28000 个临时端口都被占了。(28000 这个数字我第一反应是少得离谱,后来才知道那是 ip_local_port_range 的默认区间,容器里跑高并发客户端一天能撞好几次)

第二次:服务端回 SYN+ACK

服务端收到 SYN,tcp_rcv_state_process() 里按顺序检查,顺序本身就是设计的一部分,越便宜的先做。

  1. 校验和(不对就丢);
  2. 查四元组在不在已有连接表里(__inet_lookup);
  3. 不在 → 看是不是 SYN;
  4. 看 SYN 队列满没满(sk_acceptq_is_full 和 inet_csk_reqsk_queue_is_full);
  5. 走 tcp_v4_conn_request():分配 request_sock,做 syncookies 检查(SYN 队列满了可以不开内存直接回 SYN+ACK),构造 SYN+ACK,把 request_sock 挂到 SYN 队列,启动定时器(TCP_TIMEOUT_INIT 约 1 秒,重试 5 次)。

这一步最关键的细节是,如果 SYN 队列满了,内核默认会丢 SYN,不回任何东西,客户端只会看到超时,和服务端根本没开机一模一样。这个行为由 net.ipv4.tcp_abort_on_overflow = 0 控制,设成 1 的话服务端会回 RST,客户端能看到 Connection reset by peer,排查上更好,但生产上通常不这么设,因为会放大攻击流量。

第三次:客户端回 ACK

客户端收到 SYN+ACK 之后做四件事。

  1. 检查 ack == write_seq + 1(对不上就回 RST);
  2. 检查时间戳(如果启用了 tcp_timestamps,这里比的是对端最近的 ts_recent,用来防 PAWS,也就是重传的旧包冒充新包);
  3. 状态变 TCP_ESTABLISHED;
  4. 发 ACK。

注意客户端这时就已经让 connect() 返回了,它不需要等自己的 ACK 被确认。服务端收到那个 ACK,流程长一些。

  1. tcp_v4_hnd_req() 找到对应的 request_sock;
  2. tcp_check_req() 验证;
  3. tcp_v4_syn_recv_sock() → tcp_create_openreq_child() 建完整 sock;
  4. 检查 accept 队列满没满,这里满了的话,新连接会被丢弃(不是回 RST)。
  5. 挂到 accept 队列,状态 TCP_ESTABLISHED;
  6. 唤醒等在 accept() 上的进程。

这就是第 4 步的意义,即使 SYN 队列没满、三次握手完整走完,只要 accept 队列满了,服务端照样不会理这个连接,应用层来不及 accept() 也会让客户端连不上,而客户端那边看起来是网络问题,它会老老实实按 SYN 重传的节奏重试一分多钟。

两条队列,两个坑

这是最容易被混为一谈的两个东西,我在面试里被问过,也在代码注释里写错过。

SYN 队列 accept 队列
内核结构 request_sock_queue 里的 qlen/young 计数,半连接本身挂在 ehash 上(4.4 起不再有 syn_table) accept_queue
存什么 request_sock(半连接) 完整 sock(三次握手已完成)
大小限制 tcp_max_syn_backlog(默认值随机器内存变,官方文档只承诺低内存机器最少 128,具体多少 sysctl net.ipv4.tcp_max_syn_backlog 读) listen(fd, backlog),backlog 和 somaxconn 取小
满了怎么办 丢 SYN / 启用 syncookies 丢弃第三次 ACK
ss 里怎么看 看不到:Recv-Q 数的是 accept 队列 Recv-Q 是长度,Send-Q 是上限

监听 socket 的 Recv-Q 是 accept 队列的长度,也就是已完成握手、还没被 accept() 取走的连接数,Send-Q 是这个队列的上限,listen(backlog) 和 somaxconn 取小。SYN 队列(半连接)的长度在 ss 里看不到,只能看 netstat -s 或 /proc/net/netstat 里的 SYNs to LISTEN sockets dropped 之类的计数。内核里的对应关系很直接,tcp_diag_get_info() 就是拿这两个字段填的,idiag_rqueue = sk_ack_backlog(accept 队列长度),idiag_wqueue = sk_max_ack_backlog(上限)。

1
ss -lnt
1
2
State   Recv-Q  Send-Q  Local Address:Port  Peer Address:Port
LISTEN 2 4096 0.0.0.0:8080 0.0.0.0:*

Recv-Q 2 意味着有 2 个连接已经握手完成、躺在 accept 队列里等 accept(),长期大于 0 就说明应用来不及 accept(),可能是单线程忙、accept() 之后处理太慢、或者干脆忘了调。长期顶到 Send-Q 那个值,第三次握手就会被丢掉。

握手阶段的重传

SYN 和 SYN+ACK 都会重传,参数在两个 proc 文件里。

1
2
cat /proc/sys/net/ipv4/tcp_syn_retries
cat /proc/sys/net/ipv4/tcp_synack_retries

默认都是 6 和 5。tcp_syn_retries = 6 意味着重传发生在第 1、3、7、15、31、63 秒,第 6 次重传之后还要再等一个 64 秒的 RTO 才判定失败,一共 127 秒。我刚写下这段的时候算的是 1+2+4+8+16+32 = 63,漏掉了最后一次等待,man 7 tcp 里那句 “retrying for up to approximately 127 seconds” 才是对的。(让我去 man 一下的是组里一个同事,语气很客气,但那句算错了我确实记了很久)所以偶发连接超时经常是 127 秒,而不是 5 秒,这是 SYN 重传的完整周期,我第一次量到这个数字的时候还以为 curl 的 --max-time 没生效。内网建议把 tcp_syn_retries 调到 2~3,失败会在 7~15 秒内暴露。

抓包看三次握手

手边没有现成的服务也没关系,Python 自带一个,起在 8080 上就行。

1
2
sudo apt install tcpdump        # 没装的话
python3 -m http.server 8080 & # 起一个只会说 hello 的服务

然后在另一个终端抓包,只抓带 SYN 或 ACK 的那些,-c 6 是抓够六个就停,不然它会一直挂着。

1
sudo tcpdump -i any -nn 'tcp port 8080 and (tcp[tcpflags] & (tcp-syn|tcp-ack) != 0)' -c 6

再开第三个终端连一下。

1
curl -s http://127.0.0.1:8080/ -o /dev/null

抓到的三行是这样。

1
2
3
IP 127.0.0.1.48122 > 127.0.0.1.8080: Flags [S],  seq 1234567890, win 65495, options [...]
IP 127.0.0.1.8080 > 127.0.0.1.48122: Flags [S.], seq 987654321, ack 1234567891, win 65483
IP 127.0.0.1.48122 > 127.0.0.1.8080: Flags [.], ack 987654322, win 502

注意 [S]、[S.]、[.] 这三个记号,. 表示 ACK 位被置。seq 后面的两个值是关键,ACK 一定等于对方的 seq + 1,因为 SYN 占掉一个序号。

看 seq 是不是真的随机

连两次抓两次,写成循环省事。

1
2
3
4
5
6
for i in 1 2; do
sudo tcpdump -i lo -nn 'tcp port 8080 and tcp[tcpflags] & tcp-syn != 0' -c 1 2>/dev/null &
sleep 0.3
curl -s http://127.0.0.1:8080/ -o /dev/null
wait
done

两次的 seq 完全无关,中间没有线性关系,这就是 secure_tcp_seq 的效果。如果看到两次 seq 相差很小,那可能是关掉了随机化,这种情况极少见,见到了值得记一笔。

造一次中间状态

起一个不 accept() 的服务,把 listening backlog 设成 1。

1
2
3
4
5
6
7
8
9
10
python3 -c "
import socket, time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', 9090))
s.listen(1)
print('listening, not accepting')
time.sleep(120)
" &
sleep 1

然后开几个连上不发的客户端。

1
2
3
4
5
for i in 1 2 3 4 5; do
(exec 3<>/dev/tcp/127.0.0.1/9090; sleep 30) &
done
sleep 2
ss -lnt 'sport = :9090'

你会看到这样两行。

1
2
State   Recv-Q  Send-Q  Local Address:Port  Peer Address:Port
LISTEN 0 1 0.0.0.0:9090 0.0.0.0:*

Send-Q 是 1,listen(1) 的效果,Recv-Q 是 0。再连更多,就会看到有些连接卡在 SYN_RECV。

1
ss -ant 'sport = :9090 or dport = :9090'
1
2
3
4
5
State      Recv-Q  Send-Q  Local:Port       Peer:Port
LISTEN 0 1 0.0.0.0:9090 0.0.0.0:*
ESTAB 0 0 127.0.0.1:9090 127.0.0.1:48130
SYN-RECV 0 0 127.0.0.1:9090 127.0.0.1:48131
SYN-SENT 0 0 127.0.0.1:48132 127.0.0.1:9090

三种状态同时在,这就是握手中途的样子。玩完记得清理,后台进程会扒着不放。

1
kill %1 2>/dev/null; jobs -p | xargs -r kill 2>/dev/null

看 backlog 到底被截到了多少

listen(100000) 这种写法我写过,写的时候挺有信心。

1
2
3
4
5
6
7
8
9
10
11
python3 -c "
import socket
s = socket.socket()
s.bind(('0.0.0.0', 9600)) # 不 bind 的话内核会随机分一个端口,后面就找不到了
s.listen(100000)
print('requested backlog: 100000')
import time; time.sleep(5)
" &
sleep 1
ss -lnt 'sport = :9600' # 按端口查,别用 grep python,会撞上别的 python 进程
kill %1 2>/dev/null

Send-Q 会显示 4096,或者你的 somaxconn 值,反正不是 100000。

1
2
cat /proc/sys/net/core/somaxconn
sysctl net.ipv4.tcp_max_syn_backlog

listen() 的 backlog 是我想要多少,somaxconn 是内核最多给多少,实际取小的那个。(这个值我截图发过群,被回了句你以为你写多少就是多少吗,话糙但确实)

量一次 127 秒

把目标指到一个黑洞地址,time 一下就能看到完整周期。

1
time curl -s --max-time 150 http://10.255.255.1:9999/ -o /dev/null

10.255.255.1 是一个通常不会回应的地址,在容器或某些网络里可能不同。用 time 量出来的时长就是 tcp_syn_retries 决定的完整周期,大约 127 秒(如果 tcp_syn_retries 是 6),这个实验要跑两分钟,别用默认的 --max-time,会提前被 curl 自己掐断,量到的是 curl 的超时而不是内核的。这个数字解释了为什么很多偶发超时的耗时看着很怪。最后用 ss -i 看握手的细节信息。

1
ss -tni 'dport = :8080' | head -5
1
cubic wscale:7,7 rto:204 rtt:0.05/0.02 ato:40 mss:32768 ...

rto 是当前重传超时,rtt 是往返时间,握手阶段的 RTT 也记在这里。

connect 成功,对面可能还没准备好

有人问 connect() 返回成功是不是就说明连接建立了。客户端视角是的,服务端可能还在 SYN_RECV,第三次 ACK 丢了的话,客户端已经开始发数据,服务端还没建好完整 socket,这时客户端发的数据会被服务端回 RST。也有人说三次握手是为了确认双方都在线。更准确地说,是确认双方向都能收发,并且确认对方的序列号,防历史重复 SYN 才是为什么不能两次的核心。有人以为 listen() 的 backlog 就是并发上限。不是,它只限制 accept 队列,真正的并发上限是文件描述符数、内存和 somaxconn 三者中的最小值。还有人说 SYN 队列满了会回 RST,默认是静默丢弃,要看 Connection refused 得把 tcp_abort_on_overflow 设成 1。

ss 里 LISTEN 状态的 Recv-Q 是不是连接数。方向对了一半,它是 accept 队列的长度,已完成握手、还没被 accept() 取走,Send-Q 是它的上限,半连接的长度 ss 看不到。ISN 从 0 开始也是老黄历了,最早确实是,现在一定是随机的,这是防攻击的必要设计。还有人问握手是不是只有三个包。三层网络里每个包都在路由,同一时刻可能有几十个连接处在握手的不同阶段。握手超时也不一定就是网络断了,也可能是 iptables 的 DROP、NAT 表满、somaxconn 太小、路由黑洞,两端同时 tcpdump 能立刻区分没发出去和没收到回。

每一次握手改了什么

  • 三次握手每一次都在改双方内核的具体字段:客户端的 sk_state、write_seq;服务端的 request_sock、SYN 队列、accept 队列。
  • 客户端在第二次握手后就认为连接建立,服务端要到第三次。这个不对称是很多问题的根源。
  • 不能两次的核心原因,是防止延迟到达的历史 SYN 建立无效连接。
  • ISN 是随机哈希(secure_tcp_seq),防序列号预测攻击。
  • 两个队列分工明确:SYN 队列存半连接(满了丢 SYN),accept 队列存完成握手的 socket(满了丢第三次 ACK)。ss 里 LISTEN 状态的 Recv-Q 是 accept 队列的长度,Send-Q 是它的上限;SYN 队列的长度 ss 看不到。
  • listen(backlog) 实际上被 somaxconn 截断。
  • tcp_syn_retries = 6 意味着一次失败要等 127 秒,内网建议调到 2~3。
  • 排查连不上:两端同时 tcpdump 加 ss -ant,看卡在哪个状态。

连接建立之后到关闭之前还有 11 个状态,其中 TIME_WAIT 和 CLOSE_WAIT 最容易被混为一谈,一个是好现象,一个是你的代码有 bug。(这篇里关于内核函数的那些路径,是我照着 net/ipv4/ 下的源码一行行对过来的,文档里没写,我也只敢说自己看懂了七八成)