Linux TCP 协议基础与连接管理详解:从三次握手到四次挥手
一、TCP 报文格式详解
1.1 TCP 报文的整体结构
TCP 报文 = TCP 首部 + TCP 数据
┌──────────────────────────────────────────────────────────┐
│ TCP 首部(20-60 字节) │
├──────────────────────────────────────────────────────────┤
│ TCP 数据(可变长度) │
└──────────────────────────────────────────────────────────┘
TCP 首部的最小长度:20 字节(没有选项时)
TCP 首部的最大长度:60 字节(有选项时)
1.2 TCP 首部的各个字段
1. 源端口号和目的端口号(各 16 位)
┌─────────────────────┬─────────────────────┐
│ 源端口号 (16 位) │ 目的端口号 (16 位) │
└─────────────────────┴─────────────────────┘
作用:标识通信的两端应用程序。
范围:0-65535
2. 序列号(32 位)
┌──────────────────────────────────────────┐
│ 序列号 (Sequence Number) │
└──────────────────────────────────────────┘
作用:
- 标识 TCP 报文中数据的第一个字节的序号
- 用于排序和去重
- 初始值是随机的(为了安全)
例子:
第一个报文:序列号=1000,数据 100 字节
第二个报文:序列号=1100,数据 100 字节
第三个报文:序列号=1200,数据 100 字节
3. 确认号(32 位)
┌──────────────────────────────────────────┐
│ 确认号 (Acknowledgment Number) │
└──────────────────────────────────────────┘
作用:
- 告诉对方"我已经收到了你的数据,下一个我要接收的序列号是多少"
- 只有 ACK 标志位为 1 时才有效
例子:
收到对方的报文(序列号 1000-1099)
发送 ACK,确认号=1100(表示"我已收到 1000-1099,下一个要 1100")
4. TCP 首部长度(4 位)
┌────────┐
│ 长度 │
└────────┘
含义:TCP 首部有多少个 32 位字(4 字节)。
计算:
TCP 首部长度 = 字段值 × 4 字节
例如:字段值=5 → TCP 首部长度=20 字节(最小)
例如:字段值=15 → TCP 首部长度=60 字节(最大)
为什么是 4 位:
4 位最大值=15
15 × 4=60 字节(TCP 首部最大长度)
5. 标志位(6 位)
│ URG │ ACK │ PSH │ RST │ SYN │ FIN │
各标志位的含义:
| 标志 | 名称 | 含义 |
|---|
| SYN | 同步 | 请求建立连接 |
| ACK | 确认 | 确认号有效 |
| FIN | 结束 | 请求关闭连接 |
| RST | 重置 | 重新建立连接 |
| PSH | 推送 | 立即发送,不等缓冲区满 |
| URG | 紧急 | 紧急指针有效 |
最常用的三个:SYN、ACK、FIN
6. 窗口大小(16 位)
┌──────────────────────┐
│ 窗口大小 (16 位) │
└──────────────────────┘
作用:告诉对方"我的接收缓冲区还有多少空间"。
用途:流量控制(后面详细讲)。
范围:0-65535 字节
7. 校验和(16 位)
┌──────────────────────┐
│ 校验和 (16 位) │
└──────────────────────┘
校验和(checksum):用于检测传输过程中是否发生比特错误(覆盖 TCP 首部、数据和伪首部)。
如果校验和出错:
TCP 层丢弃该报文,不通知应用层。
8. 紧急指针(16 位)
┌──────────────────────┐
│ 紧急指针 (16 位) │
└──────────────────────┘
作用:当 URG 标志为 1 时,指示哪部分数据是紧急数据。
使用场景:很少使用。
9. 选项(可变长度)
┌──────────────────────────────────────┐
│ 选项 (0-40 字节) │
└──────────────────────────────────────┘
常见选项:
- MSS(Maximum Segment Size):最大报文段长度
- 窗口扩大因子:扩大窗口大小
- 时间戳:用于 RTT 计算和防止序列号绕回
二、TCP 的连接建立:三次握手
2.1 为什么需要三次握手
问题:为什么不是一次握手或两次握手?
答案:需要确认双方都能收发数据。
分析:
一次握手:
客户端 → 服务器:SYN
问题:服务器不知道客户端能否接收数据
两次握手:
客户端 → 服务器:SYN
服务器 → 客户端:SYN+ACK
问题:客户端知道服务器能收发,但服务器不知道客户端能接收
三次握手:
客户端 → 服务器:SYN
服务器 → 客户端:SYN+ACK
客户端 → 服务器:ACK
结果:双方都确认对方能收发
2.2 三次握手的详细过程
第一次握手:客户端发送 SYN
客户端状态:CLOSED → SYN_SENT
发送的报文:
SYN 标志=1
序列号=x(客户端初始序列号,随机)
窗口大小=客户端接收缓冲区大小
含义:
"嘿,我想和你建立连接。我的初始序列号是 x。"
第二次握手:服务器回复 SYN+ACK
服务器状态:LISTEN → SYN_RCVD
发送的报文:
SYN 标志=1
ACK 标志=1
序列号=y(服务器初始序列号,随机)
确认号=x+1(确认收到了客户端的序列号 x)
窗口大小=服务器接收缓冲区大小
含义:
"好的,我收到你的连接请求了。我的初始序列号是 y。
下一个我要接收的是你的序列号 x+1。"
第三次握手:客户端发送 ACK
客户端状态:SYN_SENT → ESTABLISHED
发送的报文:
ACK 标志=1
序列号=x+1(继续使用自己的序列号)
确认号=y+1(确认收到了服务器的序列号 y)
含义:
"好的,我收到你的回复了。下一个我要接收的是你的序列号 y+1。"
服务器状态:SYN_RCVD → ESTABLISHED
2.3 三次握手的图示
[图片:三次握手示意图]
客户端 服务器
|||
第一次握手:SYN(seq=x)
----------------------------->
|||
状态:LISTEN → SYN_RCVD
|||
第二次握手:SYN+ACK(seq=y, ack=x+1)
<-----------------------------
||
状态:SYN_SENT → ESTABLISHED
||||
第三次握手:ACK(seq=x+1, ack=y+1)
----------------------------->
|||
状态:SYN_RCVD → ESTABLISHED
|||
连接建立,可以传输数据
<---------------------------->
2.4 为什么序列号要 +1
问题:为什么确认号是 x+1 而不是 x?
答案:确认号表示"下一个我要接收的序列号"。
例子:
收到序列号为 1000 的报文(包含 100 字节数据)
这个报文的字节序号是 1000-1099
下一个我要接收的字节序号是 1100
所以确认号=1100
在握手中:
收到 SYN 报文(序列号=x)
虽然 SYN 报文没有数据,但 SYN 本身占用一个序列号
所以下一个要接收的序列号是 x+1
确认号=x+1
三、TCP 的连接关闭:四次挥手
3.1 为什么需要四次挥手
问题:为什么不是三次或两次?
答案:TCP 是全双工的,双方都可以发送数据。
分析:
两次挥手:
客户端 → 服务器:FIN(我要关闭了)
服务器 → 客户端:FIN(我也要关闭了)
问题:服务器可能还有数据要发送给客户端
四次挥手:
客户端 → 服务器:FIN(我要关闭了)
服务器 → 客户端:ACK(我收到了)
服务器 → 客户端:FIN(我也要关闭了)
客户端 → 服务器:ACK(我收到了)
结果:双方都确认对方已关闭
3.2 四次挥手的详细过程
第一次挥手:客户端发送 FIN
客户端状态:ESTABLISHED → FIN_WAIT_1
发送的报文:
FIN 标志=1
序列号=x(继续使用自己的序列号)
含义:
"我已经发送完所有数据了,现在要关闭连接。"
重要:客户端发送 FIN 后,不再发送数据,但仍然可以接收数据。
第二次挥手:服务器回复 ACK
服务器状态:ESTABLISHED → CLOSE_WAIT
发送的报文:
ACK 标志=1
确认号=x+1(确认收到了客户端的 FIN)
含义:
"好的,我收到你的关闭请求了。"
重要:服务器进入 CLOSE_WAIT 状态,表示"我收到了关闭请求,但我还有数据要发送"。
第三次挥手:服务器发送 FIN
服务器状态:CLOSE_WAIT → LAST_ACK
发送的报文:
FIN 标志=1
序列号=y(继续使用自己的序列号)
含义:
"我已经发送完所有数据了,现在也要关闭连接。"
时机:服务器在 CLOSE_WAIT 状态下,处理完所有待发送的数据后,才发送 FIN。
第四次挥手:客户端回复 ACK
客户端状态:FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT
发送的报文:
ACK 标志=1
确认号=y+1(确认收到了服务器的 FIN)
含义:
"好的,我收到你的关闭请求了。"
重要:客户端进入 TIME_WAIT 状态,等待 2MSL 时间后才进入 CLOSED 状态。
服务器状态:LAST_ACK → CLOSED
3.3 四次挥手的图示
[图片:四次挥手示意图]
客户端 服务器
|||
第一次挥手:FIN(seq=x)
----------------------------->
|||
状态:ESTABLISHED → FIN_WAIT_1
|||
状态:ESTABLISHED → CLOSE_WAIT
|||
第二次挥手:ACK(ack=x+1)
<-----------------------------
|||
状态:FIN_WAIT_1 → FIN_WAIT_2
|||
处理剩余数据...
|||
第三次挥手:FIN(seq=y)
<-----------------------------
|||
状态:FIN_WAIT_2 → TIME_WAIT
| 状态:CLOSE_WAIT → LAST_ACK
|||
第四次挥手:ACK(ack=y+1)
----------------------------->
|||
等待 2MSL
| 状态:LAST_ACK → CLOSED
|||
状态:TIME_WAIT → CLOSED
四、TCP 状态转换详解
4.1 TCP 的 11 种状态
| 状态 | 含义 |
|---|
| CLOSED | 连接已关闭 |
| LISTEN | 监听状态,等待连接 |
| SYN_SENT | 已发送 SYN,等待响应 |
| SYN_RCVD | 已收到 SYN,已发送 SYN+ACK |
| ESTABLISHED | 连接已建立,可以传输数据 |
| FIN_WAIT_1 | 已发送 FIN,等待 ACK |
| FIN_WAIT_2 | 已收到 ACK,等待对方的 FIN |
| CLOSE_WAIT | 已收到 FIN,等待应用层关闭 |
| LAST_ACK | 已发送 FIN,等待最后的 ACK |
| TIME_WAIT | 已发送最后的 ACK,等待 2MSL |
| CLOSING | 同时收到 FIN(罕见) |
4.2 服务器端的状态转换
CLOSED
↓ (调用 listen)
LISTEN
↓ (收到 SYN)
SYN_RCVD
↓ (收到 ACK)
ESTABLISHED
↓ (收到 FIN)
CLOSE_WAIT
↓ (调用 close)
LAST_ACK
↓ (收到 ACK)
CLOSED
4.3 客户端的状态转换
CLOSED
↓ (调用 connect)
SYN_SENT
↓ (收到 SYN+ACK)
ESTABLISHED
↓ (调用 close)
FIN_WAIT_1
↓ (收到 ACK)
FIN_WAIT_2
↓ (收到 FIN)
TIME_WAIT
↓ (等待 2MSL)
CLOSED
五、TIME_WAIT 状态深度理解
5.1 TIME_WAIT 是什么
TIME_WAIT:主动关闭连接的一方进入的状态。
持续时间:2MSL(Maximum Segment Lifetime)
TIME_WAIT 持续 2MSL。RFC 1122 建议 MSL 取 2 分钟(因此 2MSL=4 分钟),不同实现可能更短。Linux 的 tcp_fin_timeout 影响 FIN_WAIT_2,不同于 TIME_WAIT。
5.2 为什么需要 TIME_WAIT
原因 1:确保最后的 ACK 能到达
场景:
客户端发送最后的 ACK
这个 ACK 丢失了
服务器没收到,会重新发送 FIN
TIME_WAIT 的作用:
客户端在 TIME_WAIT 状态下,仍然可以接收数据
如果收到重复的 FIN,会重新发送 ACK
图示:
客户端 服务器
|||
第四次挥手:ACK(ack=y+1)
----------------------------->
|||
进入 TIME_WAIT
| 等待 ACK...
|||
ACK 丢失了!
|||||
超时,重新发送 FIN
| 第三次挥手(重复):FIN(seq=y)
<-----------------------------
||||
收到重复的 FIN,重新发送 ACK
----------------------------->
||||
继续等待 2MSL
| 收到 ACK,关闭
|||
2MSL 后关闭
原因 2:防止旧连接的数据干扰新连接
场景:
连接 1:客户端 192.168.1.100:54321 ↔ 服务器 192.168.1.1:8080
连接 1 关闭,但有些数据包还在网络中漂浮
连接 2:客户端 192.168.1.100:54321 ↔ 服务器 192.168.1.1:8080 (使用了相同的五元组)
旧连接的数据包到达,被新连接误认为是新数据
TIME_WAIT 的作用:
等待 2MSL,确保所有旧数据包都消失
然后才允许使用相同的五元组建立新连接
5.3 TIME_WAIT 导致的问题
问题:当服务端作为主动关闭方、且快速重启时,可能遇到 Address already in use;
场景:
./server 8080
原因:
服务器主动关闭连接,进入 TIME_WAIT 状态
TIME_WAIT 期间,端口 8080 仍然被占用
无法 bind 到同一个端口
查看 TIME_WAIT 连接:
netstat -an |grep TIME_WAIT
5.4 解决 TIME_WAIT 问题
方法 1:使用 SO_REUSEADDR 选项
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sockfd, ...);
效果:允许 bind 到处于 TIME_WAIT 状态的端口。
原理:
SO_REUSEADDR 允许在一定条件下重新绑定处于 TIME_WAIT 相关状态的本地地址/端口(常用于服务快速重启)。
不同系统/场景表现有差异,生产上以实际测试为准。
方法 2:等待 TIME_WAIT 过期
方法 3:修改 TIME_WAIT 的值
不建议试图通过 sysctl 直接修改 TIME_WAIT 时长:另外 tcp_fin_timeout 影响的是 FIN_WAIT_2,不等同 TIME_WAIT。真正想改 TIME_WAIT 时长通常涉及内核实现,不适合生产环境。
六、CLOSE_WAIT 状态深度理解
6.1 CLOSE_WAIT 是什么
CLOSE_WAIT:被动关闭连接的一方进入的状态。
含义:
"我收到了对方的关闭请求,但我还有数据要发送。"
6.2 CLOSE_WAIT 的正常流程
1. 收到对方的 FIN
2. 进入 CLOSE_WAIT 状态
3. 处理剩余的数据
4. 调用 close() 关闭连接
5. 发送 FIN
6. 进入 LAST_ACK 状态
7. 收到对方的 ACK
8. 进入 CLOSED 状态
6.3 CLOSE_WAIT 的问题
问题:大量 CLOSE_WAIT 连接堆积。
原因:应用程序没有正确关闭 socket。
例子(错误的代码):
for(;;){
TcpSocket new_sock;
listen_sock.Accept(&new_sock,...);
for(;;){
std::string req;
if(!new_sock.Recv(&req)){
break;
}
}
}
后果:
1. 客户端调用 close() → 发送 FIN
2. 服务器收到 FIN → 自动回复 ACK → 进入 CLOSE_WAIT
3. 服务器的应用层 break,但没有调用 close()
4. 服务器一直停留在 CLOSE_WAIT 状态
5. 连接无法完全关闭,资源无法释放
查看 CLOSE_WAIT 连接:
netstat -an |grep CLOSE_WAIT
输出示例(大量堆积):
tcp 10127.0.0.1:8080 127.0.0.1:54321 CLOSE_WAIT
tcp 10127.0.0.1:8080 127.0.0.1:54322 CLOSE_WAIT
tcp 10127.0.0.1:8080 127.0.0.1:54323 CLOSE_WAIT
tcp 10127.0.0.1:8080 127.0.0.1:54324 CLOSE_WAIT
... (几百个甚至几千个)
6.4 CLOSE_WAIT 问题的影响
资源泄露:
每个 CLOSE_WAIT 连接都占用:
- 一个文件描述符
- 内核中的 TCP 连接结构
- 接收和发送缓冲区
文件描述符耗尽:
Linux 默认的文件描述符限制:1024(ulimit -n 查看)
如果有 1000 个 CLOSE_WAIT 连接,就无法创建新连接了
性能下降:
大量无用的连接占用系统资源
影响正常的连接处理
6.5 解决 CLOSE_WAIT 问题
唯一的方法:正确关闭 socket
正确的代码:
for(;;){
TcpSocket new_sock;
listen_sock.Accept(&new_sock,...);
for(;;){
std::string req;
if(!new_sock.Recv(&req)){
printf("Client disconnected\n");
new_sock.Close();
break;
}
}
}
RAII 封装(推荐):
class TcpSocket{
public:
~TcpSocket(){ Close();
void Close(){
if(fd_ >= 0){
close(fd_);
fd_ = -1;
}
}
private:
int fd_;
};
使用 RAII 后:
{
TcpSocket new_sock;
listen_sock.Accept(&new_sock,...);
}
6.6 排查 CLOSE_WAIT 问题
步骤 1:确认是否有大量 CLOSE_WAIT
netstat -an |grep CLOSE_WAIT |wc -l
步骤 2:找到问题进程
netstat -anp |grep CLOSE_WAIT
步骤 3:检查代码
搜索所有 Accept() 的地方
确认每个 Accept 后都有对应的 Close()
步骤 4:使用工具检测
lsof -p <PID>|grep CLOSE_WAIT
七、本篇总结
7.1 核心要点
TCP 报文格式:
- 首部 20-60 字节,包含源端口、目的端口、序列号、确认号等
- 六个标志位:SYN、ACK、FIN、RST、PSH、URG
- 序列号用于排序和去重
- 确认号表示"下一个要接收的序列号"
三次握手:
- 第一次:客户端发送 SYN,进入 SYN_SENT
- 第二次:服务器回复 SYN+ACK,进入 SYN_RCVD
- 第三次:客户端发送 ACK,双方进入 ESTABLISHED
- 目的:确认双方都能收发数据
四次挥手:
- 第一次:客户端发送 FIN,进入 FIN_WAIT_1
- 第二次:服务器回复 ACK,进入 CLOSE_WAIT
- 第三次:服务器发送 FIN,进入 LAST_ACK
- 第四次:客户端回复 ACK,进入 TIME_WAIT
- 目的:优雅地关闭双向连接
TIME_WAIT:
- 主动关闭方进入的状态
- 持续 2MSL(通常 120 秒)
- 目的 1:确保最后的 ACK 能到达
- 目的 2:防止旧连接的数据干扰新连接
- 解决方法:使用 SO_REUSEADDR
CLOSE_WAIT:
- 被动关闭方进入的状态
- 表示"我收到 FIN 了,但还没 close()"
- 问题:应用层忘记调用 close(),导致大量堆积
- 解决方法:确保每个 accept 后都有 close()
7.2 容易混淆的点
- 序列号和确认号:
- 序列号:我发送的数据的第一个字节的序号
- 确认号:下一个我要接收的序列号
- SYN 和 FIN 占用序列号:
- 虽然它们不携带数据,但各占用 1 个序列号
- 所以确认号要 +1
- TIME_WAIT vs CLOSE_WAIT:
- TIME_WAIT:主动关闭方,正常状态
- CLOSE_WAIT:被动关闭方,如果大量堆积说明有 bug
- 三次握手 vs 四次挥手:
- 三次:因为服务器可以把 SYN 和 ACK 合并发送
- 四次:因为服务器可能还有数据要发送,不能合并
- 半关闭:
- 一方关闭了发送方向,但接收方向仍然开放
- 四次挥手的第二次和第三次之间就是半关闭状态