TCP/IP 协议栈深度
用生活化的比喻,让你深入理解 TCP 的连接状态机、滑动窗口、拥塞控制、队头阻塞——这些是排查网络问题的底层知识
前置知识:第05章 I/O与文件系统(理解 I/O 多路复用和 Socket)
阅读指南(初学者必看)
为什么你需要深入学习 TCP/IP?
你每天都在用 TCP——HTTP 请求、WebSocket 连接、数据库连接都是 TCP。但你可能不理解底层机制:
- TCP 的三次握手、四次挥手到底在做什么?TIME_WAIT 为什么等 2MSL?
- 为什么 TCP 丢包后会"降速"?这对游戏有什么影响?
- 什么是队头阻塞?为什么 WebSocket 也有这个问题?
学完本章,你能回答:
- TCP 连接的状态机是怎样的?为什么需要这些状态?
- 滑动窗口和流量控制是怎么工作的?
- 拥塞控制的四个阶段是什么?为什么对游戏很重要?
- 队头阻塞是什么?怎么解决?
本文结构
第一部分:TCP/IP 概述
第二部分:TCP 连接状态机(理解连接的生命周期)
第三部分:滑动窗口与流量控制(理解 TCP 的收发机制)
第四部分:拥塞控制(理解 TCP 的"保守"策略)
第五部分:TCP 队头阻塞(理解 TCP 的致命缺陷)
6.1 TCP/IP 概述
生活比喻:TCP/IP 就像邮政系统。
应用层:写信的人(游戏程序)
传输层:邮局分拣(TCP/UDP)
网络层:运输路线规划(IP)
链路层:卡车运输(以太网/WiFi)
写信 → 装信封 → 分拣 → 运输 → 分拣 → 投递 → 收信
协议栈结构
┌─────────────────────────────────────┐
│ 应用层 (Application) │ HTTP, WebSocket, 自定义协议
├─────────────────────────────────────┤
│ 传输层 (Transport) │ TCP, UDP
├─────────────────────────────────────┤
│ 网络层 (Network) │ IP, ICMP, ARP
├─────────────────────────────────────┤
│ 数据链路层 (Data Link) │ Ethernet, WiFi
├─────────────────────────────────────┤
│ 物理层 (Physical) │ 网线, 光纤, 无线电
└─────────────────────────────────────┘
6.2 TCP 连接状态机
TCP 状态转换图(完整版):
┌─────────┐
被动打开 │ CLOSED │ 主动打开
┌──────────│ │──────────┐
│ └─────────┘ │
▼ ▼
┌──────────┐ SYN ┌───────────┐
│ LISTEN │◀──────────│ SYN_SENT │
└────┬─────┘ └─────┬─────┘
│ SYN/ACK │ SYN/ACK
▼ ▼
┌─────────────┐ ACK ┌───────────┐
│ SYN_RCVD │──────────▶│ESTABLISHED│◀── 连接建立!
└─────────────┘ └─────┬─────┘
│ FIN
▼
┌───────────┐
│ FIN_WAIT_1│
└─────┬─────┘
│ ACK
▼
┌───────────┐
│ FIN_WAIT_2│
└─────┬─────┘
│ FIN
▼
┌───────────┐
│ TIME_WAIT │ ← 等 2MSL 后关闭
└───────────┘
三次握手
class ThreeWayHandshake {
constructor() {
this.client = new TCPConnection();
this.server = new TCPConnection();
}
simulate() {
console.log('=== TCP三次握手模拟 ===\n');
this.server.passiveOpen(8080);
console.log('1. 服务器进入LISTEN状态');
let syn = this.client.activeOpen(12345, 8080);
console.log('2. 客户端发送SYN');
let synAck = this.server.processSegment(syn);
console.log('3. 服务器收到SYN,发送SYN+ACK');
let ack = this.client.processSegment(synAck);
console.log('4. 客户端收到SYN+ACK,发送ACK');
this.server.processSegment(ack);
console.log('5. 服务器收到ACK,连接建立');
console.log('\n客户端状态:', this.client.state);
console.log('服务器状态:', this.server.state);
}
}
TIME_WAIT 为什么要等 2MSL?
- MSL = Maximum Segment Lifetime(报文最大生存时间,通常 60 秒)
- 等待 2MSL 确保最后一个 ACK 能到达对方
- 如果对方没收到 ACK,会重发 FIN,这边还能响应
- 游戏服务器大量短连接时,TIME_WAIT 堆积是常见问题
游戏中的 TCP 状态问题:
# 查看服务器上的 TCP 状态分布
netstat -ant | awk '{print $6}' | sort | uniq -c | sort -rn
# 常见问题:
# TIME_WAIT 过多 → 短连接太多,改用长连接或设置 SO_REUSEADDR
# CLOSE_WAIT 过多 → 应用没调 close(),代码 bug
# SYN_RECV 过多 → SYN Flood 攻击
6.3 滑动窗口与流量控制
生活类比:滑动窗口就像"流水线上的产品"。发送方和接收方各有一个窗口,窗口大小决定可以连续发送多少数据而不需要等待确认。
发送窗口:
┌──────────────────────────────────────────────┐
│ 已确认 │ 已发送未确认 │ 可发送 │ 不可发送 │
│ │ (在窗口内) │(窗口内)│ │
└──────────────────────────────────────────────┘
◀──── 发送窗口大小 ────▶
接收窗口:
- 接收方通过 ACK 告诉发送方自己的窗口大小
- 窗口为 0 → 发送方暂停(零窗口)
- 窗口打开 → 发送方继续
流量控制:接收方通过调整窗口大小控制发送速度
- 接收方处理慢 → 缩小窗口 → 发送方减速
- 接收方处理快 → 扩大窗口 → 发送方加速
→ 这和 Node.js Stream 的背压机制是同一个思想!
6.4 拥塞控制
生活类比:拥塞控制就像"城市交通管制"。路上车太多就限流,路上空就放行。
TCP 拥塞控制四个阶段:
1. 慢启动(Slow Start)
初始窗口 = 1 MSS(约 1460 字节)
每收到一个 ACK,窗口翻倍
1 → 2 → 4 → 8 → 16 → ... 指数增长
2. 拥塞避免(Congestion Avoidance)
窗口达到慢启动阈值(ssthresh)后
每个RTT窗口+1,线性增长
3. 快速重传(Fast Retransmit)
收到 3 个重复 ACK → 立即重传丢失的包
不等超时,减少延迟
4. 快速恢复(Fast Recovery)
不回到慢启动,而是减半窗口后继续拥塞避免
class CongestionControl {
constructor() {
this.cwnd = 1;
this.ssthresh = 64;
this.rtt = 100;
this.rttVar = 50;
this.rto = 200;
this.state = 'slow_start';
}
onAck(acked) {
switch (this.state) {
case 'slow_start':
this.cwnd += acked;
if (this.cwnd >= this.ssthresh) {
this.state = 'congestion_avoidance';
}
break;
case 'congestion_avoidance':
this.cwnd += acked / this.cwnd;
break;
}
}
onLoss() {
this.ssthresh = Math.max(this.cwnd / 2, 1);
this.cwnd = 1;
this.state = 'slow_start';
}
}
为什么这对游戏很重要?
- TCP 拥塞控制是"保守的"——丢包就降速
- 游戏网络经常丢包(WiFi、移动网络)
- TCP 降速后恢复很慢 → 游戏卡顿
- 这是游戏用 UDP 的核心原因之一
6.5 TCP 队头阻塞(Head-of-Line Blocking)
问题:TCP 保证有序交付
包1 ✓ → 包2 ✓ → 包3 ✗ 丢失 → 包4 ✓ → 包5 ✓
↑
包4和5已经到了,但要等包3重传
整个 TCP 流被阻塞!
游戏中的影响:
- 多个游戏消息通过同一个 TCP 连接发送
- 一个消息的包丢了,后面所有消息都要等
- 即便后面的消息彼此独立,也得等
WebSocket 也存在这个问题!
- WebSocket 基于 TCP
- 游戏消息通过 WebSocket 发送
- 一个消息丢包 → 所有消息延迟
解决方案:
1. 多个 TCP 连接(不推荐,连接开销大)
2. 使用 UDP + 自定义可靠传输(QUIC 方案)
3. HTTP/3 基于 QUIC,解决了 TCP 队头阻塞
自问自答
Q:为什么 TCP 需要三次握手而不是两次? A:三次握手的核心目的是"双方都确认对方的发送和接收能力正常"。两次握手只能确认一方的收发能力。如果只有两次握手,一个延迟的 SYN 可能导致服务器建立无效连接(历史连接问题)。第三次 ACK 让服务器确认客户端确实在响应当前连接请求。
Q:TIME_WAIT 过多怎么办?
A:1)使用长连接代替短连接,减少连接创建/关闭次数;2)设置 SO_REUSEADDR,允许重用 TIME_WAIT 状态的端口;3)调整 tcp_tw_reuse(Linux)允许快速回收 TIME_WAIT 连接;4)使用 tcp_max_tw_buckets 限制 TIME_WAIT 数量。游戏服务器应优先使用长连接。
Q:为什么 TCP 拥塞控制对游戏不利? A:TCP 拥塞控制的策略是"丢包就降速"——这在传统网络(拥塞导致丢包)是合理的,但在游戏网络(无线信号导致丢包)就出问题了。WiFi/4G 的丢包不是因为拥塞,而是信号波动。TCP 误判为拥塞→降速→恢复慢→游戏卡顿。这就是游戏用 UDP 的核心原因。
Q:滑动窗口和拥塞窗口有什么区别? A:滑动窗口是接收方的限制——"我能处理多少数据"(流量控制)。拥塞窗口是发送方的限制——"网络能承受多少数据"(拥塞控制)。实际发送窗口 = min(滑动窗口, 拥塞窗口)。两者是独立的控制机制,共同决定发送速度。
实践任务
- 任务1:用 Wireshark 抓包分析 TCP 三次握手和四次挥手的全过程,标注每个状态转换
- 任务2:用
ss -i查看 TCP 连接的详细参数(窗口大小、RTT、重传次数) - 任务3:用
tc netem模拟网络丢包,观察 TCP 拥塞窗口的降速和恢复过程 - 任务4:在游戏服务器上执行
netstat -ant | awk '{print $6}' | sort | uniq -c,分析 TCP 状态分布 - 任务5:用 Node.js 实现一个简单的 TCP 服务器和客户端,观察连接状态变化
与其他章节的关联
| 本章内容 | 关联章节 | 关联点 |
|---|---|---|
| TCP 状态机 | 第07章 UDP与可靠传输 | QUIC 基于 UDP,不需要 TCP 的状态机 |
| 队头阻塞 | 第08章 HTTP/2与HTTP/3 | HTTP/3 基于 QUIC 解决了 TCP 队头阻塞 |
| 滑动窗口 | 第09章 网络安全 | TLS 加密不影响 TCP 的流量控制 |
| 拥塞控制 | 第10章 实战篇 | 网络延迟排查需要理解拥塞控制 |
上一章:05-操作系统IO与文件系统 | 下一章:07-UDP与可靠传输