【Linux】TCP协议基础与连接管理详解:从三次握手到四次挥手

文章目录

TCP协议基础与连接管理详解:从三次握手到四次挥手

💬 开篇:上一篇我们学习了UDP------简单、快速、但不可靠。现在我们来看TCP的另一个极端:复杂、可靠、但相对较慢。TCP为什么这么复杂?因为它要保证数据可靠地到达对方。这一篇会详细讲解TCP的报文格式、三次握手建立连接、四次挥手断开连接、TCP状态转换,以及两个容易出现问题的状态:TIME_WAIT和CLOSE_WAIT。理解了TCP的连接管理,你就理解了为什么TCP能被称为"可靠的"协议。

👍 点赞、收藏与分享:这篇会把TCP的连接管理讲透,包括每一步的原因、状态转换、常见问题。如果对你有帮助,请点赞收藏!

🚀 循序渐进:从TCP报文格式讲起,到三次握手,到四次挥手,到状态转换,到TIME_WAIT和CLOSE_WAIT的深度理解,一步步掌握TCP的连接管理机制。


一、TCP报文格式详解

1.1 TCP报文的整体结构

TCP报文 = TCP首部 + TCP数据

bash 复制代码
┌──────────────────────────────────────────────────────────┐
│                    TCP首部(20-60字节)                   │
├──────────────────────────────────────────────────────────┤
│                    TCP数据(可变长度)                    │
└──────────────────────────────────────────────────────────┘

TCP首部的最小长度:20字节(没有选项时)

TCP首部的最大长度:60字节(有选项时)

1.2 TCP首部的各个字段

1. 源端口号和目的端口号(各16位)
bash 复制代码
┌─────────────────────┬─────────────────────┐
│   源端口号(16位)    │  目的端口号(16位)   │
└─────────────────────┴─────────────────────┘

作用:标识通信的两端应用程序。

范围:0-65535

2. 序列号(32位)
bash 复制代码
┌──────────────────────────────────────────┐
│         序列号(Sequence Number)          │
└──────────────────────────────────────────┘

作用

  • 标识TCP报文中数据的第一个字节的序号
  • 用于排序和去重
  • 初始值是随机的(为了安全)

例子

bash 复制代码
第一个报文:序列号=1000,数据100字节
第二个报文:序列号=1100,数据100字节
第三个报文:序列号=1200,数据100字节
3. 确认号(32位)
bash 复制代码
┌──────────────────────────────────────────┐
│      确认号(Acknowledgment Number)       │
└──────────────────────────────────────────┘

作用

  • 告诉对方"我已经收到了你的数据,下一个我要接收的序列号是多少"
  • 只有ACK标志位为1时才有效

例子

bash 复制代码
收到对方的报文(序列号1000-1099)
发送ACK,确认号=1100(表示"我已收到1000-1099,下一个要1100")
4. TCP首部长度(4位)
bash 复制代码
┌────────┐
│ 长度   │
└────────┘

含义:TCP首部有多少个32位字(4字节)。

计算

bash 复制代码
TCP首部长度 = 字段值 × 4 字节

例如:字段值=5 → TCP首部长度=20字节(最小)
例如:字段值=15 → TCP首部长度=60字节(最大)

为什么是4位

bash 复制代码
4位最大值=15
15 × 4 = 60字节(TCP首部最大长度)
5. 标志位(6位)
bash 复制代码
│ URG │ ACK │ PSH │ RST │ SYN │ FIN │

各标志位的含义

标志 名称 含义
SYN 同步 请求建立连接
ACK 确认 确认号有效
FIN 结束 请求关闭连接
RST 重置 重新建立连接
PSH 推送 立即发送,不等缓冲区满
URG 紧急 紧急指针有效

最常用的三个:SYN、ACK、FIN

6. 窗口大小(16位)
bash 复制代码
┌──────────────────────┐
│   窗口大小(16位)     │
└──────────────────────┘

作用:告诉对方"我的接收缓冲区还有多少空间"。

用途:流量控制(后面详细讲)。

范围:0-65535字节

7. 校验和(16位)
bash 复制代码
┌──────────────────────┐
│    校验和(16位)      │
└──────────────────────┘

校验和(checksum):用于检测传输过程中是否发生比特错误(覆盖 TCP 首部、数据和伪首部)。"

如果校验和出错

bash 复制代码
TCP层丢弃该报文,不通知应用层。
8. 紧急指针(16位)
bash 复制代码
┌──────────────────────┐
│   紧急指针(16位)     │
└──────────────────────┘

作用:当URG标志为1时,指示哪部分数据是紧急数据。

使用场景:很少使用。

