TCP 网络编程
记忆锚点
握手 3 次是"确认双方都能收发"最少次数;挥手 4 次因为关闭是半双工的——一方 FIN 只关自己方向,对方还能发。TIME_WAIT 2MSL 是为了旧包全部消散 + 让最后一个 ACK 有机会重传。TCP 是字节流不是消息流 → 应用层必须自己定分包协议。
一句话结论
TCP 靠三次握手建连、字节流传输、epoll 撑高并发、拥塞控制保可靠。
场景问题
打个比方(握手为什么是 3 次):建连就像打电话对暗号,目的是双方都确认"我能发、也能收"。A 喊"喂,听得到吗?"(证明 A 能发);B 答"听得到,你那边呢?"(证明 B 能收、也能发);A 再回"我也听得到"(证明 A 能收)——到这三句,双向链路才算都验证过。少一句(2 次),B 就没法确认 A 到底收没收到自己那句话,可能对着空气自说自话。挥手为什么是 4 次:因为关闭是半双工的,好比分手要各说各的"我说完了"——你这边说完不代表对方也讲完了(他还能继续发数据),所以两个方向各自
FIN一次、各自ACK一次,凑成 4 次。类比失效边界:打电话是"你一句我一句"有天然停顿的,但 TCP 是字节流、不保留消息边界——你发的两句话可能被粘成一坨、也可能被拆散(粘包/拆包),所以应用层必须自己定一套分包协议(定长 / 分隔符 / 长度前缀),不能真把它当电话对话。
三次握手 / 四次挥手
握手: 挥手:
Client Server Active Passive
│──SYN────────►│ │───FIN───────►│
│ │ │ │ 处理剩余数据
│◄──SYN+ACK───│ │◄──ACK────────│
│ │ │ │
│──ACK────────►│ │◄──FIN────────│
│ │ │ │
│ ESTAB │ ESTAB │───ACK───────►│
│ │
TIME_WAIT CLOSED
(2 * MSL)
状态机(重点几个)
- SYN_SENT / SYN_RCVD:握手中
- ESTABLISHED:正常传输
- FIN_WAIT_1 / FIN_WAIT_2:主动关闭方
- CLOSE_WAIT:被动方收到 FIN 但还没关——线上出现大量
CLOSE_WAIT通常是 应用忘了close(fd) - TIME_WAIT:主动关闭方最后停留
2 * MSL(Linux 默认 2 分钟)
TCP 11 个状态全景 · 一张图看到底
TCP 一共 11 个状态,两条主线:建连(左)→ 传输 → 关连(右)。哪一端主动发 FIN 就走"主动关"路径(FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT),另一端走"被动关"路径(CLOSE_WAIT → LAST_ACK)——这是 TIME_WAIT 和 CLOSE_WAIT 本质对立的根源。
每个状态一句话(产生条件 + 转出条件):
| 状态 | 谁的 | 产生条件 | 转出条件 |
|---|---|---|---|
CLOSED | 双方 | 初始/终态 | listen() 或 connect() |
LISTEN | Server | listen() 挂 accept 队列 | 收 SYN → SYN_RCVD |
SYN_SENT | Client | connect() 发出 SYN | 收 SYN+ACK → ESTABLISHED |
SYN_RCVD | Server | 收 SYN 回 SYN+ACK | 收 ACK → ESTABLISHED |
ESTABLISHED | 双方 | 三次握手成功 | 主动 close → FIN_WAIT_1;收 FIN → CLOSE_WAIT |
FIN_WAIT_1 | 主动方 | 主动 close() 发 FIN | 收 ACK → FIN_WAIT_2;收 FIN → CLOSING |
FIN_WAIT_2 | 主动方 | 收到对方 ACK(对方仍在发数据) | 收对方 FIN → TIME_WAIT |
CLOSING | 主动方 | 双方同时 close()(罕见) | 收 ACK → TIME_WAIT |
TIME_WAIT | 主动方 | 收到对方 FIN 回 ACK | 等 2 * MSL(Linux 60s)→ CLOSED |
CLOSE_WAIT | 被动方 | 收到 FIN 回 ACK,等应用 close | 应用 close() → LAST_ACK |
LAST_ACK | 被动方 | 应用 close() 发 FIN | 收 ACK → CLOSED |
记忆点:主动关的一方付 TIME_WAIT 2MSL 的代价(怕最后一个 ACK 丢),被动关的一方付 CLOSE_WAIT 的代价(要等应用调 close())——代价对称、位置不同。
TIME_WAIT vs CLOSE_WAIT · 一眼看穿的对照
面试常混淆两者,但只要抓住"谁主动关"这条线就永远不会错:
| 维度 | TIME_WAIT | CLOSE_WAIT |
|---|---|---|
| 谁的状态 | 主动关闭方(先发 FIN 的那个) | 被动关闭方(后发 FIN 的那个) |
| 产生时机 | 四次挥手最后一步——回完最后的 ACK 之后 | 收到对方 FIN、回完 ACK 之后 |
| 停留多久 | 2 * MSL(Linux hardcoded 60s,[include/net/tcp.h] TCP_TIMEWAIT_LEN) | 无上限——直到应用调 close(fd) |
| 为什么存在 | ① 让最后一个 ACK 有机会重传(对方没收到会重发 FIN)② 等旧连接的迷路包在网络中消散,避免污染下一个同五元组连接 | 给应用"我这边还没处理完,让我发完最后的数据再关" 的窗口 |
| 堆积症状 | 端口耗尽(bind: Address already in use)、ss -s 看到 timewait 数万 | fd 泄漏、ss -s 看到 CLOSE_WAIT 数千、最终 Too many open files |
| 根因 | 短连接高并发(HTTP 1.0、老爬虫、压测工具)——每次请求都新建 + 主动关闭 | 应用 bug——recv() == 0 时忘了 close(fd),或 close 前有异常路径遗漏 |
| 责任方 | 通常是客户端(主动断连) | 本进程自己——被动方 bug |
| 解决方案(治本) | 长连接 + 连接池 · HTTP Keep-Alive · gRPC · WebSocket | 检查所有 read 返回 0 / EOF 的分支都要 close · defer/RAII 兜底 |
| 解决方案(临时) | net.ipv4.tcp_tw_reuse=1(仅客户端 outbound 复用)· SO_REUSEADDR(服务重启用) | 找到持有 fd 的进程重启 · 修 close 逻辑重新发版 |
| 千万别做 | 开 tcp_tw_recycle(Linux 4.12 已删;NAT 后翻车、客户端连不上) | 上层强杀进程(fd 是被动方的、杀了下游会收到 RST) |
一句话记忆:
TIME_WAIT 是主动关的必要代价,堆积不是 bug 是设计;CLOSE_WAIT 是被动关的窗口期,堆积一定是 bug。
实现方案
关键 socket 选项
SO_REUSEADDR:允许绑定TIME_WAIT状态的地址(重启服务有用)SO_REUSEPORT(Linux 3.9+):多个 socket 绑同一端口,内核负载均衡分发到不同 worker → 干掉惊群TCP_NODELAY:关闭 Nagle 算法(Nagle 会攒小包 200ms 再发,交互式应用要关)TCP_QUICKACK:关闭延迟 ACKSO_KEEPALIVE:探活;默认 2 小时才发探测——太长,业务通常应用层心跳TCP_CORK:与 Nagle 相反,攒满一个 MSS 才发SO_LINGER:close()时是否等待未发数据;l_onoff=1, l_linger=0会发 RST 而非 FIN
I/O 多路复用:select / poll / epoll
| 特性 | select | poll | epoll (Linux) |
|---|---|---|---|
| fd 上限 | 1024 (FD_SETSIZE) | 无 | 无 |
| 数据结构 | bitmap | 链表 | 红黑树 + 就绪链表 |
| 时间复杂度 | O(n) | O(n) | O(1) 添加,O(k) 就绪 |
| 触发模式 | 水平 | 水平 | 水平 LT + 边沿 ET |
| 内核↔用户拷贝 | 每次全量 | 每次全量 | 一次注册,就绪时零拷贝 |
- LT(水平触发):只要 fd 还有数据就一直通知(默认,容错友好)
- ET(边沿触发):状态变化时才通知一次——必须一次性
read到EAGAIN,配非阻塞 fd - kqueue (BSD/macOS) / IOCP (Windows) / io_uring (Linux 5.1+,异步真正的下一代)
Reactor / Proactor 模式
- Reactor(同步 I/O + 事件通知,主流):epoll_wait 拿到就绪 fd → 分发给 handler → handler 自己 read/write
- 单 Reactor 单线程:Redis 6 之前——简单,CPU 密集会卡死
- 单 Reactor 多线程:主线程 accept + read,worker 线程处理业务
- 主从 Reactor:主 Reactor 只管 accept,子 Reactor 池处理 I/O ← Netty / Nginx / Redis 6 多线程 IO / Envoy 都是这个
- Proactor(异步 I/O,内核完成 read/write 后通知):Windows IOCP、Linux
io_uring;应用注册 buffer,内核填好通知
主从 Reactor 抽象
┌──────────────┐
│ Main Reactor │ 只做 accept()
│ epoll_wait │
└──────┬───────┘
│ 分发新连接
┌────────────────┼────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│Sub Reactor│ │Sub Reactor│ │Sub Reactor│ N = CPU 核数
│epoll_wait │ │epoll_wait │ │epoll_wait │
│ handler │ │ handler │ │ handler │
└───────────┘ └───────────┘ └───────────┘
│ │ │
└────────► Worker Pool ◄──────────┘
(业务线程池,CPU 密集任务)
零拷贝 (Zero-Copy)
传统 read+write:磁盘→内核缓冲→用户缓冲→内核 socket 缓冲→网卡(4 次拷贝,2 次上下文切换)
sendfile():内核内直接 disk→socket,2 次拷贝,1 次系统调用splice():pipe 中转,管道两端都在内核mmap + write:内存映射,避免 read 那次拷贝SO_ZEROCOPY(Linux 4.14+):send()直接引用用户 buffer- DPDK / eBPF XDP:绕过内核协议栈,用户态直接玩网卡包(HFT / CDN 压榨延迟)
为什么这么做
拥塞控制(面试常问但少人答对)
- 慢启动:cwnd 从 1 MSS 起,每 RTT 翻倍
- 拥塞避免:cwnd ≥ ssthresh 后,每 RTT +1 MSS 线性增
- 快速重传:收到 3 个重复 ACK → 立即重传,不等 RTO
- 快速恢复:ssthresh = cwnd/2, cwnd = ssthresh + 3
- 算法演进:Reno → NewReno → CUBIC (Linux 默认) → BBR(Google,基于带宽和 RTT 建模,长肥管道更好)
为什么别的选择不行
TCP 编程五大坑
1. 粘包 / 半包(TCP 是字节流):
- 应用层协议必须自定义分包:定长 / 分隔符 (
\r\n) / 长度字段(最常用:4 字节大端长度 + 包体) - Netty 的
LengthFieldBasedFrameDecoder就是干这个
2. 大量 TIME_WAIT(详情见上文"TIME_WAIT vs CLOSE_WAIT 对照表"):
- 短连接场景高并发时端口耗尽(每条连接主动关一次就要 hold 60s)
- 不要开
tcp_tw_recycle(Linux 4.12 已删,NAT 后翻车);tcp_tw_reuse相对安全(仅客户端 outbound) - 治本:长连接 + 连接池(平台代理模块 12000 上限就是这么设计);辅助
SO_REUSEADDR便于服务重启
3. 大量 CLOSE_WAIT(详情见上文对照表):
- 应用没 close(fd) bug——被动方收到 FIN 后忘关自己的写端
- 排查三板斧:
ss -tan | grep CLOSE_WAIT定位端口 →lsof -i:PORT找持有 fd 的进程 →strace -p PID -e trace=close看有没有漏调 close - 根因往往在异常路径:
try/catch里 return 前忘 close、recv() == 0判断分支没 close、defer 顺序错了导致 close 前 return - 兜底:所有 socket 都用 RAII / defer 关闭;Go 的
defer conn.Close()、C++ 的std::unique_ptr+ 自定义 deleter
4. 惊群 (Thundering Herd):
- 多进程 accept 同一 listen fd,一个连接来全被唤醒
- Linux 4.5+
EPOLLEXCLUSIVE+SO_REUSEPORT组合已解决
5. 半连接队列 SYN Flood:
tcp_max_syn_backlog满 → 新 SYN 被丢- 开
tcp_syncookies=1,用 hash 而非表存半连接
沉淀结论
生产级 TCP 服务的必备清单
SO_REUSEPORT分发到 N 个 worker(N = CPU 核数)SO_KEEPALIVE关掉,用应用层心跳 (Keep-Alive、Ping/Pong) 探活可控TCP_NODELAY打开,交互式协议禁用 Nagle- 分包用长度前缀协议(4 字节 magic + 4 字节 length + payload),一定要校验 length 上限防内存爆炸
- 连接池:最小空闲 + 最大空闲 + 超时回收
- 优雅关闭:
shutdown(WR) → 读残余数据 → close;避免 RST 打断对方 - 指标必埋:连接数、accept 队列长度、read/write 错误率、RTT、复用率
记忆口诀
连接管理:三次握手 / 四次挥手 / TIME_WAIT 2MSL
高并发:epoll 红黑树 / ET 非阻塞 / 主从 Reactor
可靠传输:字节流分包 / 拥塞控制 / 应用层心跳
避坑:粘包长度前缀 / CLOSE_WAIT 忘 close / 惊群 REUSEPORT
可视化对照:把这些概念放进一次真实请求的时间轴里看,参见 200 毫秒 · 一次请求的微观史诗 —— TCP 握手、TLS、内核收包、epoll 唤醒都能就地展开。
自测:合上资料能说清楚吗?
为什么握手要 3 次而不是 2 次,挥手却要 4 次?
参考答案
3 次是确认双方收发能力的最少次数:2 次只能确认一个方向。4 次是因为关闭半双工——被动方收到 FIN 先回 ACK,处理完剩余数据后才发自己的 FIN,两步不能合并。
线上出现大量 CLOSE_WAIT 和大量 TIME_WAIT,分别说明什么、怎么治?
参考答案
CLOSE_WAIT 堆积=应用忘了 close(fd)(被动方 bug),需查持有 fd 的进程。TIME_WAIT 堆积=短连接高并发端口耗尽,治本靠长连接+连接池,客户端可开 tcp_tw_reuse,别开 tcp_tw_recycle。
TIME_WAIT 为什么必须停留 2 * MSL?如果只等 1 MSL 甚至直接跳过会怎样?
参考答案
两个原因:
- 让最后一个 ACK 有机会重传——主动方发完最后 ACK 就直接 CLOSED 的话,如果这个 ACK 在网络里丢了,被动方
LAST_ACK超时会重发 FIN;主动方已经 CLOSED、收到这个 FIN 只能回 RST,被动方看到 RST 会把连接标记为异常关闭(可能污染日志、也可能丢弃未处理的数据)。停留 2 MSL 就是"给最后一个 ACK 一次重传的机会"——1 MSL 是主动方 ACK 到达对方的最大时间,另 1 MSL 是对方重发 FIN 到达主动方的最大时间,加起来就是 2 MSL。 - 等旧连接的迷路包在网络中消散——同一个五元组(源 IP + 源端口 + 目的 IP + 目的端口 + 协议)短时间内被复用时,如果上一个连接的迷路包还在网络里飘(例如某条链路绕远了),新连接可能会收到它并当成合法数据处理。2 MSL 保证网络中所有属于旧连接的报文都已经过期(IP 层的 TTL 也在这个量级)。
直接跳过(比如开 SO_LINGER 强制 RST 或早年的 tcp_tw_recycle)的代价:NAT 后多客户端共用出口 IP,服务端 tcp_tw_recycle 依赖 timestamp 递增判断"是否是同一客户端的新连接"——同一 NAT 后不同客户端的 timestamp 不递增就会被拒,表现为"随机连不上"。Linux 4.12 已经彻底移除这个选项。正确姿势永远是"减少主动关闭次数"(长连接 + 连接池),而不是"让 TIME_WAIT 消失"。
epoll 的 LT 和 ET 有什么区别,ET 使用时要注意什么?
参考答案
LT(水平触发)只要 fd 还有数据就一直通知,容错友好(默认)。ET(边沿触发)只在状态变化时通知一次,必须一次性 read 到 EAGAIN,且配非阻塞 fd,否则会漏数据或阻塞。
TCP 是字节流带来什么问题,应用层如何解决?
参考答案
粘包/半包:TCP 不保留消息边界,一次 read 可能读到半个或多个包。应用层需自定义分包:定长 / 分隔符 / 长度前缀(最常用,4 字节长度+包体),并校验 length 上限防内存爆炸。
Reactor 和 Proactor 模式的核心区别是什么?
参考答案
Reactor 是同步 I/O + 事件通知:epoll 通知 fd 就绪后,由应用自己 read/write(Netty/Nginx)。Proactor 是异步 I/O:应用注册 buffer,内核完成 read/write 后才通知(Windows IOCP、Linux io_uring)。
内容来源
迁移自 guide/theme-tcp-net(综合整理)。原始参考:《TCP/IP 详解 卷一》、Linux Programming Interface、Beej Guide、Netty 源码(2026-07)。
相关专题:本篇聚焦 TCP 协议本身(握手挥手、拥塞/流控、粘包);epoll/IO 多路复用详见 LVS + epoll,零拷贝详见 零拷贝。这些机制在游戏端到端低延迟链路中的整体取舍见 全栈极限低延迟。