Skip to content
Go back

TCP三次握手——为什么不是两次或四次?

TCP 三次握手:为什么不是两次也不是四次?

一句话结论(30s)

TCP 三次握手是为了在不可靠的 IP 信道上可靠地建立连接:客户端发 SYN,服务端回 SYN+ACK,客户端再回 ACK。之所以要三次而不是两次,是因为两次无法阻止历史重复连接——服务端可能为一个客户端早已放弃的旧 SYN 空等、白白分配资源;之所以不是四次,是因为服务端的 SYN 和 ACK 由内核同时产生,天然可以合并成一条 SYN+ACK,拆开只会白白多耗一个 RTT。

核心原理(2min)

先停一秒钟,问自己:握手到底在”确认”什么?为什么不能像 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+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 进入半连接队列的入口

服务端收到 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,通过则分配连接。整个过程中服务端不维护任何半连接状态——状态被编码在了序列号里。

总结

三次握手的设计不是在”选一个看起来合适的数字”,而是精确满足了三个需求:

  1. 两次不够:无法阻止历史连接
  2. 四次没必要:SYN+ACK 可合并
  3. 三次刚好:双方确认收发能力 + 防止旧连接干扰 + 同步初始序列号

章末提问

追问 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_finend_seq++ 正是这一设计的内核体现。


Share this post on:

Previous Post
TCP与UDP区别:从传输层两大协议到QUIC的演进
Next Post
SYN Flood攻击与syncookies防御——半连接队列打满的原理