9. 选项(可变长度)
bash 复制代码
┌──────────────────────────────────────┐
│        选项(0-40字节)                 │
└──────────────────────────────────────┘

常见选项

  • MSS(Maximum Segment Size):最大报文段长度
  • 窗口扩大因子:扩大窗口大小
  • 时间戳:用于RTT计算和防止序列号绕回

二、TCP的连接建立:三次握手

2.1 为什么需要三次握手

问题:为什么不是一次握手或两次握手?

答案:需要确认双方都能收发数据。

分析

一次握手

bash 复制代码
客户端 → 服务器:SYN
问题:服务器不知道客户端能否接收数据

两次握手

bash 复制代码
客户端 → 服务器:SYN
服务器 → 客户端:SYN+ACK
问题:客户端知道服务器能收发,但服务器不知道客户端能接收

三次握手

bash 复制代码
客户端 → 服务器:SYN
服务器 → 客户端:SYN+ACK
客户端 → 服务器:ACK
结果:双方都确认对方能收发

2.2 三次握手的详细过程

第一次握手:客户端发送SYN

客户端状态:CLOSED → SYN_SENT

发送的报文

bash 复制代码
SYN标志=1
序列号=x(客户端初始序列号,随机)
窗口大小=客户端接收缓冲区大小

含义

bash 复制代码
"嘿,我想和你建立连接。我的初始序列号是x。"
第二次握手:服务器回复SYN+ACK

服务器状态:LISTEN → SYN_RCVD

发送的报文

bash 复制代码
SYN标志=1
ACK标志=1
序列号=y(服务器初始序列号,随机)
确认号=x+1(确认收到了客户端的序列号x)
窗口大小=服务器接收缓冲区大小

含义

bash 复制代码
"好的,我收到你的连接请求了。我的初始序列号是y。
下一个我要接收的是你的序列号x+1。"
第三次握手:客户端发送ACK

客户端状态:SYN_SENT → ESTABLISHED

发送的报文

bash 复制代码
ACK标志=1
序列号=x+1(继续使用自己的序列号)
确认号=y+1(确认收到了服务器的序列号y)

含义

bash 复制代码
"好的,我收到你的回复了。下一个我要接收的是你的序列号y+1。"

服务器状态:SYN_RCVD → ESTABLISHED

2.3 三次握手的图示

bash 复制代码
客户端                          服务器
|                              |
| 第一次握手: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?

答案:确认号表示"下一个我要接收的序列号"。

例子

bash 复制代码
收到序列号为1000的报文(包含100字节数据)
这个报文的字节序号是1000-1099
下一个我要接收的字节序号是1100
所以确认号=1100

在握手中

bash 复制代码
收到SYN报文(序列号=x)
虽然SYN报文没有数据,但SYN本身占用一个序列号
所以下一个要接收的序列号是x+1
确认号=x+1

三、TCP的连接关闭:四次挥手

3.1 为什么需要四次挥手

问题:为什么不是三次或两次?

答案:TCP是全双工的,双方都可以发送数据。

分析

两次挥手

bash 复制代码
客户端 → 服务器:FIN(我要关闭了)
服务器 → 客户端:FIN(我也要关闭了)
问题:服务器可能还有数据要发送给客户端

四次挥手

bash 复制代码
客户端 → 服务器:FIN(我要关闭了)
服务器 → 客户端:ACK(我收到了)
服务器 → 客户端:FIN(我也要关闭了)
客户端 → 服务器:ACK(我收到了)
结果:双方都确认对方已关闭

3.2 四次挥手的详细过程

第一次挥手:客户端发送FIN

客户端状态:ESTABLISHED → FIN_WAIT_1

发送的报文

bash 复制代码
FIN标志=1
序列号=x(继续使用自己的序列号)

含义

bash 复制代码
"我已经发送完所有数据了,现在要关闭连接。"

重要:客户端发送FIN后,不再发送数据,但仍然可以接收数据。

第二次挥手:服务器回复ACK

服务器状态:ESTABLISHED → CLOSE_WAIT

发送的报文

bash 复制代码
ACK标志=1
确认号=x+1(确认收到了客户端的FIN)

含义

bash 复制代码
"好的,我收到你的关闭请求了。"

重要:服务器进入CLOSE_WAIT状态,表示"我收到了关闭请求,但我还有数据要发送"。

第三次挥手:服务器发送FIN

服务器状态:CLOSE_WAIT → LAST_ACK

发送的报文

bash 复制代码
FIN标志=1
序列号=y(继续使用自己的序列号)

含义

bash 复制代码
"我已经发送完所有数据了,现在也要关闭连接。"

时机:服务器在CLOSE_WAIT状态下,处理完所有待发送的数据后,才发送FIN。

第四次挥手:客户端回复ACK

