TCP 协议详解
TCP(Transmission Control Protocol,传输控制协议)是传输层最重要的协议,提供面向连接、可靠的字节流传输服务。理解 TCP 的工作原理对于网络编程和问题排查至关重要。
TCP 概述
TCP 是互联网最基础的传输协议之一,由 RFC 793 定义。它的设计目标是适应支持多网络应用的分层协议层次结构,为应用程序提供可靠的通信服务。
TCP 的核心特性
面向连接:通信前需要建立连接,通信结束后释放连接。就像打电话,需要先拨号建立通话,结束后挂断。
可靠传输:通过确认机制、超时重传、差错校验保证数据正确到达。发送的每个字节都会被确认,丢失的数据会自动重传。
面向字节流:TCP 将数据看作无结构的字节序列,不保留报文边界。应用层发送的多次数据可能被合并发送,也可能被拆分。
全双工通信:连接双方可以同时发送和接收数据。建立连接后,双方都有发送缓冲区和接收缓冲区。
流量控制:通过滑动窗口机制,防止发送方发送过快导致接收方缓冲区溢出。
拥塞控制:根据网络状况动态调整发送速率,避免网络拥塞。
TCP 报文段格式
TCP 首部通常为 20 字节(不含选项),结构如下:
0 16 31
+----------------+--------------------------------+
| 源端口 | 目的端口 |
+----------------+--------------------------------+
| 序号 |
+------------------------------------------------+
| 确认号 |
+--------+-------+-------+------------------------+
| 数据偏移| 保留 | 标志位| 窗口大小 |
+----------------+--------------------------------+
| 校验和 | 紧急指针 |
+----------------+--------------------------------+
| 选项(可选) |
+------------------------------------------------+
字段详解
源端口和目的端口(各 16 位):标识发送方和接收方的应用进程。端口号范围 0-65535,其中 0-1023 为知名端口。
序号(32 位):数据段中第一个数据字节的序号。TCP 为每个字节编号,初始序号随机生成。
确认号(32 位):期望收到的下一个字节的序号。表示确认号之前的所有数据都已正确接收。
数据偏移(4 位):TCP 首部长度,以 4 字节为单位。最小值 5(20 字节),最大值 15(60 字节)。
保留(6 位):保留供将来使用,目前必须置 0。
标志位(6 位):
| 标志 | 名称 | 含义 |
|---|---|---|
| URG | 紧急 | 紧急指针有效 |
| ACK | 确认 | 确认号有效 |
| PSH | 推送 | 接收方应尽快交付应用层 |
| RST | 重置 | 重置连接 |
| SYN | 同步 | 发起连接 |
| FIN | 结束 | 关闭连接 |
窗口大小(16 位):接收方的接收窗口大小,用于流量控制。最大值 65535,通过窗口扩大选项可以更大。
校验和(16 位):覆盖 TCP 首部和数据,用于检测传输错误。计算时包含伪首部(源 IP、目的 IP、协议号等)。
紧急指针(16 位):与 URG 标志配合使用,指出紧急数据的结束位置。
选项(可变):常用的选项包括:
- 最大报文段长度(MSS):TCP 载荷的最大长度,通常为 MTU - 40(IP 首部 + TCP 首部)
- 窗口扩大因子:扩展窗口大小,最大可达 1GB
- 时间戳:用于计算 RTT 和防止序号回绕
- 选择确认(SACK):允许确认不连续的数据块
TCP 连接管理
三次握手建立连接
TCP 建立连接需要三次交互,称为三次握手:
客户端 服务器
| |
| SYN=1, seq=x |
|--------------------------------------->| (LISTEN -> SYN_RCVD)
| |
(SYN_SENT) |
| SYN=1, ACK=1, seq=y, ack=x+1 |
|<---------------------------------------|
| |
| ACK=1, seq=x+1, ack=y+1 |
|--------------------------------------->|
| | (ESTABLISHED)
(ESTABLISHED) |
第一次握手:客户端发送 SYN 报文,携带初始序号 x,进入 SYN_SENT 状态。
第二次握手:服务器收到 SYN,回复 SYN+ACK 报文,携带自己的初始序号 y 和确认号 x+1,进入 SYN_RCVD 状态。
第三次握手:客户端收到 SYN+ACK,回复 ACK 报文,确认号 y+1,双方进入 ESTABLISHED 状态。
为什么需要三次握手?
假设只有两次握手,客户端发送的连接请求在网络中滞留,客户端超时重发并完成通信后关闭连接。此时滞留的请求到达服务器,服务器误认为新的连接请求,建立无效连接并等待数据,浪费资源。三次握手可以防止这种情况,因为客户端不会对滞留的请求发送第三次 ACK。
SYN 泛洪攻击:
攻击者发送大量 SYN 报文但不完成第三次握手,导致服务器维护大量半开连接,耗尽资源。防御措施包括 SYN Cookie(将连接信息编码到 ISN 中)、SYN Cache(缓存半开连接)等。
四次挥手关闭连接
TCP 关闭连接需要四次交互,称为四次挥手:
客户端 服务器
| |
| FIN=1, seq=u |
|--------------------------------------->|
| |
(FIN_WAIT_1) |
| ACK=1, seq=v, ack=u+1 |
|<---------------------------------------|
| |
(FIN_WAIT_2) |
| FIN=1, ACK=1, seq=w, ack=u+1 |
|<---------------------------------------|
| |
| ACK=1, seq=u+1, ack=w+1 |
|--------------------------------------->|
| |
(TIME_WAIT) |
| |
(CLOSED) (CLOSED)
第一次挥手:客户端发送 FIN 报文,表示没有数据要发送了,进入 FIN_WAIT_1 状态。
第二次挥手:服务器收到 FIN,回复 ACK 确认,进入 CLOSE_WAIT 状态。客户端收到 ACK 后进入 FIN_WAIT_2 状态。
第三次挥手:服务器发送 FIN 报文,表示同意关闭,进入 LAST_ACK 状态。
第四次挥手:客户端收到 FIN,回复 ACK,进入 TIME_WAIT 状态。服务器收到 ACK 后进入 CLOSED 状态。
为什么需要四次挥手?
TCP 是全双工通信,每个方向的连接需要单独关闭。服务器收到客户端的 FIN 后,可能还有数据要发送,所以先回复 ACK,等数据发送完毕再发送自己的 FIN。
TIME_WAIT 状态:
主动关闭方在发送最后一个 ACK 后进入 TIME_WAIT 状态,等待 2MSL(Maximum Segment Lifetime,通常 2 分钟)后才真正关闭连接。
TIME_WAIT 的作用:
- 确保最后的 ACK 到达:如果 ACK 丢失,服务器会重发 FIN,TIME_WAIT 状态可以重发 ACK
- 让旧连接的重复数据消失:防止旧连接的延迟数据被新连接接收
大量 TIME_WAIT 连接会占用端口资源,可以通过设置 SO_REUSEADDR 选项复用端口。
连接状态转换
TCP 连接过程中的状态转换:
+---------+
| CLOSED |
+---------+
|
passive OPEN
|
+---------+
| LISTEN |
+---------+
主动打开 | 收到 SYN
| v
SYN_SENT <------- SYN_RCVD
| |
收到 SYN+ACK | 收到 ACK
| |
v v
ESTABLISHED -----> ESTABLISHED
| |
主动关闭 | 被动关闭
| |
v v
FIN_WAIT_1 CLOSE_WAIT
| |
收到 ACK | 应用关闭
| |
v v
FIN_WAIT_2 LAST_ACK
| |
收到 FIN | 收到 ACK
| |
v v
TIME_WAIT CLOSED
|
2MSL 超时
|
v
CLOSED
TCP 可靠传输机制
TCP 通过多种机制保证可靠传输。
序号与确认
TCP 为每个字节分配序号,接收方通过确认号告知发送方期望收到的下一个字节序号。这是累积确认,表示确认号之前的所有数据都已正确接收。
发送方发送:seq=1, len=100 的数据(序号 1-100)
接收方回复:ack=101(期望收到序号 101 开始的数据)
超时重传
发送方发送数据后启动定时器,如果在超时前没有收到确认,就重传数据。
超时时间(RTO)的计算:
RTO 基于往返时间(RTT)动态计算。经典算法使用加权移动平均:
SRTT = (1 - α) * SRTT + α * RTT // 平滑 RTT
RTTVAR = (1 - β) * RTTVAR + β * |SRTT - RTT| // RTT 偏差
RTO = SRTT + 4 * RTTVAR
其中 α = 0.125,β = 0.25。
Karn 算法:
计算 RTT 时不使用重传的数据包,因为无法确定确认是对原数据包还是重传数据包的响应。
快速重传
超时重传需要等待较长时间,快速重传可以在超时前触发重传。
当接收方收到失序的数据时,会立即发送重复的 ACK,确认号是期望收到的序号。发送方收到三个重复的 ACK 后,立即重传丢失的数据段,不必等待超时。
发送方发送:seq=1, seq=101, seq=201, seq=301
seq=101 丢失
接收方回复:ack=101(对 seq=1 的确认)
ack=101(重复确认,期望 seq=101)
ack=101(重复确认)
ack=101(重复确认)
发送方收到三个重复 ACK,立即重传 seq=101
选择确认(SACK)
传统 TCP 只能使用累积确认,如果中间有数据丢失,后面的数据即使收到也要重传。SACK 允许接收方告知已收到的非连续数据块,发送方只需重传丢失的部分。
SACK 需要在连接建立时通过选项协商。接收方在 ACK 中携带 SACK 选项,列出已收到的数据块。
TCP 流量控制
流量控制防止发送方发送过快导致接收方缓冲区溢出。
滑动窗口
TCP 使用滑动窗口机制实现流量控制。窗口大小表示接收方当前可以接收的数据量。
发送方缓冲区:
+-----+-----+-----+-----+-----+-----+-----+-----+
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
+-----+-----+-----+-----+-----+-----+-----+-----+
^ ^ ^
|-------已确认----------|---已发送---|--可发送--|
已确认:1-3
已发送未确认:4-5
可发送:6-7(窗口大小为 4,减去已发送未确认的 2)
不可发送:8
发送方根据接收方通告的窗口大小控制发送速率。接收方在 ACK 中携带窗口大小,发送方据此调整发送窗口。
零窗口与持续定时器
当接收方缓冲区满时,通告窗口大小为 0,发送方停止发送。接收方处理数据后,发送窗口更新通知。
如果窗口更新通知丢失,会导致死锁。发送方使用持续定时器(Persist Timer)定期发送零窗口探测报文(1 字节),触发接收方重新通告窗口大小。
糊涂窗口综合症
如果接收方每次只通告很小的窗口,或发送方每次只发送很少的数据,会导致网络效率低下。这称为糊涂窗口综合症。
解决方案:
- 接收方:不通告小窗口,等到窗口大小达到 MSS 或缓冲区一半时再通告
- 发送方:Nagle 算法,将小数据包缓存起来,累积到一定量再发送
Nagle 算法:
- 如果数据长度达到 MSS,立即发送
- 如果之前的数据都已确认,立即发送
- 否则缓存数据,等待确认到达后再发送
Nagle 算法可以减少小包数量,但会增加延迟。对于实时性要求高的应用,可以通过 TCP_NODELAY 选项禁用。
TCP 拥塞控制
拥塞控制防止过多数据注入网络,导致网络过载。与流量控制不同,拥塞控制是全局性的,考虑整个网络的状况。
拥塞控制算法
TCP 拥塞控制包含四个核心算法:慢启动、拥塞避免、快速重传、快速恢复。
拥塞窗口(cwnd):发送方维护的窗口,表示网络可以承受的数据量。发送方的实际发送窗口取拥塞窗口和接收窗口的较小值。
慢启动阈值(ssthresh):慢启动和拥塞避免的分界点。
慢启动
连接刚建立或超时后,cwnd 从 1 MSS 开始,每收到一个 ACK,cwnd 加倍(指数增长)。
cwnd = 1 MSS
发送 1 个报文段,收到 ACK,cwnd = 2
发送 2 个报文段,收到 2 个 ACK,cwnd = 4
发送 4 个报文段,收到 4 个 ACK,cwnd = 8
...
虽然叫慢启动,但实际上增长很快。慢是相对于一开始就发送大量数据而言。
当 cwnd 达到 ssthresh 时,进入拥塞避免阶段。
拥塞避免
在拥塞避免阶段,cwnd 线性增长。每经过一个 RTT,cwnd 增加 1 MSS。
cwnd = cwnd + MSS * MSS / cwnd
这样可以避免 cwnd 增长过快导致拥塞。
快速重传与快速恢复
当收到三个重复 ACK 时,执行快速重传和快速恢复:
ssthresh = cwnd / 2
cwnd = ssthresh + 3 MSS
重传丢失的报文段
每收到一个重复 ACK,cwnd 增加 1 MSS
收到新的 ACK 时,cwnd = ssthresh,进入拥塞避免
快速恢复避免了超时后 cwnd 从 1 开始的慢启动过程。
超时处理
如果超时,说明拥塞严重:
ssthresh = cwnd / 2
cwnd = 1 MSS
进入慢启动
拥塞控制状态机
超时
+----------+
| |
v |
+------+ |
| 慢启动|------+
+------+ |
| |
cwnd >= ssthresh|
v |
+----------+ |
| 拥塞避免 |----+
+----------+ |
| |
3 个重复 ACK |
v |
+----------+ |
| 快速恢复 |----+
+----------+
TCP 编程接口
Socket API
使用 TCP 进行网络编程的基本流程:
服务器端:
import socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('0.0.0.0', 8080))
server_socket.listen(5)
print('服务器启动,等待连接...')
while True:
client_socket, address = server_socket.accept()
print(f'客户端 {address} 已连接')
data = client_socket.recv(1024)
print(f'收到数据: {data.decode()}')
client_socket.send(b'Hello from server')
client_socket.close()
客户端:
import socket
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_socket.connect(('127.0.0.1', 8080))
client_socket.send(b'Hello from client')
data = client_socket.recv(1024)
print(f'收到响应: {data.decode()}')
client_socket.close()
Socket 选项
常用的 Socket 选项:
# 地址复用,允许绑定处于 TIME_WAIT 状态的地址
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 禁用 Nagle 算法,减少延迟
client_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# 设置发送和接收缓冲区大小
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 65536)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 65536)
TCP 常见问题排查
连接超时
可能原因:
- 网络不通
- 防火墙阻止
- 服务器未启动
- SYN 队列满
排查方法:
# 测试连通性
ping server_ip
# 检查端口
telnet server_ip port
nc -zv server_ip port
# 查看连接状态
netstat -an | grep port
连接重置
可能原因:
- 服务器进程崩溃
- 连接空闲超时
- 访问被拒绝
排查方法:
# 查看系统日志
dmesg | grep -i tcp
# 抓包分析
tcpdump -i eth0 port 8080
性能问题
可能原因:
- 窗口太小
- Nagle 算法延迟
- 缓冲区太小
优化方法:
# 调整内核参数
echo "net.ipv4.tcp_window_scaling = 1" >> /etc/sysctl.conf
echo "net.core.rmem_max = 16777216" >> /etc/sysctl.conf
echo "net.core.wmem_max = 16777216" >> /etc/sysctl.conf
sysctl -p
小结
TCP 是可靠传输的核心协议:
- 报文格式:序号、确认号、标志位、窗口等关键字段
- 连接管理:三次握手建立连接,四次挥手关闭连接
- 可靠传输:序号确认、超时重传、快速重传、SACK
- 流量控制:滑动窗口机制,防止接收方溢出
- 拥塞控制:慢启动、拥塞避免、快速重传、快速恢复
理解 TCP 的工作原理,有助于编写高性能的网络程序和排查网络问题。
练习
- 描述 TCP 三次握手和四次挥手的详细过程
- 解释 TIME_WAIT 状态的作用和可能的问题
- 说明 TCP 滑动窗口的工作原理
- 分析 TCP 拥塞控制的四个算法