笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 互联网 / 智能硬件后台(3 年)

    • 互联网 / 智能硬件后台
    • php-fpm + Nginx 多进程异步 I/O
    • IoT 私有协议设计与 MQTT
    • 前端工程化与 Web 游戏引擎
    • Elixir 与函数式 / BEAM
    • DNS 攻防与隧道
    • DNS 清洗拦截与 CoreDNS
    • LVS 与 epoll
    • TCP/HTTP 滑动窗口
    • TCP 网络编程

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()
LISTENServerlisten() 挂 accept 队列收 SYN → SYN_RCVD
SYN_SENTClientconnect() 发出 SYN收 SYN+ACK → ESTABLISHED
SYN_RCVDServer收 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_WAITCLOSE_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:关闭延迟 ACK
  • SO_KEEPALIVE:探活;默认 2 小时才发探测——太长,业务通常应用层心跳
  • TCP_CORK:与 Nagle 相反,攒满一个 MSS 才发
  • SO_LINGER:close() 时是否等待未发数据;l_onoff=1, l_linger=0 会发 RST 而非 FIN

I/O 多路复用:select / poll / epoll

特性selectpollepoll (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 甚至直接跳过会怎样?

参考答案

两个原因:

  1. 让最后一个 ACK 有机会重传——主动方发完最后 ACK 就直接 CLOSED 的话,如果这个 ACK 在网络里丢了,被动方 LAST_ACK 超时会重发 FIN;主动方已经 CLOSED、收到这个 FIN 只能回 RST,被动方看到 RST 会把连接标记为异常关闭(可能污染日志、也可能丢弃未处理的数据)。停留 2 MSL 就是"给最后一个 ACK 一次重传的机会"——1 MSL 是主动方 ACK 到达对方的最大时间,另 1 MSL 是对方重发 FIN 到达主动方的最大时间,加起来就是 2 MSL。
  2. 等旧连接的迷路包在网络中消散——同一个五元组(源 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,零拷贝详见 零拷贝。这些机制在游戏端到端低延迟链路中的整体取舍见 全栈极限低延迟。

最近更新: 2026/9/10 11:38
Prev
TCP/HTTP 滑动窗口