3. TCP三次握手与Socket:从理论到代码的落地

TCP三次握手,说白了就是两台机器建立连接前的一次「互相确认」。你发一个SYN,我回一个SYN+ACK,你再回一个ACK——这个流程大家背得滚瓜烂熟。但真正落到Socket代码里,每一步对应什么API、内核做了什么、网络栈怎么处理,很多人就懵了。

我刚开始做网络编程时,以为三次握手就是三次send/recv。结果线上服务偶尔出现连接超时,查了三天才发现是accept队列满了。嗯,这里面的门道,咱们今天一次讲透。

3.1 三次握手:不只是「你好、你好、你好」

先看标准流程。客户端和服务端通过三次报文交换,完成序列号同步和窗口协商。我用一张图把整个过程画清楚:

TCP三次握手与Socket API对应关系 客户端 服务端 CLOSED → SYN_SENT CLOSED → LISTEN ① SYN (seq=x) connect() 触发 ② SYN+ACK (seq=y, ack=x+1) 内核自动回复 ③ ACK (ack=y+1) connect() 返回 → ESTABLISHED LISTEN → SYN_RCVD → ESTABLISHED accept() 从已完成队列取连接 ⚠ 关键点 • 前两次握手不能携带数据,第三次握手可以 • 服务端在收到 SYN 后立即分配资源(半连接队列) • 第三次握手到达后,连接移入全连接队列,accept() 才能取出

这张图里我特意把Socket API标注在对应位置。你会发现:connect() 只触发第一次握手,accept() 要等到三次握手全部完成才能拿到连接。这个时间差,就是很多问题的根源。

3.2 Socket API 在三次握手中的作用

很多人以为 connect() 返回就代表连接建立好了。其实 connect() 返回 只代表第三次握手发出去了,不代表服务端已经处理完。我踩过这个坑——客户端 connect() 成功立即发数据,结果服务端还没从 accept() 返回,数据就丢了。

3.2.1 connect():客户端的「敲门砖」

connect() 干了三件事:

  1. 构造 SYN 报文:内核自动填充源端口、序列号、窗口大小
  2. 发送 SYN:进入 SYN_SENT 状态
  3. 等待 SYN+ACK:阻塞直到收到服务端回复

connect() 返回时,客户端已经进入 ESTABLISHED 状态。但注意——服务端此时还在 SYN_RCVD 状态,要等第三次握手到达才变成 ESTABLISHED。

个人经验: 我曾经在压测时发现 connect() 耗时波动很大。后来排查发现是服务端半连接队列满了,SYN 被丢弃,客户端重试导致延迟飙升。解决方案是调大 tcp_max_syn_backlog。

3.2.2 accept():服务端的「取号机」

accept() 不参与握手过程。它只是从内核的「已完成连接队列」里取一个连接。这个队列由三次握手完成后自动填充。

关键点:

  • accept() 之前:三次握手已经完成,连接已经建立
  • accept() 返回:拿到一个新的 socket fd,代表这个连接
  • accept() 阻塞:如果队列为空,进程会休眠
避坑指南: 我曾经写过一个服务,accept() 之后直接 recv(),结果 recv() 返回 0。查了半天发现是客户端在三次握手完成后立即关闭了连接。accept() 拿到的连接可能已经「死」了——这叫「惊群效应」的变种。解决方案是 accept() 后先检查连接状态。

3.3 三次握手的完整状态变迁

我把客户端和服务端的状态变化整理成一张表,方便对照:

步骤 客户端状态 服务端状态 报文 Socket API
初始 CLOSED LISTEN - socket() + bind() + listen()
第一次握手 SYN_SENT SYN_RCVD SYN (seq=x) connect() 触发
第二次握手 ESTABLISHED SYN_RCVD SYN+ACK (seq=y, ack=x+1) 内核自动回复
第三次握手 ESTABLISHED ESTABLISHED ACK (ack=y+1) connect() 返回 / accept() 可取

注意一个细节:客户端在收到 SYN+ACK 后就进入 ESTABLISHED 了,比服务端早。这意味着客户端可以立即发数据,但数据要等到服务端完成第三次握手后才能被处理。

3.4 SYN Flood 攻击:三次握手的「死穴」

SYN Flood 是经典的 DDoS 攻击方式。原理很简单:攻击者伪造大量 SYN 报文,但不完成第三次握手。服务端为每个 SYN 分配资源(半连接队列),很快就把内存耗尽,无法处理正常连接。

3.4.1 攻击流程

  1. 攻击者发送大量 SYN 报文,源 IP 伪造为不存在的主机
  2. 服务端回复 SYN+ACK,等待 ACK
  3. 服务端将连接放入半连接队列(SYN_RCVD 状态)
  4. 攻击者不回复 ACK,半连接队列逐渐填满
  5. 新连接无法建立,服务端拒绝服务
核心问题: 服务端在收到 SYN 时就分配了资源(内存、控制块),但无法确认客户端是否真实存在。这种「先信任后验证」的设计,就是 SYN Flood 的突破口。

3.4.2 防御手段

我整理了几种常见的防御方案,以及它们的优缺点:

方案 原理 优点 缺点
SYN Cookie 收到 SYN 时不分配资源,用 Cookie 编码信息 几乎不消耗内存 丢失部分 TCP 选项
增大 backlog 调大半连接队列长度 简单粗暴 治标不治本
SYN 重试 + 超时 减少 SYN+ACK 重试次数,加快超时 释放资源快 正常用户可能受影响
防火墙过滤 识别异常 SYN 流量并丢弃 从源头阻断 需要硬件/软件支持
我的实践: 线上服务我一般同时开启 SYN Cookie 和调大 backlog。SYN Cookie 是最后一道防线,backlog 是缓冲。但注意——SYN Cookie 会丢失 TCP 时间戳和窗口缩放选项,对高带宽长肥网络有影响。我遇到过因为 SYN Cookie 导致吞吐量下降 30% 的案例。

3.4.3 代码层面的防御

在应用层,我们可以通过调整 Socket 选项来缓解 SYN Flood:

// 开启 SYN Cookie(Linux 内核参数)
// /etc/sysctl.conf 或 sysctl 命令
net.ipv4.tcp_syncookies = 1

// 调大半连接队列
net.ipv4.tcp_max_syn_backlog = 4096

// 减少 SYN+ACK 重试次数
net.ipv4.tcp_synack_retries = 2

// 开启 TCP 时间戳(辅助 SYN Cookie)
net.ipv4.tcp_timestamps = 1

这些参数在 /etc/sysctl.conf 里配置,执行 sysctl -p 生效。我个人习惯在部署脚本里自动设置,避免每次手动改。

警告: 不要在生产环境随意调小 tcp_synack_retries。我见过有人设成 0,结果正常用户网络波动时连接频繁超时。建议保留至少 1-2 次重试。

3.5 本章小结

三次握手不是简单的三次报文交换。它背后涉及 Socket API 的调用时机、内核队列的管理、以及安全风险。记住几个关键点:

  • connect() 只触发第一次握手,accept() 等三次握手完成
  • 服务端在 SYN_RCVD 状态就分配了资源,这是 SYN Flood 的根源
  • SYN Cookie 是有效的防御手段,但有副作用
  • 调参要谨慎,别为了防攻击牺牲正常用户体验