客户端状态:FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT

发送的报文

bash 复制代码
ACK标志=1
确认号=y+1(确认收到了服务器的FIN)

含义

bash 复制代码
"好的,我收到你的关闭请求了。"

重要:客户端进入TIME_WAIT状态,等待2MSL时间后才进入CLOSED状态。

服务器状态:LAST_ACK → CLOSED

3.3 四次挥手的图示

bash 复制代码
客户端                          服务器
|                              |
| 第一次挥手: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 服务器端的状态转换

bash 复制代码
CLOSED
↓ (调用listen)
LISTEN
↓ (收到SYN)
SYN_RCVD
↓ (收到ACK)
ESTABLISHED
↓ (收到FIN)
CLOSE_WAIT
↓ (调用close)
LAST_ACK
↓ (收到ACK)
CLOSED

4.3 客户端的状态转换

bash 复制代码
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能到达

场景

bash 复制代码
客户端发送最后的ACK
这个ACK丢失了
服务器没收到,会重新发送FIN

TIME_WAIT的作用

bash 复制代码
客户端在TIME_WAIT状态下,仍然可以接收数据
如果收到重复的FIN,会重新发送ACK

图示

bash 复制代码
客户端                          服务器
|                              |
| 第四次挥手:ACK(ack=y+1)      |
|----------------------------->|
|                              |
| 进入TIME_WAIT                 | 等待ACK...
|                              |
| ACK丢失了!                   |
|                              |
|                              | 超时,重新发送FIN
| 第三次挥手(重复):FIN(seq=y)|
|<-----------------------------|
|                              |
| 收到重复的FIN,重新发送ACK    |
|----------------------------->|
|                              |
| 继续等待2MSL                  | 收到ACK,关闭
|                              |
| 2MSL后关闭                    |
原因2:防止旧连接的数据干扰新连接

场景

bash 复制代码
连接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的作用

bash 复制代码
等待2MSL,确保所有旧数据包都消失
然后才允许使用相同的五元组建立新连接

5.3 TIME_WAIT导致的问题

问题:当服务端作为主动关闭方、且快速重启时,可能遇到 Address already in use;

场景

bash 复制代码
# 启动服务器
./server 8080

# 运行一段时间后,按Ctrl+C关闭
# 立刻重启
./server 8080
# 错误:Address already in use

原因

bash 复制代码
服务器主动关闭连接,进入TIME_WAIT状态
TIME_WAIT期间,端口8080仍然被占用
无法bind到同一个端口

查看TIME_WAIT连接

bash 复制代码
netstat -an | grep TIME_WAIT

5.4 解决TIME_WAIT问题

方法1:使用SO_REUSEADDR选项

cpp 复制代码
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sockfd, ...);

效果:允许bind到处于TIME_WAIT状态的端口。

原理

