目录
[TCP 协议](#TCP 协议)
[TCP 协议段格式](#TCP 协议段格式)
[TCP 首部格式详解](#TCP 首部格式详解)
[1. 源端口(16 位)& 目的端口(16 位)](#1. 源端口(16 位)& 目的端口(16 位))
[2. 序号(32 位,Sequence Number)](#2. 序号(32 位,Sequence Number))
[3. 确认序号(32 位,Acknowledgment Number)](#3. 确认序号(32 位,Acknowledgment Number))
[4. 首部长度(4 位)](#4. 首部长度(4 位))
[5. 保留(6 位)](#5. 保留(6 位))
[6. 标志位(每个 1 位,共 6 位)](#6. 标志位(每个 1 位,共 6 位))
[7. 窗口大小(16 位)](#7. 窗口大小(16 位))
[8. 校验和(16 位)](#8. 校验和(16 位))
[9. 紧急指针(16 位)](#9. 紧急指针(16 位))
[10. 选项(长度可变,最多 40 字节)](#10. 选项(长度可变,最多 40 字节))
[场景 A:客户端主动关闭(服务器被动关闭)](#场景 A:客户端主动关闭(服务器被动关闭))
[场景 B:服务器主动关闭(客户端被动关闭)](#场景 B:服务器主动关闭(客户端被动关闭))
[1. 服务器端应用层](#1. 服务器端应用层)
[2. 服务器端 TCP 层状态](#2. 服务器端 TCP 层状态)
[二、TCP 三次握手(建立连接)](#二、TCP 三次握手(建立连接))
[1. 客户端应用层](#1. 客户端应用层)
[2. 客户端 TCP 层状态](#2. 客户端 TCP 层状态)
[3. 服务器端 TCP 层](#3. 服务器端 TCP 层)
[4. 应用层返回](#4. 应用层返回)
[TCP 层状态:](#TCP 层状态:)
[四、TCP 四次挥手(关闭连接)](#四、TCP 四次挥手(关闭连接))
[1. 服务器端应用层调用 close(connfd)](#1. 服务器端应用层调用 close(connfd))
[2. 服务器端 TCP 状态转移](#2. 服务器端 TCP 状态转移)
[3. 客户端 TCP 状态转移(被动关闭)](#3. 客户端 TCP 状态转移(被动关闭))
[TCP 状态转换的汇总](#TCP 状态转换的汇总)
[理解 TIME_WAIT 状态](#理解 TIME_WAIT 状态)
[解决 TIME_WAIT 状态引起的 bind 失败的方法](#解决 TIME_WAIT 状态引起的 bind 失败的方法)
[理解 CLOSE_WAIT 状态](#理解 CLOSE_WAIT 状态)
[思考: 对于 UDP 协议来说, 是否也存在 "粘包问题" 呢?](#思考: 对于 UDP 协议来说, 是否也存在 "粘包问题" 呢?)
[TCP 异常情况](#TCP 异常情况)
[TCP 小结](#TCP 小结)
[基于 TCP 应用层协议](#基于 TCP 应用层协议)
[TCP/UDP 对比](#TCP/UDP 对比)
[用 UDP 实现可靠传输(经典面试题)](#用 UDP 实现可靠传输(经典面试题))
[1. 引入序列号(Sequence Number)](#1. 引入序列号(Sequence Number))
[2. 引入确认应答(ACK)](#2. 引入确认应答(ACK))
[3. 引入超时重传(Timeout Retransmission)](#3. 引入超时重传(Timeout Retransmission))
[4. 引入滑动窗口(可选,但常见)](#4. 引入滑动窗口(可选,但常见))
[5. 引入拥塞控制(更进阶)](#5. 引入拥塞控制(更进阶))
TCP 协议
TCP 全称为 "传输控制协议(Transmission Control Protocol"). 人如其名, 要对数据的传 输进行一个详细的控制;
TCP 协议段格式

• 源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去;
• 32 位序号/32 位确认号: 后面详细讲;
• 4 位 TCP 报头长度: 表示该 TCP 头部有多少个 32 位 bit(有多少个 4 字节); 所以 TCP 头部最大长度是 15 * 4 = 60
• 6 位标志位:
○ URG: 紧急指针是否有效
○ ACK: 确认号是否有效
○ PSH: 提示接收端应用程序立刻从 TCP 缓冲区把数据读走
○ RST: 对方要求重新建立连接; 我们把携带 RST 标识的称为复位报文段
○ SYN: 请求建立连接; 我们把携带 SYN 标识的称为同步报文段
○ FIN: 通知对方, 本端要关闭了, 我们称携带 FIN标识的为结束报文段
• 16 位窗口大小: 后面再说
• 16 位校验和: 发送端填充, CRC 校验. 接收端校验不通过, 则认为数据有问题. 此 处的检验和不光包含 TCP 首部, 也包含 TCP 数据部分.
• 16 位紧急指针: 标识哪部分数据是紧急数据;
• 40 字节头部选项: 暂时忽略
TCP 首部格式详解
TCP 首部长度最小为 20 字节 (没有选项时),最大为 60 字节(选项占 40 字节)。下图是每个字段的位置:
cpp
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 源端口 | 目的端口 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 序号 (Sequence Number) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 确认序号 (Acknowledgment Number) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 首部长度 | 保留 |U|A|P|R|S|F| 窗口大小 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 校验和 | 紧急指针 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 选项 (可选) | 填充 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 数据 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
1. 源端口(16 位)& 目的端口(16 位)
-
作用 :标识发送方和接收方的应用进程(通过端口号找到对应的 socket)。
-
细节:端口号加上 IP 首部中的源 IP 和目的 IP,才能唯一确定一个 TCP 连接(四元组)。
-
常用范围:0~1023 为系统保留(知名端口),1024~49151 为注册端口,49152~65535 为动态/私有端口。
2. 序号(32 位,Sequence Number)
-
作用 :该 TCP 报文段中第一个数据字节的编号。
-
初始序号(ISN):建立连接时双方随机生成(避免历史报文干扰),而非固定从 1 开始。
-
范围:0 ~ 2^32 - 1,到达最大值后回绕到 0(TCP 通过时间戳或扩展选项处理回绕问题)。
-
为什么重要:保证数据有序、去重、检测丢失。
3. 确认序号(32 位,Acknowledgment Number)
-
作用 :期望收到的下一个字节的序号,同时隐含确认了该序号之前的所有字节都已正确收到。
-
有效条件 :仅当 ACK 标志位 = 1 时,确认序号字段才有效。
-
累计确认:Ack = N 表示序号 N-1 及之前的数据都已收到。
-
示例:收到 Seq=1001 且长度=500 的数据后,回复 Ack=1501(表示期待第 1501 字节)。
4. 首部长度(4 位)
-
作用 :表示 TCP 首部有多少个 32 位字(4 字节)。
-
取值范围:5 ~ 15(因为最小 20 字节,即 5×4=20;最大 60 字节,即 15×4=60)。
-
为什么需要:因为选项字段长度可变,接收方需要知道首部在哪里结束、数据从哪里开始。
5. 保留(6 位)
- 作用:保留为未来使用,当前必须置 0。
6. 标志位(每个 1 位,共 6 位)
| 标志 | 名称 | 为 1 时的含义 |
|---|---|---|
| URG | Urgent | 紧急指针字段有效,报文中有紧急数据(应优先处理) |
| ACK | Acknowledgment | 确认序号字段有效(除初始 SYN 报文外,几乎所有报文都置 1) |
| PSH | Push | 提示接收端立即将数据交给应用层,不要等缓冲区满 |
| RST | Reset | 连接出现严重异常,需要强制关闭并重新建立连接(拒绝非法请求) |
| SYN | Synchronize | 建立连接时使用:SYN=1 表示这是一个连接请求或连接接受报文 |
| FIN | Finish | 关闭连接时使用:发送方不再发送数据 |
补充细节:
PSH 的实际行为:发送方设置 PSH 后,接收方 TCP 不会等待缓冲区填满,而是立即把数据递交给应用进程(但现代 TCP 实现通常自动优化,很少显式依赖)。
RST 常见场景:尝试连接一个未监听的端口、连接超时、收到不属于现存连接的报文。
SYN+ACK:服务器回复连接请求时,SYN=1, ACK=1。
7. 窗口大小(16 位)
-
作用 :告诉对方从确认序号开始,自己还能接收多少字节的数据(即接收窗口大小)。
-
范围 :0 ~ 65535 字节。若需要使用更大窗口,可以通过 TCP 窗口缩放选项(Window Scale)将窗口值左移若干位(最大可达 1GB)。
-
用途 :实现流量控制,防止发送方发送过快导致接收方缓冲区溢出。
-
动态变化:接收方根据自身可用缓冲区大小随时调整窗口值并通知发送方。
8. 校验和(16 位)
-
作用:检测 TCP 首部和数据在传输过程中是否出现比特错误。
-
计算范围 :TCP 伪首部(12 字节,包含源 IP、目的 IP、协议号、TCP 长度)+ TCP 首部 + TCP 数据。
- 伪首部并不真正传输,只在计算校验和时临时构造。
-
计算方式:将上述所有内容按 16 位字累加,进位回卷,最后取反码。
-
接收方:同样计算,若结果不为全 1(即 0xFFFF)则丢弃报文。
为什么包含伪首部:为了验证报文是否确实发送给了正确的 IP 和协议(防止 IP 欺骗或路由错误)。
9. 紧急指针(16 位)
-
作用 :仅在 URG=1 时有效,指向紧急数据的最后一个字节的序号(偏移量,相对于当前序号字段的值)。
-
用途:发送紧急数据(如中断命令)时,接收方可以立即读取,而不被流控阻塞。
-
实际使用:现代应用很少依赖,因为带外数据(out-of-band data)在其他机制下可能更复杂。
10. 选项(长度可变,最多 40 字节)
-
常见选项:
-
MSS (Maximum Segment Size):告诉对方自己能接收的最大报文段长度(不包括 TCP 首部)。
-
窗口缩放因子:用于扩展窗口大小(Windows Scale)。
-
时间戳:用于计算 RTT 和防止序号回绕(PAWS)。
-
SACK (Selective Acknowledgment):允许接收方告知哪些数据块丢失(提高重传效率)。
-
-
填充:确保首部长度是 4 字节的整数倍(用 0 填充)。
总结:各字段的功能分组
| 功能 | 相关字段 |
|---|---|
| 标识进程 | 源端口、目的端口 |
| 可靠传输(有序/确认) | 序号、确认序号、ACK、SYN、FIN |
| 流量控制 | 窗口大小 |
| 错误检测 | 校验和 |
| 紧急数据 | URG、紧急指针 |
| 连接控制 | SYN、FIN、RST |
| 提示接收方 | PSH |
| 扩展功能 | 选项、保留、首部长度 |
确认应答(ACK)机制
-
主机A:发送方
-
主机B:接收方
-
数据(1~1000) :表示该TCP报文段携带的数据字节序号是从 1 到 1000。
-
确认应答(下一个是1001):主机B收到数据后回复的确认报文,含义是 "我已收到 1~1000 的所有数据,下一个期望收到的字节序号是 1001"。
-
数据(1001~2000):主机A收到确认后,继续发送下一个数据段,字节序号从 1001 到 2000。
-
确认应答(下一个是2001):主机B再次回复,表示成功收到 1001~2000,期待序号 2001。
(1)序列号与累计确认
-
TCP 把数据流看作一个字节流,每个字节都有一个唯一的序列号(Seq)。
-
主机A发送的第一个数据段"1~1000"实际上是指该数据段起始字节序号为 1,长度为 1000 字节。
-
主机B回复的确认号(Ack)是 1001 ,这是 TCP 的 累计确认 机制:告诉主机A "我已经收到了序号 1000 及之前的所有字节,请从 1001 开始发送"。
(2)确认应答保证可靠
-
主机A每发一个数据段,都需要收到来自主机B的确认。
-
如果一段时间内没有收到确认(超时),主机A会重传未确认的数据。
-
图中每个数据段都得到了确认,因此主机A可以正常发送下一段。
(3)发送与接收的同步
-
主机A不会一次性把所有数据全部发出去,而是等待对方确认后再发下一段(这是最简单的 停-等协议 的体现)。
-
在实际 TCP 中,为了提高效率,会使用 滑动窗口 允许连续发送多个数据段再等待确认,但图里展示的是基础逻辑:发送 → 确认 → 再发送。
TCP 将每个字节的数据都进行了编号. 即为序列号.

每一个 ACK 都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下 一次你从哪里开始发
超时重传机制

• 主机 A 发送数据给 B 之后, 可能因为网络拥堵等原因, 数据无法到达主机 B;
• 如果主机 A 在一个特定时间间隔内没有收到 B 发来的确认应答, 就会进行重发
但是, 主机 A 未收到 B 发来的确认应答, 也可能是因为 ACK 丢失了;

因此主机 B 会收到很多重复数据. 那么 TCP 协议需要能够识别出那些包是重复的包, 并 且把重复的丢弃掉. 这时候我们可以利用前面提到的序列号, 就可以很容易做到去重的效果.
那么, 如何超时的时间如何确定?
• 最理想的情况下, 找到一个最小的时间, 保证 "确认应答一定能在这个时间内返 回".
• 但是这个时间的长短, 随着网络环境的不同, 是有差异的.
• 如果超时时间设的太长, 会影响整体的重传效率;
• 如果超时时间设的太短, 有可能会频繁发送重复的包
TCP 为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超 时时间
• Linux 中(BSD Unix 和 Windows 也是如此), 超时以 500ms 为一个单位进行控 制, 每次判定超时重发的超时时间都是 500ms 的整数倍.
• 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
• 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.
• 累计到一定的重传次数, TCP 认为网络或者对端主机出现异常, 强制关闭连接
连接管理机制
在正常情况下, TCP 要经过三次握手建立连接, 四次挥手断开连接
三次握手(建立连接)
| 当前状态 | 事件 / 动作 | 下一状态 | 说明 |
|---|---|---|---|
| CLOSED | 服务器执行 listen() |
LISTEN | 服务器被动监听 |
| CLOSED | 客户端执行 connect() |
SYN_SENT | 客户端发送 SYN |
| LISTEN | 收到客户端的 SYN | SYN_RCVD | 服务器回复 SYN+ACK |
| SYN_SENT | 收到服务器的 SYN+ACK | ESTABLISHED | 客户端回复 ACK |
| SYN_RCVD | 收到客户端的 ACK | ESTABLISHED | 连接建立完成 |
四次挥手(关闭连接)
关键原则 :主动调用
close()的一方为主动关闭方 ,另一方为被动关闭方。下面分别列出两种场景。
场景 A:客户端主动关闭(服务器被动关闭)
| 角色 | 当前状态 | 事件 / 动作 | 下一状态 | 说明 |
|---|---|---|---|---|
| 客户端(主动) | ESTABLISHED | 调用 close(),发送 FIN |
FIN_WAIT_1 | 主动发起关闭 |
| 服务器(被动) | ESTABLISHED | 收到 FIN,回复 ACK | CLOSE_WAIT | 应用层会收到 EOF |
| 客户端 | FIN_WAIT_1 | 收到 ACK(对 FIN 的确认) | FIN_WAIT_2 | 等待服务器发送 FIN |
| 服务器 | CLOSE_WAIT | 应用层处理完数据后调用 close(),发送 FIN |
LAST_ACK | 主动发送 FIN |
| 客户端 | FIN_WAIT_2 | 收到服务器的 FIN,回复 ACK | TIME_WAIT | 进入 2MSL 等待 |
| 服务器 | LAST_ACK | 收到客户端对 FIN 的 ACK | CLOSED | 彻底关闭 |
| 客户端 | TIME_WAIT | 等待 2MSL 后 | CLOSED | 防止残留报文干扰 |
场景 B:服务器主动关闭(客户端被动关闭)
| 角色 | 当前状态 | 事件 / 动作 | 下一状态 | 说明 |
|---|---|---|---|---|
| 服务器(主动) | ESTABLISHED | 调用 close(),发送 FIN |
FIN_WAIT_1 | 主动发起关闭 |
| 客户端(被动) | ESTABLISHED | 收到 FIN,回复 ACK | CLOSE_WAIT | 应用层会收到 EOF |
| 服务器 | FIN_WAIT_1 | 收到 ACK(对 FIN 的确认) | FIN_WAIT_2 | 等待客户端发送 FIN |
| 客户端 | CLOSE_WAIT | 应用层处理完数据后调用 close(),发送 FIN |
LAST_ACK | 主动发送 FIN |
| 服务器 | FIN_WAIT_2 | 收到客户端的 FIN,回复 ACK | TIME_WAIT | 进入 2MSL 等待 |
| 客户端 | LAST_ACK | 收到服务器对 FIN 的 ACK | CLOSED | 彻底关闭 |
| 服务器 | TIME_WAIT | 等待 2MSL 后 | CLOSED | 防止残留报文干扰 |
注意 :TIME_WAIT 只会出现在主动关闭方 。CLOSE_WAIT 和 LAST_ACK 出现在被动关闭方。
常见状态解释
| 状态 | 含义 | 出现位置 |
|---|---|---|
| LISTEN | 服务器监听,等待客户端连接 | 服务器 |
| SYN_SENT | 客户端已发送 SYN,等待 SYN+ACK | 客户端 |
| SYN_RCVD | 服务器收到 SYN,已回复 SYN+ACK,等待 ACK | 服务器 |
| ESTABLISHED | 连接已建立,可以传输数据 | 双方 |
| FIN_WAIT_1 | 主动关闭方已发送 FIN,等待 ACK | 主动方 |
| FIN_WAIT_2 | 主动关闭方已收到 FIN 的 ACK,等待对方 FIN | 主动方 |
| CLOSE_WAIT | 被动关闭方收到 FIN,等待应用层关闭 | 被动方 |
| LAST_ACK | 被动关闭方已发送 FIN,等待最终 ACK | 被动方 |
| TIME_WAIT | 主动关闭方收到 FIN 并回复 ACK,等待 2MSL | 主动方 |
| CLOSED | 连接完全关闭 | 双方 |
补充:
-
TIME_WAIT为什么要等 2MSL?-
保证主动关闭方发送的最后一个 ACK 能到达对方(若丢失,对方重发 FIN,自己还能响应)。
-
让本次连接的所有残留报文在网络中消失,避免干扰新连接。
-
-
CLOSE_WAIT容易出问题- 如果被动关闭方的应用层没有及时调用
close(),连接会一直卡在CLOSE_WAIT,导致文件描述符泄漏。
- 如果被动关闭方的应用层没有及时调用

详细解释:
一、前期准备(服务器端先启动)
1. 服务器端应用层
-
listenfd = socket():创建一个 监听 socket ,返回文件描述符listenfd。 -
bind(listenfd, 服务器地址端口):将listenfd绑定到服务器的 IP 和端口(例如0.0.0.0:8080)。 -
listen(listenfd, 连接队列长度):将listenfd变为被动监听状态,内核为该 socket 维护一个已完成连接队列(backlog)。
2. 服务器端 TCP 层状态
- 执行
listen()后,服务器 TCP 状态从CLOSED→LISTEN,等待客户端连接。
此时服务器阻塞在 accept() 调用上,等待客户端连接到来。
二、TCP 三次握手(建立连接)
1. 客户端应用层
-
fd = socket():创建主动 socket。 -
connect(fd, 服务器地址端口):发起连接请求,阻塞等待服务器应答。
2. 客户端 TCP 层状态
- 调用
connect()后,客户端状态:CLOSED→SYN_SENT(发送 SYN 报文)。
3. 服务器端 TCP 层
-
收到 SYN 后,状态:
LISTEN→SYN_RCVD,并回复 SYN+ACK。 -
客户端收到 SYN+ACK 后,状态:
SYN_SENT→ESTABLISHED,并回复 ACK。 -
服务器收到 ACK 后,状态:
SYN_RCVD→ESTABLISHED。
4. 应用层返回
-
客户端
connect()返回,表示连接建立成功。 -
服务器端阻塞的
accept()返回,生成一个新的已连接 socketconnfd,用于与该客户端通信。
注:
accept()返回后,服务器 TCP 层已经处于ESTABLISHED状态。
三、数据传输(可循环多次)
服务器端应用层:
-
read(connfd, buf, size):阻塞等待客户端请求数据。 -
收到数据后
read返回,处理请求 -
write(connfd, buf, size):向客户端发送应答数据。 -
继续循环
read→ 处理 →write,形成多次请求-应答(图中所示"循环多次")。
客户端应用层:
- 图中未画客户端的数据发送,但通常客户端也会
write发送请求,read接收应答。
TCP 层状态:
-
整个数据传输期间,双方 TCP 状态保持在
ESTABLISHED。 -
每次
write产生数据报文,收到ACK确认;每次read获取对方发来的数据。
图中客户端 TCP 层状态一栏写着
DATA和ACK,这并非正式状态,而是表示数据发送和确认阶段。
四、TCP 四次挥手(关闭连接)
以服务器端主动关闭为例(服务器调用 close(connfd))。
1. 服务器端应用层调用 close(connfd)
- TCP 层发送 FIN 报文,主动关闭。
2. 服务器端 TCP 状态转移
-
ESTABLISHED→FIN_WAIT_1(发送 FIN 后)。 -
收到客户端对 FIN 的 ACK 后:
FIN_WAIT_1→FIN_WAIT_2。 -
收到客户端的 FIN 后:发送 ACK,
FIN_WAIT_2→TIME_WAIT(等待 2MSL 后关闭)。 -
TIME_WAIT→CLOSED。
3. 客户端 TCP 状态转移(被动关闭)
-
收到 FIN 后:发送 ACK,
ESTABLISHED→CLOSE_WAIT(图中未显示,但标准是CLOSE_WAIT)。 -
客户端应用层调用
close()后:发送 FIN,状态CLOSE_WAIT→LAST_ACK。 -
收到服务器对 FIN 的 ACK 后:
LAST_ACK→CLOSED。
图中客户端 TCP 层状态只画到了
FIN_WAIT_2?实际上对客户端来说,被动关闭时没有FIN_WAIT_1/2,只有CLOSE_WAIT和LAST_ACK。图中可能简化了,或者画的是客户端主动关闭的情况。建议按标准理解。
五、"循环多次"的含义
-
服务器端 :在
accept()得到connfd后,在一个循环中反复read/write,处理同一条连接上的多次请求(例如 HTTP 持久连接)。 -
外层循环 :
accept()本身可以循环,接受多个客户端的连接,每个连接分配一个connfd,分别处理。
在服务器端标注了两个"循环多次":
-
内层:对同一个
connfd反复读请求 → 发应答。 -
外层:不断
accept()新连接。
六、关键总结
| 系统调用 | 作用 | 对应 TCP 状态变化 |
|---|---|---|
socket() |
创建套接字 | 不改变状态 |
bind() |
绑定地址端口 | 无状态变化 |
listen() |
变为监听套接字 | CLOSED → LISTEN |
accept() |
接受连接 | 返回已连接套接字,状态不变 |
connect() |
主动连接 | CLOSED → SYN_SENT → ESTABLISHED |
read()/write() |
读写数据 | 保持 ESTABLISHED |
close() |
关闭连接 | 触发四次挥手,状态变化如上 |
TCP 状态转换的汇总
• 较粗的虚线表示服务端的状态变化情况;
• 较粗的实线表示客户端的状态变化情况;
• CLOSED 是一个假想的起始点, 不是真实状态;
理解 TIME_WAIT 状态
现在做一个测试,首先启动 server,然后启动 client,然后用 Ctrl-C 使 server 终止,这时马 上再运行 server,结果是:

这是因为,虽然 server 的应用程序终止了,但 TCP 协议层的连接并没有完全断开,因此不 能再次监 听同样的 server 端口. 我们用 netstat 命令查看一下:

• TCP 协议规定,主动关闭连接的一方要处于 TIME_ WAIT 状态,等待两个 MSL(maximum segment lifetime)的时间后才能回到 CLOSED 状态.
• 我们使用 Ctrl-C 终止了 server, 所以 server 是主动关闭连接的一方, 在 TIME_WAIT 期间仍然不能再次监听同样的 server 端口;
• MSL 在 RFC1122 中规定为两分钟,但是各操作系统的实现不同, 在 Centos7 上 默认配置的值是 60s;
• 可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看 msl 的值;

想一想, 为什么是 TIME_WAIT 的时间是 2MSL?
• MSL 是 TCP 报文的最大生存时间, 因此 TIME_WAIT 持续存在 2MSL 的话
• 就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服 务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错 误的);
• 同时也是在理论上保证最后一个报文可靠到达(假设最后一个 ACK 丢失, 那么 服务器会再重发一个 FIN. 这时虽然客户端的进程不在了, 但是 TCP 连接还在, 仍然 可以重发 LAST_ACK);
解决 TIME_WAIT 状态引起的 bind 失败的方法
-
头文件 :
<sys/socket.h>、<netinet/in.h>、<unistd.h>(POSIX,Linux/macOS)。 -
setsockopt必须在bind之前调用。 -
Windows 环境下使用
Winsock2,代码结构类似,但需注意:-
setsockopt的 optval 类型应传const char*(或char*)。 -
最后调用
closesocket而非close,并WSACleanup()。
-
cpp
#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
int main() {
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
perror("socket");
return 1;
}
// 设置 SO_REUSEADDR,允许重用 TIME_WAIT 状态的端口
int opt = 1;
if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
perror("setsockopt(SO_REUSEADDR)");
close(sock);
return 1;
}
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8080); // 你想绑定的端口
addr.sin_addr.s_addr = INADDR_ANY; // 0.0.0.0
if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind"); // 这里通常不会再报 Address already in use
close(sock);
return 1;
}
std::cout << "Bind success (SO_REUSEADDR enabled)" << std::endl;
// ... listen, accept ...
close(sock);
return 0;
}
在 server 的 TCP 连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的
• 服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短, 但是 每秒都有很大数量的客户端来请求).
• 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃, 就需要被服务 器端主动清理掉), 就会产生大量 TIME_WAIT 连接.
• 由于我们的请求量很大, 就可能导致 TIME_WAIT 的连接数很多, 每个连接都会 占用一个通信五元组(源 ip, 源端口, 目的 ip, 目的端口, 协议). 其中服务器的 ip 和端 口和协议是固定的. 如果新来的客户端连接的 ip 和端口号和 TIME_WAIT 占用的链 接重复了, 就会出现问题.
使用 setsockopt()设置 socket 描述符的 选项 SO_REUSEADDR 为 1, 表示允许创建端 口号相同但 IP 地址不同的多个 socket 描述

理解 CLOSE_WAIT 状态
以之前写过的 TCP 服务器为例, 我们稍加修改 将 new_sock.Close(); 这个代码去掉
将 new_sock.Close(); 这个代码去掉
cpp
#pragma once
#include <functional>
#include "tcp_socket.hpp"
typedef std::function<void (const std::string& req, std::string* resp)> Handler;
class TcpServer {
public:
TcpServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
}
bool Start(Handler handler) {
// 1. 创建 socket;
CHECK_RET(listen_sock_.Socket());
// 2. 绑定端口号
CHECK_RET(listen_sock_.Bind(ip_, port_))
// 3. 进行监听
CHECK_RET(listen_sock_.Listen(5));
// 4. 进入事件循环
for (;;) {
// 5. 进行 accept
TcpSocket new_sock;
std::string ip;
uint16_t port = 0;
if (!listen_sock_.Accept(&new_sock, &ip, &port)) {
continue;
}
printf("[client %s:%d] connect!\n", ip.c_str(), port);
// 6. 进行循环读写
for (;;) {
std::string req;
// 7. 读取请求. 读取失败则结束循环
bool ret = new_sock.Recv(&req);
if (!ret) {
printf("[client %s:%d] disconnect!\n", ip.c_str(), port);
// [注意!] 将此处的关闭 socket 去掉
// new_sock.Close();
break;
}
// 8. 计算响应
std::string resp;
handler(req, &resp);
// 9. 写回响应
new_sock.Send(resp);
printf("[%s:%d] req: %s, resp: %s\n", ip.c_str(), port,
req.c_str(), resp.c_str());
}
}
return true;
}
private:
TcpSocket listen_sock_;
std::string ip_;
uint64_t port_;
};
然后我们关闭客户端程序, 观察 TCP 状态

此时服务器进入了 CLOSE_WAIT 状态, 结合我们四次挥手的流程图, 可以认为四次挥 手没有正确完成.
小结: 对于服务器上出现大量的 CLOSE_WAIT 状态, 原因就是服务器没有正确的关闭 socket, 导致四次挥手没有正确完成. 这是一个 BUG. 只需要加上对应的 close 即可解 决问题
滑动窗口
刚才我们讨论了确认应答策略, 对每一个发送的数据段, 都要给一个 ACK 确认应答. 收 到 ACK 后再发送下一个数据段. 这样做有一个比较大的缺点, 就是性能较差. 尤其是数 据往返的时间较长的时候.

既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性 能(其实是将多个段的等待时间重叠在一起了).

• 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值. 上图的窗口 大小就是 4000 个字节(四个段).
• 发送前四个段的时候, 不需要等待任何 ACK, 直接发送;
• 收到第一个 ACK 后, 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推;
• 操作系统内核为了维护这个滑动窗口, 需要开辟 发送缓冲区 来记录当前还有哪 些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉;
• 窗口越大, 则网络的吞吐率就越高

那么如果出现了丢包, 如何进行重传? 这里分两种情况讨论.
情况一: 数据包已经抵达, ACK 被丢了
这种情况下, 部分 ACK 丢了并不要紧, 因为可以通过后续的 ACK 进行确认

情况二: 数据包就直接丢了.

• 当某一段报文段丢失之后, 发送端会一直收到 1001 这样的 ACK, 就像是在提醒 发送端 "我想要的是 1001" 一样;
• 如果发送端主机连续三次收到了同样一个 "1001" 这样的应答, 就会将对应的数 据 1001 - 2000 重新发送
• 这个时候接收端收到了 1001 之后, 再次返回的 ACK 就是 7001 了(因为 2001 - 7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中;
这种机制被称为 "高速重发控制"(也叫 "快重传"
流量控制
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这 个时候如果发送端继续发送, 就会造成丢包, 继而引起丢包重传等等一系列连锁反应.
因此 TCP 支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量 控制(Flow Control)
• 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通 过 ACK 端通知发送端;
• 窗口大小字段越大, 说明网络的吞吐量越高;
• 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通 知给发送端;
• 发送端接受到这个窗口之后, 就会减慢自己的发送速度;
• 如果接收端缓冲区满了, 就会将窗口置为 0; 这时发送方不再发送数据, 但是需 要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端

接收端如何把窗口大小告诉发送端呢? 回忆我们的 TCP 首部中, 有一个 16 位窗口字段, 就是存放了窗口大小信息;
那么问题来了, 16 位数字最大表示 65535, 那么 TCP 窗口最大就是 65535 字节么? 实际上, TCP 首部 40 字节选项中还包含了一个窗口扩大因子 M, 实际窗口大小是 窗口 字段的值左移 M 位
拥塞控制
虽然 TCP 有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开 始阶段就发送大量的数据, 仍然可能引发问题.
因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络 状态下, 贸然发送大量的数据, 是很有可能引起雪上加霜的.
TCP 引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按 照多大的速度传输数据

• 此处引入一个概念称为拥塞窗口
• 发送开始的时候, 定义拥塞窗口大小为 1;
• 每次收到一个 ACK 应答, 拥塞窗口加 1;
• 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较 小的值作为实际发送的窗口;
像上面这样的拥塞窗口增长速度, 是指数级别的. "慢启动" 只是指初使时慢, 但是增长速 度非常快.
• 为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.
• 此处引入一个叫做慢启动的阈值
• 当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增 长

• 当 TCP 开始启动的时候, 慢启动阈值等于窗口最大值;
• 在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回 1;
少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;
当 TCP 通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;
拥塞控制, 归根结底是 TCP 协议想尽可能快的把数据传输给对方, 但是又要避免给网络 造成太大压力的折中方案. TCP 拥塞控制这样的过程, 就好像 热恋的感觉
延迟应答
如果接收数据的主机立刻返回 ACK 应答, 这时候返回的窗口可能比较小.
• 假设接收端缓冲区为 1M. 一次收到了 500K 的数据; 如果立刻应答, 返回的窗口 就是 500K;
• 但实际上可能处理端处理的速度很快, 10ms 之内就把 500K 数据从缓冲区消费 掉了;
• 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也 能处理过来;
• 如果接收端稍微等一会再应答, 比如等待 200ms 再应答, 那么这个时候返回的 窗口大小就是 1M;
一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络 不拥塞的情况下尽量提高传输效率; 那么所有的包都可以延迟应答么? 肯定也不是:
• 数量限制: 每隔 N 个包就应答一次;
• 时间限制: 超过最大延迟时间就应答一次
具体的数量和超时时间, 依操作系统不同也有差异; 一般 N 取 2, 超时时间取 200ms;

捎带应答
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 "一发一收" 的. 意味着客户端给服务器说了 "How are you", 服务器也会给客户端回一个 "Fine, thank you"; 那么这个时候 ACK 就可以搭顺风车, 和服务器回应的 "Fine, thank you" 一起回给客户 端

面向字节流
创建一个 TCP 的 socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
• 调用 write 时, 数据会先写入发送缓冲区中;
• 如果发送的字节数太长, 会被拆分成多个 TCP 的数据包发出;
• 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或 者其他合适的时机发送出去;
• 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
• 然后应用程序可以调用 read 从接收缓冲区拿数据;
• 另一方面, TCP 的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一 个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工
由于缓冲区的存在, TCP 程序的读和写不需要一 一匹配, 例如:
• 写 100 个字节数据时, 可以调用一次 write 写 100 个字节, 也可以调用 100 次 write, 每次写一个字节;
• 读 100 个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次 read 100 个字节, 也可以一次 read 一个字节, 重复 100 次;
粘包问题
• 首先要明确, 粘包问题中的 "包" , 是指的应用层的数据包.
• 在 TCP 的协议头中, 没有如同 UDP 一样的 "报文长度" 这样的字段, 但是有一 个序号这样的字段.
• 站在传输层的角度, TCP 是一个一个报文过来的. 按照序号排好序放在缓冲区 中.
• 站在应用层的角度, 看到的只是一串连续的字节数据.
• 那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个 部分, 是一个完整的应用层数据包. 那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界.
• 对于定长的包, 保证每次都按固定大小读取即可; 例如上面的 Request 结构, 是 固定大小的, 那么就从缓冲区从头开始按 sizeof(Request)依次读取即可;
• 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包 的结束位置;
• 对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿 自己来定的, 只要保证分隔符不和正文冲突即可);
思考: 对于 UDP 协议来说, 是否也存在 "粘包问题" 呢?
• 对于 UDP, 如果还没有上层交付数据, UDP 的报文长度仍然在. 同时, UDP 是一 个一个把数据交付给应用层. 就有很明确的数据边界.
• 站在应用层的站在应用层的角度, 使用 UDP 的时候, 要么收到完整的 UDP 报 文, 要么不收. 不会出现"半个"的情况
TCP 异常情况
进程终止: 进程终止会释放文件描述符, 仍然可以发送 FIN. 和正常关闭没有什么区别.
机器重启: 和进程终止的情况相同. 机器掉电/网线断开: 接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已 经不在了, 就会进行 reset. 即使没有写入操作, TCP 自己也内置了一个保活定时器, 会 定期询问对方是否还在. 如果对方不在, 也会把连接释放.
另外, 应用层的某些协议, 也有一些这样的检测机制. 例如 HTTP 长连接中,也会定期检测对方的状态. 例如 QQ, 在 QQ 断线之后, 也会定期尝试重新连接
TCP 小结
为什么 TCP 这么复杂? 因为要保证可靠性, 同时又尽可能的提高性能.
可靠性:
• 校验和
• 序列号(按序到达)
• 确认应答
• 超时重发
• 连接管理
• 流量控制
• 拥塞控制
提高性能:
• 滑动窗口
• 快速重传
• 延迟应答
• 捎带应答
其他:
• 定时器(超时重传定时器, 保活定时器, TIME_WAIT 定时器等
基于 TCP 应用层协议
• HTTP
• HTTPS
• SSH
• Telnet
• FTP
• SMTP
当然, 也包括你自己写 TCP
TCP/UDP 对比
我们说了 TCP 是可靠连接, 那么是不是 TCP 一定就优于 UDP 呢? TCP 和 UDP 之间的 优点和缺点, 不能简单, 绝对的进行比较
• TCP 用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景;
• UDP 用于对高速传输和实时性要求较高的通信领域, 例如, 早期的 QQ, 视频传 输等. 另外 UDP 可以用于广播;
归根结底, TCP 和 UDP 都是程序员的工具, 什么时机用, 具体怎么用, 还是要根据具体 的需求场景去判定.
用 UDP 实现可靠传输(经典面试题)
参考 TCP 的可靠性机制, 在应用层实现类似的逻辑; 例如:
• 引入序列号, 保证数据顺序;
• 引入确认应答, 确保对端收到了数据;
• 引入超时重传, 如果隔一段时间没有应答, 就重发数据;
• ......
这道题的核心思想是:在应用层模仿 TCP 的可靠性机制,为 UDP 套上一层"可靠协议" 。
虽然 UDP 本身不可靠,但你可以自己在应用层代码里实现以下关键点:
1. 引入序列号(Sequence Number)
-
每个发送的 UDP 报文都带上一个递增的序列号(比如放在应用层报文头里)。
-
作用:接收方可以按序重组 ,也能检测出丢包(比如收到 seq=1,3,就知道 2 丢了)。
2. 引入确认应答(ACK)
-
接收方收到报文后,回复一个 ACK(确认号 = 收到的序列号)。
-
作用:让发送方知道哪些报文已经被对端成功接收。
3. 引入超时重传(Timeout Retransmission)
-
发送方发送一个报文后,启动一个定时器。
-
如果定时器到期还没收到对应的 ACK,就重发该报文。
-
注意:可能会收到重复的报文(接收方需要根据序列号去重)。
4. 引入滑动窗口(可选,但常见)
-
用于流量控制:发送方根据接收方通告的窗口大小,限制未确认的数据量。
-
避免发送太快导致接收方缓冲区溢出。
5. 引入拥塞控制(更进阶)
- 慢启动、拥塞避免、快重传、快恢复等算法也可以在应用层模拟。
简单实现示意(伪代码)
cpp
发送端:
seq = 0
for each data block:
发送 (seq, data)
启动定时器
while 未收到 ack(seq) 且 未超时:
等待
if 超时:
重发 (seq, data)
else:
seq++
接收端:
expected_seq = 0
while True:
收到 (seq, data)
if seq == expected_seq:
交付给上层
回复 ACK(seq)
expected_seq++
else:
回复 ACK(expected_seq - 1) // 让发送方重传缺失的包
需要注意的问题
-
UDP 报文可能乱序,所以需要排序。
-
UDP 可能丢包,需要重传。
-
接收方需要缓存乱序到达的报文。
-
需要处理重复报文(因为重传可能导致接收方收到同一序列号的多个包)。
-
性能开销较大,但可以做到接近 TCP 的可靠性,同时保留 UDP 的一些特性(如支持广播、更低的头部开销等)。
实际上,很多可靠 UDP 协议(如 KCP、QUIC 的一部分、UDT)就是基于这些思想实现的。

