深入理解TCP连接管理:三次握手与四次挥手
TCP连接管理是网络通信的核心,本文深入解析了TCP三次握手和四次挥手的关键机制。三次握手确保可靠连接建立,防止历史连接导致的资源浪费;四次挥手则保证数据完整传输和连接安全关闭。文章详细探讨了SYN攻击防御(如SYN Cookie)、TIME_WAIT状态作用和常见问题(CLOSE_WAIT堆积)。通过Reactor项目实战,展示了实际开发中的连接管理技巧和问题排查方法,包括端口占用、连接泄漏等场
深入理解TCP连接管理:三次握手与四次挥手

目录
一、前言
准备校招的时候,计网是绕不过去的一关。我先看了王道考研的视频课程,对TCP三次握手有了基本概念:客户端发SYN,服务器回SYN+ACK,客户端再发ACK。看起来很简单,但深入思考后发现有很多细节没搞懂:
- 为什么必须是三次握手?两次不行吗?
- 第三次握手丢失了怎么办?
- 什么是SYN攻击?如何防御?
- 四次挥手的TIME_WAIT状态是干什么的?
后来我写了个Reactor网络服务器项目,实际遇到了不少问题。比如服务器重启后端口被占用,报错"Address already in use";又比如发现服务器有很多CLOSE_WAIT状态的连接,导致文件描述符耗尽。这些问题逼着我去深入理解TCP连接管理的每个细节。
今天把我的学习过程分享出来,希望对你有帮助。
本文包含:
- TCP三次握手的完整流程(状态变化、为什么是3次)
- SYN攻击与SYN Cookie防御机制
- TCP四次挥手的完整流程(TIME_WAIT、CLOSE_WAIT详解)
- 实际问题排查(端口占用、连接泄漏)
- Reactor项目中的连接管理实战
二、TCP三次握手深度解析
2.1 基本流程
先看一张完整的三次握手时序图:
客户端 服务器
CLOSED CLOSED
| |
| socket() + connect() socket() + bind() + listen()
| |
| LISTEN
| |
|-------------- SYN (seq=x) ------------------>|
| |
SYN_SENT SYN_RCVD
| |
|<------- SYN+ACK (seq=y, ack=x+1) ------------|
| |
|-------------- ACK (ack=y+1) ---------------->|
| |
ESTABLISHED ESTABLISHED
| |
|<------------- 数据传输 ---------------------->|
第一次握手: 客户端发送SYN包
- 发送:SYN=1, seq=x(初始序列号,随机)
- 状态变化:CLOSED → SYN_SENT
- 含义:客户端请求建立连接
第二次握手: 服务器回复SYN+ACK包
- 发送:SYN=1, ACK=1, seq=y, ack=x+1
- 状态变化:LISTEN → SYN_RCVD
- 含义:服务器同意连接,并确认收到客户端的SYN
第三次握手: 客户端回复ACK包
- 发送:ACK=1, ack=y+1
- 状态变化:SYN_SENT → ESTABLISHED
- 含义:客户端确认收到服务器的SYN
服务器收到ACK后,状态变为ESTABLISHED,连接建立完成。
2.2 为什么是三次握手?
这是面试必考题。很多人只知道"确认双方的收发能力",但面试官一追问细节就答不上来了。我们从两次握手的问题说起。
两次握手的问题:
假设只有两次握手:
- 客户端发送SYN
- 服务器回复SYN+ACK,连接建立
看起来没问题,但考虑这个场景:
时刻T1:客户端发送SYN1(因网络延迟,滞留在网络中)
时刻T2:客户端超时,重发SYN2
时刻T3:服务器收到SYN2,建立连接,数据传输完毕,连接关闭
时刻T4:延迟的SYN1到达服务器
问题来了:
- 两次握手下,服务器收到SYN1后立即建立连接
- 但客户端已经关闭了,不会再发数据
- 服务器白白分配了资源(TCB控制块、端口),浪费!
三次握手如何解决:
时刻T4:延迟的SYN1到达服务器
服务器:收到SYN1,回复SYN+ACK(第二次握手)
客户端:收到SYN+ACK,但这不是我发的连接请求!
不回复ACK(或发送RST)
服务器:超时未收到ACK(第三次握手)
放弃这次连接,不分配资源
本质原因: 三次握手让客户端有机会确认"这是我发起的连接请求",避免因延迟的旧连接请求导致服务器资源浪费。
面试标准答案:
三次握手的目的是确认双方的收发能力:
- 第一次握手:服务器确认"客户端能发"
- 第二次握手:客户端确认"服务器能收、能发",客户端确认"自己能收"
- 第三次握手:服务器确认"客户端能收"
此外,三次握手还能防止因历史连接请求(延迟的SYN包)导致服务器资源浪费。
2.3 第三次握手可以携带数据吗?
可以!这是个冷门但有深度的考点。
前两次握手不能携带数据:
- 原因:防止SYN泛洪攻击
- 如果SYN包可以携带大量数据,攻击者可以伪造大量SYN包,消耗服务器带宽和资源
第三次握手可以携带数据:
- 此时客户端已经是ESTABLISHED状态
- 携带数据相当于第一个数据包,不会被用于攻击
TCP Fast Open(TFO):
现代优化,允许在SYN包中携带数据(需要Cookie验证)。
2.4 三次握手失败的场景
场景1:SYN丢失
客户端:发送SYN → 超时未收到SYN+ACK → 重传SYN
重传次数:tcp_syn_retries(默认6次)
重传间隔:1s, 2s, 4s, 8s, 16s, 32s(指数退避)
总超时时间:约127秒
场景2:SYN+ACK丢失
服务器:发送SYN+ACK → 超时未收到ACK → 重传SYN+ACK
重传次数:tcp_synack_retries(默认5次)
客户端:超时重传SYN
场景3:ACK丢失
服务器:未收到ACK,停留在SYN_RCVD状态 → 超时重传SYN+ACK
客户端:已经ESTABLISHED,发送数据
服务器:收到数据包,隐式确认ACK → 进入ESTABLISHED
注意场景3的处理很巧妙:客户端发送的数据包本身就带着ACK,服务器可以通过数据包进入ESTABLISHED状态。
2.5 SYN攻击与防御
SYN攻击原理:
攻击者大量伪造源IP,发送SYN包:
攻击者 → 服务器:SYN (源IP=1.1.1.1)
攻击者 → 服务器:SYN (源IP=2.2.2.2)
攻击者 → 服务器:SYN (源IP=3.3.3.3)
...发送数万个SYN包
服务器:
1. 为每个SYN分配资源(TCB控制块)
2. 加入半连接队列
3. 等待ACK(永远不会来,因为IP是伪造的)
4. 半连接队列满了 → 拒绝正常连接请求
传统防御方式:
- 增大半连接队列(治标不治本)
- 缩短SYN_RCVD超时时间
- SYN Proxy(代理检查)
SYN Cookie防御机制:
核心思想:不分配资源,把状态信息编码到序列号中。
服务器收到SYN:
1. 不分配TCB控制块
2. 不加入半连接队列
3. 计算Cookie:
cookie = hash(源IP, 源端口, 目的IP, 目的端口, 时间戳, 密钥)
4. 发送SYN+ACK,seq=cookie
客户端(真实):
收到SYN+ACK,回复ACK (ack=cookie+1)
服务器收到ACK:
1. 提取cookie = ack - 1
2. 重新计算:expected_cookie = hash(...)
3. 验证:cookie == expected_cookie?
4. 验证通过:分配资源,建立连接
攻击者(伪造IP):
不会回复ACK → 服务器不分配资源
Linux中启用SYN Cookie:
# 查看状态
cat /proc/sys/net/ipv4/tcp_syncookies
# 启用(1=启用,0=禁用)
echo 1 > /proc/sys/net/ipv4/tcp_syncookies
在我的Reactor项目中,这是默认开启的系统防护。
三、TCP四次挥手深度解析
3.1 基本流程
客户端(主动关闭方) 服务器(被动关闭方)
ESTABLISHED ESTABLISHED
| |
| close() |
| |
|------------- FIN (seq=u) ---------------->|
| |
FIN_WAIT_1 CLOSE_WAIT
| |
|<------------ ACK (ack=u+1) ---------------|
| |
FIN_WAIT_2 CLOSE_WAIT
| |
| close() |
| |
|<------------ FIN (seq=v) -----------------|
| |
TIME_WAIT LAST_ACK
| |
|------------- ACK (ack=v+1) -------------->|
| |
TIME_WAIT CLOSED
|
| 等待2MSL
|
CLOSED
第一次挥手: 客户端发送FIN
- 发送:FIN=1, seq=u
- 状态:ESTABLISHED → FIN_WAIT_1
- 含义:客户端没有数据要发送了
第二次挥手: 服务器回复ACK
- 发送:ACK=1, ack=u+1
- 状态:ESTABLISHED → CLOSE_WAIT
- 含义:服务器确认收到FIN
第三次挥手: 服务器发送FIN
- 发送:FIN=1, seq=v
- 状态:CLOSE_WAIT → LAST_ACK
- 含义:服务器也没有数据要发送了
第四次挥手: 客户端回复ACK
- 发送:ACK=1, ack=v+1
- 状态:FIN_WAIT_2 → TIME_WAIT
- 含义:客户端确认收到服务器的FIN
3.2 为什么是四次挥手?
这个问题的标准答案是"因为TCP是全双工的",但这个答案太抽象了。我们用实际场景说明。
为什么不能合并成三次?
第二次和第三次挥手能不能合并?答案是:有时可以,有时不行。
场景1:服务器没有数据要发送
客户端:FIN (我不发了)
服务器:ACK + FIN (我确认,我也不发了) ← 合并!
客户端:ACK (确认)
这种情况下,确实是三次挥手
场景2:服务器还有数据要发送
客户端:FIN (我不发了,但我还能收)
服务器:ACK (我确认,但我还有数据要发)
继续发送剩余数据...
数据发完了
FIN (我也不发了)
客户端:ACK (确认)
这种情况下,必须是四次挥手
本质原因: TCP的关闭是独立的。客户端不发了,不代表服务器也不发了。第二次挥手只是确认"我知道你不发了",第三次挥手才是"我也不发了"。
面试标准答案:
建立连接需要三次握手,因为只需要同步双方的初始序列号。
关闭连接需要四次挥手,因为TCP是全双工的,双方都需要单独关闭发送方向:
- 第一次:客户端关闭发送
- 第二次:服务器确认
- 第三次:服务器关闭发送
- 第四次:客户端确认
第二次和第三次不能合并,因为服务器可能还有数据要发送。
3.3 TIME_WAIT状态详解
TIME_WAIT是最容易被问到的状态。在我的Reactor项目中,服务器重启时经常遇到"Address already in use"错误,就是因为TIME_WAIT导致的。
为什么需要TIME_WAIT?
主要有两个原因:
原因1:保证最后的ACK能到达
客户端发送最后的ACK后立即关闭:
客户端:ACK (ack=v+1) → CLOSED(有问题)
|
| ACK丢失
↓
服务器:超时未收到ACK → 重传FIN
|
↓
客户端:已经CLOSED,收到FIN → 回复RST(端口不存在)
服务器:收到RST → 异常终止(有问题)
TIME_WAIT等待2MSL(Maximum Segment Lifetime),确保:
- 如果ACK丢失,服务器会重传FIN
- 客户端在TIME_WAIT期间能收到重传的FIN,重新发送ACK
原因2:防止旧连接的数据包干扰新连接
场景:客户端关闭连接后,立即用相同的四元组(源IP、源端口、目的IP、目的端口)建立新连接
旧连接:客户端:8888 → 服务器:80
发送数据,seq=100
关闭连接
新连接:客户端:8888 → 服务器:80(相同四元组)
旧连接的延迟数据包:seq=100 → 到达服务器
服务器:误以为是新连接的数据(问题出现)
TIME_WAIT等待2MSL,确保网络中所有旧连接的数据包都消失了(MSL是IP数据包在网络中的最大生存时间)。
2MSL的原因:
- 1个MSL:客户端的ACK到达服务器的最大时间
- 1个MSL:服务器重传的FIN到达客户端的最大时间
- 2MSL:一个来回的最大时间
Linux中,MSL=30秒(不可配置),所以TIME_WAIT=60秒。
TIME_WAIT过多的问题:
在高并发场景下(比如压力测试),客户端会产生大量TIME_WAIT状态的连接:
- 占用端口(每个连接需要一个端口)
- 客户端端口范围有限(32768-60999,约2.8万个)
- 端口耗尽 → 无法建立新连接
解决方法:
# 允许TIME_WAIT状态的socket被新连接复用
net.ipv4.tcp_tw_reuse = 1
# 快速回收TIME_WAIT连接(不推荐,可能导致数据混乱)
net.ipv4.tcp_tw_recycle = 0 # Linux 4.12后已废弃
3.4 CLOSE_WAIT状态详解
CLOSE_WAIT是服务器端容易出问题的状态。我在Reactor项目中遇到过CLOSE_WAIT堆积,导致文件描述符耗尽的问题。
CLOSE_WAIT产生的原因:
1. 客户端:发送FIN,主动关闭连接
2. 服务器:内核自动回复ACK,进入CLOSE_WAIT状态
3. 服务器:应用层应该调用close(),关闭连接
4. 如果应用层不调用close(),连接一直停留在CLOSE_WAIT(问题出现)
常见原因:
- 应用层bug,忘记关闭连接
- 应用层阻塞,无法及时处理连接关闭
- 应用层死循环或死锁
危害:
- 连接泄漏,占用文件描述符
- 文件描述符耗尽 → 无法accept新连接
- 服务器挂掉
排查方法:
# 查看CLOSE_WAIT连接数
netstat -an | grep CLOSE_WAIT | wc -l
# 查看哪个进程占用
lsof -i | grep CLOSE_WAIT
# 查看文件描述符使用情况
cat /proc/<pid>/fd | wc -l
ulimit -n # 查看限制
在Reactor项目中的修复:
// 错误的写法
void handle_read_event(int connfd) {
char buf[4096];
int n = recv(connfd, buf, sizeof(buf), 0);
if (n > 0) {
process_data(buf, n);
} else if (n == 0) {
// 对方关闭连接
epoll_ctl(epollfd, EPOLL_CTL_DEL, connfd, NULL);
// 忘记close(connfd) → CLOSE_WAIT堆积
}
}
// 正确的写法
void handle_read_event(int connfd) {
char buf[4096];
int n = recv(connfd, buf, sizeof(buf), 0);
if (n > 0) {
process_data(buf, n);
} else if (n == 0) {
// 对方关闭连接
epoll_ctl(epollfd, EPOLL_CTL_DEL, connfd, NULL);
close(connfd); // 必须close
// 最好记录日志
LOG_INFO("Connection closed by peer, fd=%d", connfd);
}
}
3.5 close() vs shutdown()
这是个常考但容易混淆的知识点。
close():
- 关闭读和写两个方向
- 引用计数减1,如果为0则真正关闭
- 发送FIN,开始四次挥手
int connfd = accept(...);
// 使用connfd...
close(connfd); // 完全关闭
shutdown():
- 可以单独关闭读或写方向
- 不管引用计数,立即关闭指定方向
shutdown(fd, SHUT_WR)发送FIN,但还能接收数据
int connfd = accept(...);
// 半关闭:关闭写方向,但还能读
shutdown(connfd, SHUT_WR);
// 发送FIN给对方
// 但还能recv()接收对方的数据
// 等对方也关闭后,再close
close(connfd);
使用场景:
- close():大部分情况使用
- shutdown():需要优雅关闭时使用(比如HTTP服务器发送完响应后,关闭写方向,但还要等客户端关闭)
四、Reactor项目实战
下面是我在Reactor项目中处理TCP连接管理的实际代码。
4.1 创建监听socket
int create_listen_socket(int port) {
// 1. 创建socket
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd < 0) {
perror("socket");
return -1;
}
// 2. 设置SO_REUSEADDR,避免TIME_WAIT导致的端口占用
int opt = 1;
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,
&opt, sizeof(opt)) < 0) {
perror("setsockopt SO_REUSEADDR");
close(listenfd);
return -1;
}
// 3. bind
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
if (bind(listenfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
close(listenfd);
return -1;
}
// 4. listen
// backlog=128:全连接队列的最大长度
if (listen(listenfd, 128) < 0) {
perror("listen");
close(listenfd);
return -1;
}
printf("Server listening on port %d\n", port);
return listenfd;
}
关键点:
SO_REUSEADDR:允许处于TIME_WAIT状态的地址被重新bind,解决服务器重启问题backlog=128:全连接队列大小,影响并发连接数
4.2 接受新连接
void handle_accept_event(int listenfd, int epollfd) {
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
// accept获取新连接
int connfd = accept(listenfd,
(struct sockaddr*)&client_addr,
&addr_len);
if (connfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 没有新连接了,正常
return;
}
perror("accept");
return;
}
// 打印客户端信息
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, ip, sizeof(ip));
printf("New connection from %s:%d, fd=%d\n",
ip, ntohs(client_addr.sin_port), connfd);
// 设置非阻塞
set_nonblocking(connfd);
// 加入epoll,监听读事件
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 边缘触发
ev.data.fd = connfd;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, connfd, &ev) < 0) {
perror("epoll_ctl ADD");
close(connfd);
return;
}
}
4.3 处理读事件(检测对方关闭)
void handle_read_event(int connfd, int epollfd) {
char buf[4096];
while (1) {
int n = recv(connfd, buf, sizeof(buf), 0);
if (n > 0) {
// 正常接收数据
buf[n] = '\0';
printf("Received %d bytes: %s\n", n, buf);
// 回声服务器:原样返回
send(connfd, buf, n, 0);
} else if (n == 0) {
// 对方关闭连接(收到FIN)
printf("Connection closed by peer, fd=%d\n", connfd);
// 从epoll删除
epoll_ctl(epollfd, EPOLL_CTL_DEL, connfd, NULL);
// 关闭连接(触发四次挥手)
close(connfd);
break;
} else {
// n < 0
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 非阻塞socket,数据读完了
break;
} else {
// 真实错误
perror("recv");
epoll_ctl(epollfd, EPOLL_CTL_DEL, connfd, NULL);
close(connfd);
break;
}
}
}
}
关键点:
recv()返回0:对方发送了FIN,连接关闭- 必须调用
close(connfd):否则停留在CLOSE_WAIT状态 - 边缘触发模式:必须循环读取直到EAGAIN
4.4 主动关闭连接
void close_connection(int connfd, int epollfd) {
// 1. 从epoll删除
epoll_ctl(epollfd, EPOLL_CTL_DEL, connfd, NULL);
// 2. 关闭连接(触发四次挥手)
close(connfd);
// 注意:close后,connfd进入FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT
// TIME_WAIT会持续60秒(2MSL)
printf("Connection closed, fd=%d\n", connfd);
}
4.5 优雅关闭
对于HTTP服务器,更好的做法是优雅关闭:
void graceful_close(int connfd, int epollfd) {
// 1. 发送完响应后,关闭写方向
shutdown(connfd, SHUT_WR);
// 此时发送FIN给客户端
// 2. 继续接收数据,直到对方关闭
// (在handle_read_event中处理recv返回0的情况)
// 3. 收到对方的FIN后,调用close
// close(connfd);
}
4.6 常见问题排查
问题1:Address already in use
# 原因:TIME_WAIT状态占用端口
# 解决:设置SO_REUSEADDR
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
问题2:大量CLOSE_WAIT
# 原因:应用层没有close
# 解决:检查代码,确保recv返回0时调用close
if (n == 0) {
close(connfd); // 必须
}
问题3:连接超时
# 原因:防火墙丢弃SYN包,或服务器半连接队列满
# 解决:
# 1. 增大半连接队列
net.ipv4.tcp_max_syn_backlog = 8192
# 2. 启用SYN Cookie
net.ipv4.tcp_syncookies = 1
# 3. 检查防火墙
iptables -L -n
五、面试高频题总结
必背知识点(10个)
-
三次握手的完整流程和状态变化
- SYN_SENT、SYN_RCVD、ESTABLISHED
-
为什么是三次握手,不是两次或四次
- 防止历史连接请求导致资源浪费
- 确认双方收发能力
-
SYN攻击原理和SYN Cookie防御
- 半连接队列满,拒绝服务
- 不分配资源,状态编码到seq
-
四次挥手的完整流程和状态变化
- FIN_WAIT_1、FIN_WAIT_2、TIME_WAIT、CLOSE_WAIT
-
TIME_WAIT的作用和2MSL的原因
- 保证最后的ACK到达
- 防止旧连接数据包干扰新连接
-
CLOSE_WAIT产生的原因和解决方法
- 应用层没有close
- 必须在recv返回0时调用close
-
SO_REUSEADDR的作用
- 允许TIME_WAIT状态的地址被复用
- 解决服务器重启问题
-
close() vs shutdown()的区别
- close关闭读写,shutdown可单独关闭
- shutdown用于优雅关闭
-
半连接队列和全连接队列
- 半连接:SYN_RCVD状态的连接
- 全连接:ESTABLISHED状态,等待accept
-
如何在项目中处理连接管理
- socket、bind、listen、accept
- epoll监听,recv检测关闭,及时close
面试标准答案模板
问:为什么TCP需要三次握手?
TCP需要三次握手有两个原因:
确认双方的收发能力:
- 第一次握手:服务器确认客户端能发
- 第二次握手:客户端确认服务器能收、能发,客户端确认自己能收
- 第三次握手:服务器确认客户端能收
防止历史连接请求导致资源浪费:
- 如果只有两次握手,客户端发送的延迟SYN包到达服务器时,服务器会立即建立连接
- 但客户端已经关闭,不会发送数据,服务器白白分配了资源
- 三次握手让客户端有机会确认"这是我发起的连接",拒绝旧连接请求
在我的Reactor项目中,我理解了listen和accept的作用:listen创建半连接队列,三次握手完成后连接移入全连接队列,accept从全连接队列取出连接。
问:TIME_WAIT状态的作用是什么?
TIME_WAIT状态有两个作用:
保证最后的ACK能到达:
- 如果最后的ACK丢失,服务器会重传FIN
- 客户端在TIME_WAIT期间能收到重传的FIN,重新发送ACK
- 等待2MSL,确保服务器能收到ACK(一个来回的时间)
防止旧连接的数据包干扰新连接:
- 如果立即关闭,新连接可能使用相同的四元组
- 旧连接的延迟数据包可能被新连接误接收
- 等待2MSL,确保旧连接的数据包在网络中消失
Linux中,MSL=30秒,所以TIME_WAIT=60秒。在我的项目中,通过设置SO_REUSEADDR,解决了服务器重启时的端口占用问题。
六、总结
TCP连接管理是计网的核心知识点,也是面试必考题。学习过程中,我的体会是:
理论学习:
- 看视频课程(王道考研),建立基本概念
- 画时序图和状态机图,理解状态变化
- 思考"为什么":为什么是三次?为什么TIME_WAIT?
实践验证:
- 写Reactor项目,实际处理连接管理
- 遇到问题(端口占用、CLOSE_WAIT),查资料深入理解
- 抓包分析(tcpdump/Wireshark),观察真实的三次握手和四次挥手
面试准备:
- 整理标准答案模板,背诵核心知识点
- 结合项目讲解,展示实践经验
- 准备追问:SYN攻击、TIME_WAIT、CLOSE_WAIT
下一篇文章,我们深入聊TCP的可靠性机制:滑动窗口、超时重传、拥塞控制。这部分更难,但也更有深度。
参考资料
- 王道考研《计算机网络》视频课程
- RFC 793:TCP协议规范
- Linux内核源码:
net/ipv4/tcp_input.c
更多推荐



所有评论(0)