bash 复制代码
`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:被动关闭连接的一方进入的状态。

含义

bash 复制代码
"我收到了对方的关闭请求,但我还有数据要发送。"

6.2 CLOSE_WAIT的正常流程

bash 复制代码
1. 收到对方的FIN
2. 进入CLOSE_WAIT状态
3. 处理剩余的数据
4. 调用close()关闭连接
5. 发送FIN
6. 进入LAST_ACK状态
7. 收到对方的ACK
8. 进入CLOSED状态

6.3 CLOSE_WAIT的问题

问题:大量CLOSE_WAIT连接堆积。

原因:应用程序没有正确关闭socket。

例子(错误的代码)

cpp 复制代码
// 服务器代码
for (;;) {
    TcpSocket new_sock;
    listen_sock.Accept(&new_sock, ...);
    
    for (;;) {
        std::string req;
        if (!new_sock.Recv(&req)) {
            // 客户端关闭了连接(收到FIN)
            // 服务器进入CLOSE_WAIT状态
            // 但没有调用new_sock.Close()
            break;  // ← 这里直接break了,没有关闭socket
        }
        // 处理请求...
    }
    // ← 缺少:new_sock.Close();
}

后果

bash 复制代码
1. 客户端调用close() → 发送FIN
2. 服务器收到FIN → 自动回复ACK → 进入CLOSE_WAIT
3. 服务器的应用层break,但没有调用close()
4. 服务器一直停留在CLOSE_WAIT状态
5. 连接无法完全关闭,资源无法释放

查看CLOSE_WAIT连接

bash 复制代码
netstat -an | grep CLOSE_WAIT
# 或
ss -tan | grep CLOSE_WAIT

输出示例(大量堆积)

bash 复制代码
tcp  1  0  127.0.0.1:8080  127.0.0.1:54321  CLOSE_WAIT
tcp  1  0  127.0.0.1:8080  127.0.0.1:54322  CLOSE_WAIT
tcp  1  0  127.0.0.1:8080  127.0.0.1:54323  CLOSE_WAIT
tcp  1  0  127.0.0.1:8080  127.0.0.1:54324  CLOSE_WAIT
... (几百个甚至几千个)

6.4 CLOSE_WAIT问题的影响

资源泄露

bash 复制代码
每个CLOSE_WAIT连接都占用:
- 一个文件描述符
- 内核中的TCP连接结构
- 接收和发送缓冲区

文件描述符耗尽

bash 复制代码
Linux默认的文件描述符限制:1024(ulimit -n查看)
如果有1000个CLOSE_WAIT连接,就无法创建新连接了

性能下降

bash 复制代码
大量无用的连接占用系统资源
影响正常的连接处理

6.5 解决CLOSE_WAIT问题

唯一的方法:正确关闭socket

正确的代码

cpp 复制代码
// 服务器代码
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();  // ← 关键:调用close()
            break;
        }
        // 处理请求...
    }
}

RAII封装(推荐)

cpp 复制代码
class TcpSocket {
public:
    ~TcpSocket() {
        Close();  // 析构时自动关闭
    }
    
    void Close() {
        if (fd_ >= 0) {
            close(fd_);
            fd_ = -1;
        }
    }
    
private:
    int fd_;
};

使用RAII后

cpp 复制代码
{
    TcpSocket new_sock;
    listen_sock.Accept(&new_sock, ...);
    
    // ... 处理请求 ...
    
} // ← new_sock析构时自动调用Close(),不会忘记关闭

6.6 排查CLOSE_WAIT问题

步骤1:确认是否有大量CLOSE_WAIT

bash 复制代码
netstat -an | grep CLOSE_WAIT | wc -l

步骤2:找到问题进程

bash 复制代码
netstat -anp | grep CLOSE_WAIT
# 输出会显示进程PID和名称

步骤3:检查代码

bash 复制代码
搜索所有Accept()的地方
确认每个Accept后都有对应的Close()

步骤4:使用工具检测

bash 复制代码
# 使用lsof查看进程打开的文件描述符
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 容易混淆的点

  1. 序列号和确认号

    • 序列号:我发送的数据的第一个字节的序号
    • 确认号:下一个我要接收的序列号
  2. SYN和FIN占用序列号

    • 虽然它们不携带数据,但各占用1个序列号
    • 所以确认号要+1
  3. TIME_WAIT vs CLOSE_WAIT

    • TIME_WAIT:主动关闭方,正常状态
    • CLOSE_WAIT:被动关闭方,如果大量堆积说明有bug
  4. 三次握手vs四次挥手

    • 三次:因为服务器可以把SYN和ACK合并发送
    • 四次:因为服务器可能还有数据要发送,不能合并
  5. 半关闭

    • 一方关闭了发送方向,但接收方向仍然开放
    • 四次挥手的第二次和第三次之间就是半关闭状态

💬 总结:TCP通过复杂的连接管理机制,实现了可靠的数据传输。三次握手确保双方都能收发数据,四次挥手优雅地关闭连接。TIME_WAIT是主动关闭方的正常状态,而CLOSE_WAIT大量堆积则是应用层的bug。理解了这些状态转换,你就理解了TCP为什么是"可靠的"。下一篇我们会讲TCP的可靠性机制(确认应答、超时重传、滑动窗口、流量控制、拥塞控制),看看TCP如何通过这些机制保证数据的可靠传输。
👍 点赞、收藏与分享:如果这篇帮你理解了TCP的连接管理和状态转换,请点赞收藏!网络编程,从理解TCP开始!

相关推荐
想做功的洛伦兹力12 小时前
2026/2/12日打卡
开发语言·c++·算法
你撅嘴真丑2 小时前
蛇形填充数组 与 查找最接近的元素
数据结构·c++·算法
蓝天居士2 小时前
Linux串口接收0x0D莫名转换为0x0A问题的根本原因分析
linux
njmanong2 小时前
Google点名处置IPIDEA及子品牌:代理IP行业进入强治理期
网络·网络协议·tcp/ip
终生成长者2 小时前
Kubernetes常用操作与概念总结--从服务器导出mongo数据,并下载到本地
服务器·容器·kubernetes
blackicexs2 小时前
第四周第四天
数据结构·c++·算法
UP_Continue2 小时前
Linux--动静态库
linux·运维·服务器
CheungChunChiu3 小时前
Linux 音频系统全景解析:PipeWire、PulseAudio 与 ALSA 的层次关系
linux·运维·服务器·audio
君陌社区·网络安全防护中心3 小时前
通过OVSDB管理交换机
网络