跳到主要内容Linux TCP 协议基础与连接管理详解:从三次握手到四次挥手 | 极客日志编程语言算法
Linux TCP 协议基础与连接管理详解:从三次握手到四次挥手
TCP 协议通过报文格式、三次握手建立连接、四次挥手断开连接实现可靠传输。文章详解 TCP 首部字段含义,分析三次握手确认双方收发能力的原因及序列号机制,阐述四次挥手因全双工特性需分离关闭请求与确认的过程。重点解析 TIME_WAIT 状态确保 ACK 到达及防止旧数据干扰的作用,以及 CLOSE_WAIT 状态反映应用层未正确关闭 Socket 的问题。结合状态转换图与代码示例,提供排查资源泄露的方法,帮助理解 TCP 连接管理的核心逻辑与常见故障处理。
Linux TCP 协议基础与连接管理详解:从三次握手到四次挥手
一、TCP 报文格式详解
1.1 TCP 报文的整体结构
┌──────────────────────────────────────────────────────────┐
│ TCP 首部(20-60 字节) │
├──────────────────────────────────────────────────────────┤
│ TCP 数据(可变长度) │
└──────────────────────────────────────────────────────────┘
1.2 TCP 首部的各个字段
1. 源端口号和目的端口号(各 16 位)
┌─────────────────────┬─────────────────────┐
│ 源端口号 (16 位) │ 目的端口号 (16 位) │
└─────────────────────┴─────────────────────┘
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 位最大值=15
15 × 4=60 字节(TCP 首部最大长度)
5. 标志位(6 位)
│ URG │ ACK │ PSH │ RST │ SYN │ FIN │
| 标志 | 名称 | 含义 |
|---|
| SYN | 同步 | 请求建立连接 |
| ACK | 确认 | 确认号有效 |
| FIN | 结束 | 请求关闭连接 |
| RST | 重置 | 重新建立连接 |
| PSH | 推送 | 立即发送,不等缓冲区满 |
| URG | 紧急 | 紧急指针有效 |
6. 窗口大小(16 位)
┌──────────────────────┐
│ 窗口大小 (16 位) │
└──────────────────────┘
7. 校验和(16 位)
┌──────────────────────┐
│ 校验和 (16 位) │
└──────────────────────┘
校验和(checksum):用于检测传输过程中是否发生比特错误(覆盖 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
SYN 标志=1
序列号=x(客户端初始序列号,随机)
窗口大小=客户端接收缓冲区大小
第二次握手:服务器回复 SYN+ACK
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
收到序列号为 1000 的报文(包含 100 字节数据)
这个报文的字节序号是 1000-1099
下一个我要接收的字节序号是 1100
所以确认号=1100
收到 SYN 报文(序列号=x)
虽然 SYN 报文没有数据,但 SYN 本身占用一个序列号
所以下一个要接收的序列号是 x+1
确认号=x+1
三、TCP 的连接关闭:四次挥手
3.1 为什么需要四次挥手
客户端 → 服务器: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 状态。
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 状态下,仍然可以接收数据
如果收到重复的 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 (使用了相同的五元组)
旧连接的数据包到达,被新连接误认为是新数据
等待 2MSL,确保所有旧数据包都消失
然后才允许使用相同的五元组建立新连接
5.3 TIME_WAIT 导致的问题
问题:当服务端作为主动关闭方、且快速重启时,可能遇到 Address already in use;
服务器主动关闭连接,进入 TIME_WAIT 状态
TIME_WAIT 期间,端口 8080 仍然被占用
无法 bind 到同一个端口
netstat -an |grep TIME_WAIT
5.4 解决 TIME_WAIT 问题
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sockfd, ...);
效果:允许 bind 到处于 TIME_WAIT 状态的端口。
SO_REUSEADDR 允许在一定条件下重新绑定处于 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 的问题
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. 连接无法完全关闭,资源无法释放
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 问题
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;
}
}
}
class TcpSocket{
public:
~TcpSocket(){ Close();
void Close(){
if(fd_ >= 0){
close(fd_);
fd_ = -1;
}
}
private:
int fd_;
};
{
TcpSocket new_sock;
listen_sock.Accept(&new_sock,...);
}
6.6 排查 CLOSE_WAIT 问题
netstat -an |grep CLOSE_WAIT |wc -l
netstat -anp |grep CLOSE_WAIT
搜索所有 Accept() 的地方
确认每个 Accept 后都有对应的 Close()
lsof -p <PID>|grep CLOSE_WAIT
七、本篇总结
7.1 核心要点
- 首部 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
- 目的:优雅地关闭双向连接
- 主动关闭方进入的状态
- 持续 2MSL(通常 120 秒)
- 目的 1:确保最后的 ACK 能到达
- 目的 2:防止旧连接的数据干扰新连接
- 解决方法:使用 SO_REUSEADDR
- 被动关闭方进入的状态
- 表示"我收到 FIN 了,但还没 close()"
- 问题:应用层忘记调用 close(),导致大量堆积
- 解决方法:确保每个 accept 后都有 close()
7.2 容易混淆的点
- 序列号和确认号:
- 序列号:我发送的数据的第一个字节的序号
- 确认号:下一个我要接收的序列号
- SYN 和 FIN 占用序列号:
- 虽然它们不携带数据,但各占用 1 个序列号
- 所以确认号要 +1
- TIME_WAIT vs CLOSE_WAIT:
- TIME_WAIT:主动关闭方,正常状态
- CLOSE_WAIT:被动关闭方,如果大量堆积说明有 bug
- 三次握手 vs 四次挥手:
- 三次:因为服务器可以把 SYN 和 ACK 合并发送
- 四次:因为服务器可能还有数据要发送,不能合并
- 半关闭:
- 一方关闭了发送方向,但接收方向仍然开放
- 四次挥手的第二次和第三次之间就是半关闭状态
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online