💖 点赞 + 收藏 + 关注,Socket 工业级网络编程全套实战持续更新!
✨ 本专栏 全程手写源码、无阉割、纯工程
0. 前言
上一章我们实现了阻塞 TCP 客户端与服务端 Demo,能够正常收发数据。但绝大多数开发者写完 Demo 之后,完全不理解连接建立和断开在内核中发生了什么。
实际项目中经常遇到这些棘手问题:
- 服务端重启后,提示
Address already in use端口占用,需要等待几十秒才能重新启动; - 客户端异常断电拔网线,服务端长时间无法感知连接断开,形成半连接;
- 抓包看到大量 TIME_WAIT、CLOSE_WAIT 状态连接,连接数不断堆积,程序最终无法新建连接;
- 程序调用 close 关闭连接后,偶尔收到 RST 复位报文,数据丢失;
所有问题根源:不懂 TCP 连接状态流转、三次握手、四次挥手内核行为。 本章不堆砌晦涩课本理论,结合抓包、状态流转、工程解决方案讲解,兼顾面试考点和量产调试。
工具推荐:wireshark 抓包工具,可以直观观察握手、挥手报文。
1. TCP 连接基础:五元组与 TCP 头部核心字段
一条 TCP 连接由五元组唯一标识:源IP、源端口、目的IP、目的端口、协议(TCP) TCP 报文头部关键字段:
- SEQ(序列号):本次发送数据的起始编号,保证数据流有序;
- ACK(确认号):期望下一次收到的序列号 = 对方 SEQ + 已接收数据长度;
- 标志位:SYN(同步建立连接)、FIN(结束断开连接)、ACK(确认)、RST(强制复位)、PSH(推送数据);
- Window 滑动窗口:流量控制;
- Checksum:校验和。
SYN、FIN 报文本身会占用一个序列号,即使没有数据负载。
2. TCP 三次握手(连接建立)完整流程
通信双方:客户端(主动发起连接)、服务端(监听等待连接) 整体目标:协商双方初始序列号 ISN,确认双方收发能力正常
第一次握手:客户端 → 服务端,SYN
客户端调用 connect(),内核封装 SYN 报文,携带客户端初始序列号 ISN (c) 客户端状态:CLOSED → SYN_SENT
第二次握手:服务端 → 客户端,SYN + ACK
服务端监听套接字收到 SYN 报文,内核回复报文: SYN(服务端自己初始序列号 ISN (s)) + ACK(确认号 = ISN (c)+1) 服务端状态:LISTEN → SYN_RCVD
第三次握手:客户端 → 服务端,ACK
客户端收到 SYN+ACK,校验通过后回复纯 ACK 报文,确认号 = ISN (s)+1 客户端状态:SYN_SENT → ESTABLISHED 服务端收到这个 ACK,状态:SYN_RCVD → ESTABLISHED
✅ 三次握手完成,连接正式建立,用户态 connect() 返回 0,accept() 返回新 connfd,程序可以收发业务数据。
工程问答:为什么不是两次握手? 两次握手只能确认客户端发、服务端收正常,无法确认服务端发、客户端收链路正常,会造成半开连接资源浪费。
3. TCP 四次挥手(连接断开)完整流程
TCP 是全双工通信,两个方向数据流可以独立关闭,因此断开需要四次报文交互。 主动关闭方(调用 close)、被动关闭方。
第一次挥手:主动方 → 被动方,FIN
主动调用 close (fd),内核发送 FIN 报文,表示:我不再发送新数据,但我还可以接收你剩下的数据 主动方状态:ESTABLISHED → FIN_WAIT_1
第二次挥手:被动方 → 主动方,ACK
被动方收到 FIN,内核立即回复 ACK 确认。 被动方状态:ESTABLISHED → CLOSE_WAIT
⚠️ 重点:此时被动方应用层还没调用 close! 内核只是确认收到断开请求,业务代码依旧可以继续发送剩余数据。 主动方收到 ACK:状态切换
FIN_WAIT_1→FIN_WAIT_2
第三次挥手:被动方业务处理完毕,调用 close → 发送 FIN
被动方业务数据发送完成,调用 close,内核发出 FIN 报文 被动方状态:CLOSE_WAIT → LAST_ACK
第四次挥手:主动方收到 FIN,回复 ACK
主动方回复 ACK 确认,状态:FIN_WAIT_2 → TIME_WAIT 被动方收到 ACK:LAST_ACK → CLOSED,连接彻底释放。 主动方 等待 2MSL 时长,才最终切换为 CLOSED,释放五元组资源。
MSL:报文最大生存时间,Linux 默认 30s,2MSL 就是 60s
4. 关键连接状态详解(工程高频)
4.1 TIME_WAIT(主动关闭方产生)
产生场景:主动 close,完成四次挥手最后一步 ACK 之后,停留 2MSL 作用:
- 防止第四次挥手 ACK 报文丢失,被动方超时重发 FIN,主动方保留状态可以重传 ACK;
- 保证这条连接上所有残留报文在网络中全部消失,避免新复用的五元组收到旧滞留数据包。
工程痛点:大量 TIME_WAIT 占用端口,服务无法快速重启 解决方案:端口复用 setsockopt(),代码示例本章末尾提供。
4.2 CLOSE_WAIT(被动关闭方产生)
产生根源:内核收到 FIN 报文,进入 CLOSE_WAIT,但是业务代码一直没有调用 close (connfd) 后果:文件描述符泄漏,连接永久挂着,fd 耗尽后无法新建连接! 排查思路:代码分支检查,所有 read 返回 0、读写异常场景,必须 close 关闭 fd。
4.3 LISTEN:服务端监听状态,等待客户端 SYN 连接
4.4 ESTABLISHED:正常双向数据通信状态
4.5 SYN_SENT:客户端已发送 SYN,等待服务端 SYN+ACK
4.6 SYN_RCVD:服务端收到 SYN,等待客户端第三次握手 ACK
5. RST 复位报文(强制断开,不走四次挥手)
当出现异常场景,TCP 会发送 RST 报文,直接粗暴释放连接,不做优雅关闭:
- 向一个不存在监听端口发送数据;
- 连接已经关闭,依旧往 fd 写入数据(SIGPIPE 信号);
- 半开连接长时间异常;
- 报文校验失败、非法序列号。
工程避坑:程序默认收到 SIGPIPE 信号会直接崩溃!量产程序必须捕获或者忽略 SIGPIPE。
// 忽略SIGPIPE,防止write断开的socket导致程序崩溃
signal(SIGPIPE, SIG_IGN);
6. 端口复用 setsockopt 解决 TIME_WAIT 端口占用
默认情况下,服务端主动 close 后,端口处于 TIME_WAIT,2MSL 内无法重新 bind。 添加端口复用选项,允许快速重启服务,工业项目必加!
int opt = 1;
// 设置端口复用,放在socket创建之后,bind之前调用
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0)
{
perror("setsockopt fail");
}
注意:SO_REUSEADDR 主要解决 TIME_WAIT 占用;还有 SO_REUSEPORT 支持多进程绑定同一个端口(Linux3.9+)
7. 查看本机 TCP 连接状态调试命令(必备调试工具)
# 查看所有tcp连接状态
netstat -antp
# 或者ss命令(性能更好,推荐)
ss -antp
# 过滤指定端口8888
ss -antp | grep 8888
可以直接观察 TIME_WAIT / CLOSE_WAIT / ESTABLISHED 连接数量,定位连接泄漏问题。
8. 本章工程踩坑总结
- close 是单向关闭读写,内核完成四次挥手;shutdown 可以精细控制只关闭读 / 写方向,适合优雅断连;
- CLOSE_WAIT 99% 都是业务代码忘记 close 套接字,造成句柄泄漏;
- TIME_WAIT 是保护机制,不是 bug,服务端必须添加 SO_REUSEADDR 端口复用;
- 写入已断开 socket 会触发 SIGPIPE,进程直接退出,量产程序务必忽略该信号;
- 客户端断电、拔网线不会发送 FIN 报文!内核无法立刻感知断开,需要应用层心跳检测(后续章节讲解);
- SYN 队列溢出:高并发短连接场景,listen 的 backlog 配合内核参数调整,防止 SYN 攻击。
9. 本章小结
本章从报文交互拆解三次握手建立连接、四次挥手断开连接完整流程,梳理 TCP 核心连接状态流转,重点分析 TIME_WAIT、CLOSE_WAIT 两大线上高频故障成因与解决方案。 我们明白了优雅关闭和 RST 强制复位区别,掌握端口复用、SIGPIPE 处理、连接状态排查命令。
遗留思考:
- TCP 流式协议没有报文边界,粘包分包到底如何完整解决?
- 如何区分正常断连和网络异常掉线?
下一章:TCP 粘包分包终极解决方案|流式无边界问题、环形缓冲区、协议帧切割工业级实现