TCP 三次握手:为什么不是两次也不是四次?
一句话结论(30s)
TCP 三次握手是为了在不可靠的 IP 信道上可靠地建立连接:客户端发 SYN,服务端回 SYN+ACK,客户端再回 ACK。之所以要三次而不是两次,是因为两次无法阻止历史重复连接——服务端可能为一个客户端早已放弃的旧 SYN 空等、白白分配资源;之所以不是四次,是因为服务端的 SYN 和 ACK 由内核同时产生,天然可以合并成一条 SYN+ACK,拆开只会白白多耗一个 RTT。
核心原理(2min)
- 本质目标:让双方各自确认对方的收发能力、同步初始序列号(ISN),并防止旧连接干扰新连接。
- 两次为什么不够:若只有两次握手,服务端收到 SYN 就立即进入 ESTABLISHED 并分配资源,却无法确认这是客户端”当下”想要的连接;历史滞留的旧 SYN 会让服务端为一个早已失效的连接空等。
- 四次为什么没必要:服务端收到 SYN 后,内核立刻就能确定自己的初始序列号并构造回复,SYN 与 ACK 天然同时产生,合并为 SYN+ACK 没有信息损失,拆开只会多一次往返(多一个 RTT)。
- 一句话理解:三次是”防旧连接 + 同步序列号”两条需求共同决定的最小握手次数——两次防不住旧连接,四次纯属浪费。
先停一秒钟,问自己:握手到底在”确认”什么?为什么不能像 UDP 那样直接发数据? 因为 TCP 是面向连接的可靠协议,通信前双方必须互相对齐”我从哪个序号开始发”。序号(ISN)不是双方各拍脑袋定一个就行的——它需要双向交换:客户端要告诉服务端”我的起始序号是 x”,服务端也要告诉客户端”我的起始序号是 y”。这就决定了至少要有两方各说一次的机会。那为什么还需要第三条?带着这个问题往下读。
底层深入(5-10min)
先看完整的握手过程
客户端 服务端
| |
|──── SYN (seq=x) ────────────→| ① 客户端: 我要连你,我起始 seq=x
| |
|←─── SYN+ACK (seq=y, ack=x+1) | ② 服务端: 收到,我起始 seq=y,你 seq 到 x 了
| |
|──── ACK (ack=y+1) ──────────→| ③ 客户端: 收到,你 seq 到 y 了
| |
双方进入 ESTABLISHED
三次握手后,双方都确认了对方的收发能力正常。
内核视角:客户端怎么发出第一条 SYN
客户端发起连接时,内核的 tcp_connect() 会构造一个只带 SYN 标志的空数据包并发送出去。注意注释里那句 “SYN eats a sequence byte”——SYN 和 FIN 一样,本身要占用一个序号,这也是后面所有 ack = seq + 1 的来源。
/* Build a SYN and send it off. */
int tcp_connect(struct sock *sk)
{
...
tcp_connect_init(sk);
...
/* SYN eats a sequence byte, write_seq updated by
* tcp_connect_queue_skb().
*/
tcp_init_nondata_skb(buff, sk, tp->write_seq, TCPHDR_SYN);
tcp_mstamp_refresh(tp);
tp->retrans_stamp = tcp_time_stamp_ts(tp);
tcp_connect_queue_skb(sk, buff);
tcp_ecn_send_syn(sk, buff);
tcp_rbtree_insert(&sk->tcp_rtx_queue, buff);
/* Send off SYN; include data in Fast Open. */
err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :
tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);
...
/* Timer for repeating the SYN until an answer. */
tcp_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
inet_csk(sk)->icsk_rto, false);
return 0;
}
分析:tcp_init_nondata_skb(buff, sk, tp->write_seq, TCPHDR_SYN) 这条最关键——SYN 被当作一个”不携带数据但占一个序号”的 skb 塞进发送队列。最后两行启动了重传定时器:SYN 发出去之后如果没有应答,内核会按 RTO 反复重发,这正是”在不可靠信道上可靠建连”的第一道保险。
💭 思考:为什么连“建立连接”这种还没开始传数据的动作,也要靠重传定时器兜底?——因为 SYN 走的也是和普通数据一样的不可靠 IP 信道,包丢在路上,服务端根本不知道有连接请求。所以“可靠建连”的本质不是“包不会丢”,而是“丢了就重发,直到对方应答或放弃”。这也顺带解释了为什么 SYN 要像数据一样占用一个序号——它必须能被对端 ACK,发送方才好判断“对方到底收没收到”。
为什么不是两次?
核心原因:防止历史重复连接。
假设客户端发了一个 SYN(seq=90),但这个包在网络中滞留了很久。客户端超时重发了 SYN(seq=100)。服务端收到第二个 SYN,按两次握手建立连接,数据传输完毕关闭。过了一段时间,网络深处那个旧 SYN(seq=90)终于到达服务端。
- 两次握手:服务端收到旧 SYN → 回复 SYN+ACK → 直接进入 ESTABLISHED 状态,分配资源等待数据。但客户端早已放弃这个旧连接,服务端空等造成资源浪费。
- 三次握手:服务端收到旧 SYN → 回复 SYN+ACK(seq=y, ack=91)→ 进入 SYN_RCVD 等待。客户端收到这个 SYN+ACK,发现 ack=91 而不是期望的值 → 回复 RST 终止连接,服务端收到 RST 释放资源。
三次握手让服务端有机会确认客户端的”意图”——确实想要这个连接。
跟着推一遍:为什么”确认客户端意图”这一关非要放在第三次,第二次就不行? 因为服务端在第二次(SYN+ACK)时只是”回应”了客户端,它自己还无法判断客户端是不是真的想连——那个 SYN 可能是网络里飘了半天的旧包。只有当客户端看到 SYN+ACK 里的
ack=91、发现”这不是我当前请求的序号”并回 RST 时,服务端才第一次真正知道了客户端的意图。这一问一答,正是三次握手多出来的那”第三脚”。
内核视角:客户端如何”识破”旧连接
客户端在 SYN_SENT 状态收到 SYN+ACK 后,会先校验 ACK 号是否落在自己发送的范围内。这段校验就是”防历史连接”的落点——序号对不上的 SYN+ACK 会被直接 RST:
if (th->ack) {
/* rfc793:
* "If the state is SYN-SENT then
* first check the ACK bit
* If the ACK bit is set
* If SEG.ACK =< ISS, or SEG.ACK > SND.NXT, send
* a reset (unless the RST bit is set, if so drop
* the segment and return)"
*/
if (!after(TCP_SKB_CB(skb)->ack_seq, tp->snd_una) ||
after(TCP_SKB_CB(skb)->ack_seq, tp->snd_nxt)) {
/* Previous FIN/ACK or RST/ACK might be ignored. */
if (icsk->icsk_retransmits == 0)
tcp_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
TCP_TIMEOUT_MIN, false);
SKB_DR_SET(reason, TCP_INVALID_ACK_SEQUENCE);
goto reset_and_undo;
}
分析:!after(ack_seq, snd_una) 意思是”这个 ACK 号 ≤ 我最早发送的序号”,说明它是冲着一个早已被放弃的旧 SYN 来的;after(ack_seq, snd_nxt) 则说明 ACK 越界了。两种情况都会走到 reset_and_undo 回一个 RST——这正是上面”旧 SYN(seq=90)被客户端用 RST 拒绝”的内核实现。
为什么不是四次?
服务端的 SYN 和 ACK 可以合并为一条消息(SYN+ACK),因为它们在内核中同时产生。四次握手中的第二次(ACK)和第三次(SYN)在语义上可以被合并。
换成四次:
客户端 → SYN (x)
服务端 → ACK (x+1) ← 这两步可以合并
服务端 → SYN (y) ←
客户端 → ACK (y+1)
在实际 TCP 实现中,服务端收到 SYN 后,内核立刻可以构造 SYN+ACK——序列号的确定不依赖应用层。所以合并这两步没有信息损失,分开只会增加一个 RTT。
内核视角:SYN 和 ACK 就是同一个包的两个标志位
在 Linux 里,tcp_send_synack() 会先把 SYN 包取出来,然后直接对它的控制位做一次 |= TCPHDR_ACK 操作——SYN 和 ACK 合并成一条 SYN+ACK,从代码上就是”同一个 skb 多置一个位”:
/* Send a crossed SYN-ACK during socket establishment. */
int tcp_send_synack(struct sock *sk)
{
struct sk_buff *skb;
skb = tcp_rtx_queue_head(sk);
if (!skb || !(TCP_SKB_CB(skb)->tcp_flags & TCPHDR_SYN)) {
pr_err("%s: wrong queue state\n", __func__);
return -EFAULT;
}
if (!(TCP_SKB_CB(skb)->tcp_flags & TCPHDR_ACK)) {
...
TCP_SKB_CB(skb)->tcp_flags |= TCPHDR_ACK;
tcp_ecn_send_synack(sk, skb);
}
return tcp_transmit_skb(sk, skb, 1, GFP_ATOMIC);
}
分析:tcp_flags |= TCPHDR_ACK 一行就把 SYN 升级成了 SYN+ACK,中间没有任何应用层参与,也不需要额外一个 RTT。这就是”四次没必要”的源码证据——第二次和第三次在协议栈里根本是同一个时刻、同一个包。
💭 思考:为什么建连时 SYN 和 ACK 能合并成一条,挥手时 ACK 和 FIN 却不能?——关键看这两个标志位是不是“同一个人、同一时刻”产生的。建连时服务端收到 SYN,内核立刻就能定下自己的 ISN 并构造回复,SYN 与 ACK 天然同时;挥手时 ACK 是内核秒回、FIN 却要等应用层 close(),中间隔着“服务端继续发数据”的间隙。所以“能不能合并”从来不是协议说了算,而是“触发者与时间点是否一致”说了算。
握手与 accept 的关系
三次握手完全由内核完成,accept() 只是从就绪队列取连接。
客户端 connect() 服务端内核 服务端进程
| | |
├─ SYN ────────────────→| |
| ├─ 半连接队列(syn queue) |
├─ SYN+ACK ←────────────┤ |
| | |
├─ ACK ────────────────→| |
| ├─ 全连接队列(accept queue)
| | |
| ←─ accept() 从队列中取出─┤
两个队列:
- 半连接队列(syn queue):收到 SYN 但未完成三次握手的连接
- 全连接队列(accept queue):已完成三次握手,等待应用
accept()的连接
内核视角:SYN 进入半连接队列的入口
服务端收到 SYN 后,tcp_conn_request() 负责分配一个 request_sock 挂进半连接队列,同时做 SYN Flood 防护。注意下面两段:队列满时的 cookie 兜底,和”历史 SYN 直接转为 open request 绕过限制”的注释:
/* If isn is non-zero, this SYN originally matched a TIME_WAIT socket.
* TW sockets are converted to open requests without limitations,
* we skip the queue limits and syncookie checks in the block below.
*/
if (!isn) {
syncookies = READ_ONCE(net->ipv4.sysctl_tcp_syncookies);
if (syncookies == 2 || inet_csk_reqsk_queue_is_full(sk)) {
want_cookie = tcp_syn_flood_action(sk,
rsk_ops->slab_name);
if (!want_cookie)
goto drop;
}
}
if (sk_acceptq_is_full(sk)) {
NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
goto drop;
}
req = inet_reqsk_alloc(rsk_ops, sk, !want_cookie);
if (!req)
goto drop;
if (!want_cookie && !isn) {
int max_syn_backlog = READ_ONCE(net->ipv4.sysctl_max_syn_backlog);
/* Kill the following clause, if you dislike this way. */
if (!syncookies &&
(max_syn_backlog - inet_csk_reqsk_queue_len(sk) <
(max_syn_backlog >> 2)) &&
!tcp_peer_is_proven(req, dst)) {
...
goto drop_and_release;
}
isn = st.seq;
}
...
if (!want_cookie &&
unlikely(!inet_csk_reqsk_queue_hash_add(sk, req))) {
reqsk_free(req);
dst_release(dst);
return 0;
分析:inet_csk_reqsk_queue_hash_add() 就是把连接挂进半连接队列的动作;而 inet_csk_reqsk_queue_is_full() / tcp_syn_flood_action() 则是 SYN Flood 的第一道闸——队列一满,内核转而走 SYN Cookie,把状态编码进序号、不再占用内存。这也解释了为什么半连接队列满了之后,合法 SYN 会被丢弃。
💭 思考:为什么半连接队列一旦被塞满,连合法用户的连接也会被拒?——因为队列是“先到先得”的固定空间,服务端在收到第三次 ACK 之前,没法区分“真连接”和“只发 SYN 不回 ACK 的假连接”。攻击者用海量 SYN 把队列占满,合法 SYN 就只能被丢弃。这也埋下了后面 SYN Cookie 的伏笔:既然“排队等确认”会被打满,那就干脆“不排队、不占内存”,把状态藏进序号里。
内核视角:第三次 ACK 到达,连接从半连接转正
当服务端收到客户端的第三次 ACK,tcp_rcv_state_process() 的 TCP_SYN_RECV 分支把 socket 正式置为 TCP_ESTABLISHED:
case TCP_SYN_RECV:
WRITE_ONCE(tp->delivered, tp->delivered + 1); /* SYN-ACK delivery isn't tracked in tcp_ack */
if (!tp->srtt_us)
tcp_synack_rtt_meas(sk, req);
...
tcp_ao_established(sk);
smp_mb();
tcp_set_state(sk, TCP_ESTABLISHED);
sk->sk_state_change(sk);
...
WRITE_ONCE(tp->snd_una, TCP_SKB_CB(skb)->ack_seq);
tp->snd_wnd = ntohs(th->window) << tp->rx_opt.snd_wscale;
tcp_init_wl(tp, TCP_SKB_CB(skb)->seq);
分析:tcp_set_state(sk, TCP_ESTABLISHED) 之后,内核才把这个 socket 移进全连接队列(accept queue),等待应用 accept() 取走。也就是说,服务端真正”认下”这条连接,是在收到第三条消息之后——这和”三次握手防旧连接”的结论完全吻合。
而客户端那一侧,收到 SYN+ACK 后由 tcp_rcv_synsent_state_process() 完成同样的状态跃迁:
/* rfc793:
* "If the SYN bit is on ...
* are acceptable then ...
* (our SYN has been ACKed), change the connection
* state to ESTABLISHED..."
*/
...
/* Ok.. it's good. Set up sequence numbers and
* move to established.
*/
WRITE_ONCE(tp->rcv_nxt, TCP_SKB_CB(skb)->seq + 1);
...
tcp_finish_connect(sk, skb);
分析:注意 rcv_nxt = seq + 1——因为服务端的 SYN 也占了一个序号,所以客户端要 ACK 的是 y+1。两边的状态跃迁都完成后,双方才真正进入 ESTABLISHED,开始传数据。
再追问一层:客户端在第二次就已经拿到了服务端的 ISN,为什么还要发第三条 ACK,服务端才能进入 ESTABLISHED? 因为服务端的 ISN 只走了”服务端→客户端”这一半,客户端还没证明”我收到了你的 ISN,并愿意按这个序号开始通信”。第三条 ACK 携带
ack=y+1,正是把”服务端→客户端方向”的序号同步补全。三次之后,两个方向的序号都各自完成了”发出 + 确认”。
SYN Flood 攻击
攻击者发送大量伪造源 IP 的 SYN 包,服务端回复 SYN+ACK 后进入 SYN_RCVD 状态,但永远等不到第三次 ACK。大量半连接耗尽 syn queue,导致合法 SYN 被丢弃。
防御——SYN Cookie(tcp_syncookies=1):
收到 SYN 时,不分配连接资源,而是将连接信息(双方 IP/端口 + MSS + 时间戳)哈希为一个”Cookie”,作为 SYN+ACK 的初始序列号。收到 ACK 时验证 Cookie,通过则分配连接。整个过程中服务端不维护任何半连接状态——状态被编码在了序列号里。
总结
三次握手的设计不是在”选一个看起来合适的数字”,而是精确满足了三个需求:
- 两次不够:无法阻止历史连接
- 四次没必要:SYN+ACK 可合并
- 三次刚好:双方确认收发能力 + 防止旧连接干扰 + 同步初始序列号
章末提问
追问 1:三次握手能不能简化成两次?为什么? 结论先行——不能。因为两次握手时,服务端只要收到 SYN 就进入 ESTABLISHED 并分配资源,它无法区分”这是客户端当下的连接请求”还是”网络深处滞留的旧 SYN”。一旦旧 SYN 迟到,服务端会为一个客户端早已放弃的连接空等,造成资源浪费和状态错乱。三次握手把”确认意图”这一脚留给了客户端:客户端收到 SYN+ACK 后校验 ack 号,发现是旧连接就回 RST 终止。
追问 2:握手阶段为什么会丢包?丢包后怎么恢复?
结论先行——握手也会丢包,靠重传定时器兜底。因为握手报文走的同样是不可靠的 IP 信道。内核源码里 tcp_connect() 发完 SYN 后立刻 tcp_reset_xmit_timer(sk, ICSK_TIME_RETRANS, ...) 启动重传;服务端的 SYN+ACK 若在 SYN_RECV 等不到 ACK,也会重发。所以建连的可靠性不是”包不会丢”,而是”丢了能重发直到成功或放弃”。
追问 3:SYN Cookie 为什么能防 SYN Flood?它牺牲了什么? 结论先行——它把”半连接状态”从内存挪到了序号里,让服务端收到 SYN 时完全不分配资源。因为 Cookie 是把双方 IP/端口、MSS、时间戳哈希后当作 SYN+ACK 的 ISN 发出去的,服务端只在收到第三次 ACK 时反解并验证 Cookie,验证通过才真正建连。代价是:Cookie 序列号空间有限,放不下全部 TCP 选项(比如大的窗口缩放因子、SACK),所以一旦触发 SYN Cookie,连接的某些性能特性会被降级。
追问 4:为什么 ISN 不能固定为 0 或某个常数,非要随机?
结论先行——防止历史重复连接和伪造连接。如果 ISN 固定,一个迟到的旧数据包(或旧 SYN)的序号很容易落在新连接的接收窗口内,被误当成新连接的数据;随机的、随时间单调递增的 ISN 让新旧连接在序号空间上几乎不可能碰撞。源码里 tcp_conn_request 通过 tcp_v4_init_seq_and_ts_off()(基于时钟与随机源)生成 ISN,就是为这个目的。
追问 5:SYN 和 FIN 都要占用一个序号,这带来什么设计上的好处?
结论先行——让 SYN/FIN 这类控制信息也能被可靠确认和重传。因为它们”吃掉”一个序号,对端就必须用 ack = seq + 1 显式确认收到这个控制位;如果没收到 ACK,发送方就能重传。反之,如果 SYN/FIN 不占序号,就无法区分”对端确认的是数据还是控制位”,重传判定也会失效。源码注释 “SYN eats a sequence byte” 和 tcp_send_fin 里 end_seq++ 正是这一设计的内核体现。