TCP 协议详解
传输控制协议(Transmission Control Protocol,简称 TCP)是互联网传输层最核心的协议。它为应用层提供面向连接、可靠、基于字节流的传输服务,是互联网能够稳定运行的基石。理解 TCP 的工作原理,对于网络编程、性能优化、故障排查都至关重要。
TCP 的核心特性
TCP 的设计目标是解决网络传输中的三大问题:可靠性、流量控制和拥塞控制。为此,TCP 具备以下核心特性:
面向连接
TCP 在传输数据之前,必须先建立连接。这就像打电话:拨号等待对方接听,确认双方都能听到对方的声音后,才开始正式通话。连接建立的过程称为"三次握手",它确保了双方的发送和接收能力都正常。
可靠传输
TCP 通过多种机制保证数据的可靠传输:
- 确认应答:接收方收到数据后,必须发送确认(ACK)
- 超时重传:发送方如果在规定时间内未收到确认,则重传数据
- 序列号:每个字节都有序列号,保证数据按序到达
- 校验和:检测数据在传输过程中是否损坏
面向字节流
TCP 将数据视为连续的字节流,而不是独立的消息。这意味着应用层交给 TCP 的数据,可能会被 TCP 分成多个段发送,也可能将多次发送的数据合并成一个段。应用层需要自己处理消息边界问题,这就是著名的"粘包"问题。
全双工通信
TCP 连接建立后,双方可以同时向对方发送数据。这就像电话通话,双方可以同时说话(虽然通常不会这么做),每个方向的传输都独立管理。
与 UDP 的对比
| 特性 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接 | 无连接 |
| 可靠性 | 可靠传输,保证送达 | 尽力而为,不保证送达 |
| 顺序 | 保证顺序 | 不保证顺序 |
| 流量控制 | 有 | 无 |
| 拥塞控制 | 有 | 无 |
| 传输效率 | 较低(头部 20 字节) | 较高(头部 8 字节) |
| 适用场景 | 文件传输、网页浏览、邮件 | 视频直播、DNS 查询、游戏 |
TCP 报文格式
TCP 报文由首部和数据两部分组成。首部最少 20 字节,包含控制连接和数据传输所需的各种字段。
报文结构
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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Offset| Res. | Flags | Window |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options (if present) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
字段详解
| 字段 | 长度 | 说明 |
|---|---|---|
| Source Port | 16 位 | 源端口号,标识发送端的应用进程 |
| Destination Port | 16 位 | 目的端口号,标识接收端的应用进程 |
| Sequence Number | 32 位 | 序列号,标识本报文段所发送数据的第一个字节的序号 |
| Acknowledgment Number | 32 位 | 确认号,期望收到下一个报文段的第一个字节的序号 |
| Data Offset | 4 位 | 数据偏移,指出 TCP 首部的长度,以 4 字节为单位 |
| Reserved | 4 位 | 保留,目前置为 0 |
| Flags | 8 位 | 控制位,包含多个重要的标志位 |
| Window | 16 位 | 窗口大小,接收方的接收窗口大小 |
| Checksum | 16 位 | 校验和,检验首部和数据是否出错 |
| Urgent Pointer | 16 位 | 紧急指针,与 URG 标志配合使用 |
标志位(Flags)
TCP 首部包含 6 个重要的标志位,控制着连接的建立、数据传输和连接的关闭:
| 标志 | 名称 | 说明 |
|---|---|---|
| URG | Urgent | 紧急指针有效,表示有紧急数据需要优先处理 |
| ACK | Acknowledgment | 确认号有效,连接建立后所有报文都必须设置此位 |
| PSH | Push | 接收方应尽快将数据交付给应用层 |
| RST | Reset | 重置连接,用于中断异常连接 |
| SYN | Synchronize | 同步序列号,用于建立连接 |
| FIN | Finish | 终止连接,用于关闭连接 |
常见 TCP 报文类型
根据标志位的组合,TCP 报文可以分为以下几种类型:
- SYN 报文:
SYN=1,用于发起连接请求 - SYN+ACK 报文:
SYN=1, ACK=1,用于确认连接请求并同意建立连接 - ACK 报文:
ACK=1,用于确认收到的数据 - FIN 报文:
FIN=1, ACK=1,用于请求关闭连接 - RST 报文:
RST=1,用于重置连接 - 数据报文:
PSH=1, ACK=1,携带实际数据
三次握手:建立连接
TCP 建立连接的过程称为三次握手(Three-way Handshake)。这个过程确保双方的发送和接收能力都正常,并同步双方的初始序列号。
为什么需要三次握手
考虑一个场景:客户端发送了一个连接请求 SYN,但这个请求在网络中滞留了很久才到达服务器。客户端超时后重发了新的连接请求,完成了通信并关闭了连接。此时,那个滞留的旧请求才到达服务器。
如果只有两次握手,服务器收到这个旧请求后会误认为客户端想建立新连接,于是发送确认并建立连接,等待客户端发送数据。但客户端根本没有发起新连接,不会理会这个确认,服务器的资源就这样被白白浪费了。
三次握手可以防止这种情况:服务器收到 SYN 后发送 SYN+ACK,客户端收到后会检查这个确认是否对应自己当前的请求。如果不是,客户端会发送 RST 拒绝,服务器就不会建立无效连接。
三次握手过程
第一步:客户端发送 SYN
客户端发送一个 SYN 报文段,主动发起连接请求:
SYN=1,表示这是一个连接请求seq=x,客户端选择一个初始序列号(Initial Sequence Number, ISN)- 客户端进入
SYN_SENT状态,等待服务器的响应
第二步:服务器发送 SYN+ACK
服务器收到客户端的 SYN 后,发送 SYN+ACK 报文段进行确认:
SYN=1,表示服务器也发起连接请求ACK=1,表示确认号有效seq=y,服务器选择自己的初始序列号ack=x+1,确认收到客户端的 SYN(确认号为客户端序列号加 1)- 服务器进入
SYN_RCVD状态
第三步:客户端发送 ACK
客户端收到服务器的 SYN+ACK 后,发送 ACK 进行确认:
ACK=1seq=x+1,客户端的序列号ack=y+1,确认收到服务器的 SYN- 客户端进入
ESTABLISHED状态 - 服务器收到 ACK 后也进入
ESTABLISHED状态
初始序列号(ISN)的选择
初始序列号不是从 0 或 1 开始的,而是根据时钟动态生成的。这是因为:
- 防止旧连接的数据干扰新连接:如果连接关闭后很快重新建立,旧连接的延迟数据包可能被误认为是新连接的数据
- 安全性考虑:如果 ISN 可预测,攻击者可以伪造 TCP 报文
RFC 793 建议基于时钟生成 ISN,每 4 微秒加 1,大约 4.55 小时才会重复。现代系统还会加入随机因素,使 ISN 更难预测。
SYN 泛洪攻击与防护
SYN 泛洪(SYN Flood)是一种经典的 DDoS 攻击方式。攻击者发送大量伪造源 IP 的 SYN 报文,服务器收到后分配资源并进入 SYN_RCVD 状态,等待客户端的 ACK。由于源 IP 是伪造的,服务器永远收不到 ACK,这些"半连接"会一直占用资源,最终导致服务器无法处理正常的连接请求。
防护措施:SYN Cookie
SYN Cookie 是一种有效的防护技术。服务器收到 SYN 后不分配资源存储连接状态,而是将连接信息加密编码到返回的 SYN+ACK 的序列号中:
- 服务器收到 SYN,不存储半连接状态
- 服务器计算一个特殊的序列号:
序列号 = hash(源IP, 目的IP, 端口, 时间戳) + MSS编码 - 服务器发送 SYN+ACK,使用这个特殊序列号
- 客户端返回 ACK 时,服务器验证序列号是否正确
- 如果正确,才真正分配资源建立连接
这样,服务器在收到 ACK 之前不需要分配任何资源,有效防止了 SYN 泛洪攻击。
四次挥手:关闭连接
TCP 是全双工通信,每个方向的连接需要单独关闭。关闭连接的过程称为四次挥手(Four-way Wave)。
为什么需要四次挥手
建立连接时,SYN 和 ACK 可以合并在一个报文中发送(SYN+ACK),所以只需要三次握手。但关闭连接时,FIN 和 ACK 通常不能合并:
- 当一方发送 FIN 时,表示它没有数据要发送了,但它仍然可以接收数据
- 另一方收到 FIN 后,可能还有数据需要发送
- 所以另一方先发送 ACK 确认收到 FIN,等自己发送完数据后,再发送自己的 FIN
这就是为什么关闭连接需要四次挥手。
四次挥手过程
第一步:客户端发送 FIN
客户端主动关闭连接,发送 FIN 报文:
FIN=1, ACK=1seq=u,客户端当前序列号- 客户端进入
FIN_WAIT_1状态
第二步:服务器发送 ACK
服务器收到 FIN 后,发送 ACK 确认:
ACK=1ack=u+1,确认收到客户端的 FIN- 服务器进入
CLOSE_WAIT状态 - 客户端收到 ACK 后进入
FIN_WAIT_2状态
此时,客户端到服务器方向的连接已关闭,但服务器还可以向客户端发送数据。
第三步:服务器发送 FIN
服务器发送完所有数据后,发送 FIN 请求关闭:
FIN=1, ACK=1seq=w,服务器当前序列号- 服务器进入
LAST_ACK状态
第四步:客户端发送 ACK
客户端收到 FIN 后,发送最后的 ACK:
ACK=1ack=w+1- 客户端进入
TIME_WAIT状态 - 服务器收到 ACK 后进入
CLOSED状态
TIME_WAIT 状态与 2MSL
客户端在发送最后的 ACK 后进入 TIME_WAIT 状态,并等待 2MSL(Maximum Segment Lifetime,报文最大生存时间)才进入 CLOSED 状态。
为什么需要等待 2MSL?
原因一:确保最后的 ACK 到达
网络是不可靠的,最后的 ACK 可能丢失。如果服务器没有收到 ACK,它会重发 FIN。如果客户端立即关闭,就无法响应重发的 FIN,服务器就无法正常关闭。
等待 2MSL 可以确保:
- 如果 ACK 丢失,服务器会在 MSL 内重发 FIN
- 客户端收到重发的 FIN 后,重发 ACK,并重新计时 2MSL
原因二:防止旧连接的数据干扰新连接
TCP 报文在网络中的最大生存时间是 MSL。等待 2MSL 可以让旧连接的所有报文都在网络中消失,不会影响可能建立的新连接。
TIME_WAIT 过多的问题
在高并发短连接的场景下,大量连接处于 TIME_WAIT 状态会占用端口资源(一个 IP 地址最多 65535 个端口)。解决方案包括:
- 开启端口复用:
SO_REUSEADDR选项 - 调低 MSL 值(不推荐,可能导致问题)
- 使用长连接代替短连接
TCP 状态机
TCP 连接从建立到关闭,会经历一系列状态转换。理解 TCP 状态机有助于诊断连接问题。
完整状态列表
| 状态 | 说明 |
|---|---|
| CLOSED | 初始状态,没有连接 |
| LISTEN | 服务器状态,等待客户端连接 |
| SYN_SENT | 客户端状态,已发送 SYN,等待 SYN+ACK |
| SYN_RCVD | 服务器状态,已收到 SYN,已发送 SYN+ACK,等待 ACK |
| ESTABLISHED | 连接已建立,可以传输数据 |
| FIN_WAIT_1 | 主动关闭方状态,已发送 FIN,等待 ACK |
| FIN_WAIT_2 | 主动关闭方状态,已收到 ACK,等待对方的 FIN |
| CLOSING | 主动关闭方状态,发送 FIN 后收到 FIN 而非 ACK |
| TIME_WAIT | 主动关闭方状态,已发送最后的 ACK,等待 2MSL |
| CLOSE_WAIT | 被动关闭方状态,收到 FIN,等待应用层关闭 |
| LAST_ACK | 被动关闭方状态,已发送 FIN,等待最后的 ACK |
状态转换图
常见状态问题排查
SYN_SENT 状态持续存在
可能原因:
- 服务器未运行或防火墙阻止
- 网络不通
- 服务器负载过高,无法处理连接请求
大量 CLOSE_WAIT 状态
可能原因:
- 应用程序未调用
close()关闭连接 - 应用程序处理太慢,来不及关闭
大量 TIME_WAIT 状态
这是正常现象,但如果过多会影响性能。可以通过调整内核参数或使用长连接来解决。
序列号与确认号
序列号(Sequence Number)和确认号(Acknowledgment Number)是 TCP 可靠传输的核心机制。
序列号的作用
TCP 给每个字节的数据分配一个序列号。报文段中的序列号表示该报文段第一个字节的序号。
例如,如果一段数据的序列号是 1000,数据长度是 100 字节,那么:
- 这段数据的第一个字节序号是 1000
- 最后一个字节序号是 1099
- 下一个报文段的序列号应该是 1100
序列号的初始值(ISN)不是从 0 或 1 开始的,而是随机生成的,这增加了安全性并防止旧数据干扰新连接。
确认号的作用
确认号表示期望收到的下一个字节的序号,同时确认之前的所有数据都已正确收到。
例如:
- 接收方收到序列号 1000-1099 的数据
- 接收方发送确认号为 1100 的 ACK
- 这表示"我已经收到了 1000-1099 的数据,请发送从 1100 开始的数据"
确认机制的特点
累积确认
TCP 使用累积确认,确认号表示之前的所有数据都已收到。例如,确认号为 1000 表示序号 999 及之前的所有字节都已收到。
延迟确认
接收方通常不会对每个报文都立即发送 ACK,而是等待一小段时间(通常 200ms-500ms),看是否有数据需要发送,如果有就捎带确认。这样可以减少网络中的 ACK 报文数量,但也会增加延迟。
选择性确认(SACK)
当出现丢包时,普通的累积确认效率较低。例如,发送方发送了 4 个报文段,第 2 个丢失,接收方只能确认第 1 个。发送方不知道第 3、4 个是否收到,可能需要重传后 3 个。
选择性确认(Selective Acknowledgment,SACK)允许接收方告诉发送方哪些数据已经收到。发送方只需要重传丢失的报文段,提高效率。
SACK 是一个 TCP 选项,需要在连接建立时协商启用。
滑动窗口与流量控制
滑动窗口是 TCP 流量控制的核心机制,它让接收方能够控制发送方的发送速率,防止接收方缓冲区溢出。
为什么需要流量控制
发送方发送数据的速率可能超过接收方的处理能力。如果接收方的缓冲区满了,新到达的数据会被丢弃,导致重传和网络资源浪费。
流量控制通过滑动窗口机制,让接收方告诉发送方自己还有多少缓冲区空间,发送方据此控制发送速率。
滑动窗口原理
滑动窗口的核心思想是:发送方在收到确认之前,可以连续发送多个报文段,但总量不能超过接收方窗口的大小。
发送方窗口示意图:
已发送并确认 已发送未确认 可发送但未发送 不可发送
|-----------------|-------------------|-------------------|-----------------|
0 1000 2000 3000 4000
|<------ 窗口大小 ------->|
窗口内的数据分为两类:
- 已发送未确认:已经发送但还没有收到 ACK
- 可发送未发送:在窗口范围内但还没有发送
窗口滑动: 当收到确认后,窗口向前滑动,新的数据进入窗口可以发送。
窗口大小字段
TCP 首部中的 Window 字段(16 位)表示接收窗口的大小,即接收方当前可用的缓冲区大小。
窗口大小以字节为单位,最大 65535 字节。如果使用窗口扩大选项(Window Scaling),可以表示更大的窗口。
零窗口与持续计时器
当接收方缓冲区满时,会发送窗口大小为 0 的 ACK,通知发送方停止发送。
发送方收到零窗口通知后,会启动持续计时器(Persist Timer),定期发送一个 1 字节的探测报文,询问接收方窗口是否恢复。接收方回复当前的窗口大小。
这样设计避免了零窗口通知丢失导致的死锁:如果接收方恢复后发送的非零窗口通知丢失,发送方会一直等待,而接收方会一直等待数据。
糊涂窗口综合症
糊涂窗口综合症(Silly Window Syndrome)是指一种低效的传输现象:接收方缓冲区只剩少量空间就通告小窗口,发送方只有少量数据就发送小报文,导致网络中充斥大量小报文。
解决方案:
接收方策略:David D. Clark 方案。接收方在缓冲区只有少量空间(比如只有 MSS 的几分之一)时,不立即通告窗口,而是等待缓冲区有足够空间后再通告。
发送方策略:Nagle 算法。发送方如果有小数据要发送,先缓存起来,等待收到之前数据的 ACK 后再发送。或者等到积累到足够多的数据(接近 MSS)再发送。
拥塞控制
TCP 的拥塞控制是为了防止过多的数据注入网络,避免网络中的路由器或链路过载。与流量控制不同,拥塞控制关注的是整个网络的状况,而不是单个接收方的处理能力。
为什么需要拥塞控制
网络的承载能力是有限的。如果所有发送方都以最大速度发送数据,网络中的路由器缓冲区会溢出,导致大量丢包。丢包又会触发重传,进一步加重网络负担,形成恶性循环,最终导致网络瘫痪(拥塞崩溃)。
TCP 的拥塞控制通过动态调整发送速率,在网络负载较轻时提高速率,在网络拥塞时降低速率,实现网络资源的公平分配。
拥塞控制的核心变量
TCP 拥塞控制使用几个关键变量:
| 变量 | 说明 |
|---|---|
| cwnd | 拥塞窗口(Congestion Window),发送方根据网络状况维护的窗口 |
| ssthresh | 慢启动阈值(Slow Start Threshold),区分慢启动和拥塞避免的界限 |
| rwnd | 接收窗口(Receiver Window),接收方通告的窗口 |
| FlightSize | 已发送未确认的数据量 |
发送方的实际发送窗口是 min(cwnd, rwnd)。
四大算法
TCP 拥塞控制包含四个相互关联的算法:慢启动、拥塞避免、快重传和快恢复。
慢启动(Slow Start)
连接刚建立时,发送方不知道网络的承载能力,需要逐步探测。慢启动阶段,cwnd 从 1 个 MSS 开始,每收到一个 ACK,cwnd 加倍(指数增长)。
初始: cwnd = 1 MSS
收到 1 个 ACK: cwnd = 2 MSS
收到 2 个 ACK: cwnd = 4 MSS
收到 4 个 ACK: cwnd = 8 MSS
虽然叫"慢启动",但实际上增长速度很快。之所以叫"慢",是与 TCP 早期版本(一开始就发送整个窗口的数据)相比。
慢启动在以下情况发生:
- 连接刚建立时
- 超时重传后
- 较长时间空闲后
当 cwnd 达到 ssthresh 时,进入拥塞避免阶段。
拥塞避免(Congestion Avoidance)
进入拥塞避免阶段后,cwnd 从指数增长变为线性增长。每经过一个 RTT,cwnd 增加 1 个 MSS。
增长公式: cwnd = cwnd + MSS * MSS / cwnd
这种保守的增长策略可以避免快速导致拥塞。
快重传(Fast Retransmit)
快重传是对超时重传的优化。当发送方收到 3 个重复的 ACK(总共 4 个相同的 ACK)时,立即重传丢失的报文段,而不必等待超时。
原理:重复的 ACK 说明接收方收到了后续的数据,只是中间某个报文段丢失了。网络仍然在工作,只是轻微拥塞,不必重新慢启动。
快恢复(Fast Recovery)
快重传后,不必将 cwnd 降为 1 MSS(像超时那样),而是执行快恢复:
ssthresh = cwnd / 2cwnd = ssthresh + 3 * MSS(+3 是因为有 3 个重复 ACK 对应的数据已离开网络)- 重传丢失的报文段
- 每收到一个重复 ACK,
cwnd += MSS - 收到新的 ACK 后,
cwnd = ssthresh,进入拥塞避免
拥塞控制流程图
不同拥塞控制算法
RFC 5681 定义的是经典的 TCP Reno 算法。后来还出现了多种改进算法:
| 算法 | 特点 |
|---|---|
| TCP Reno | 经典算法,使用快重传/快恢复 |
| TCP NewReno | 改进快恢复,处理多个丢失 |
| TCP CUBIC | Linux 默认,适合高带宽长延迟网络 |
| BBR | Google 开发,基于带宽和 RTT 探测 |
| TCP Vegas | 主动检测拥塞,在丢包前降低速率 |
TCP 选项字段
TCP 首部的 Options 字段用于扩展 TCP 功能。常见的选项包括:
最大报文段长度(MSS)
MSS(Maximum Segment Size)选项在连接建立时协商,表示 TCP 报文段中数据部分的最大长度。
MSS 的选择需要考虑:
- 不能超过路径 MTU 减去 IP 和 TCP 首部的长度
- 以太网的 MTU 是 1500 字节,IP 首部 20 字节,TCP 首部 20 字节,所以 MSS 通常是 1460 字节
合理设置 MSS 可以避免 IP 分片,提高效率。
窗口扩大选项(Window Scaling)
TCP 首部的 Window 字段只有 16 位,最大窗口为 65535 字节,在现代高带宽网络中远远不够。
窗口扩大选项通过一个移位计数器,将窗口值左移相应的位数,可以表示更大的窗口。例如,移位值为 7 时,窗口值需要左移 7 位,最大窗口可达 65535 × 128 = 8,388,480 字节。
窗口扩大选项只在 SYN 报文中发送,连接建立后不能改变。
选择性确认(SACK)
SACK 选项允许接收方告诉发送方已收到的不连续数据块。发送方据此只重传丢失的数据,而不是整个窗口的数据。
SACK 需要在连接建立时通过 SACK-Permitted 选项协商。连接建立后,接收方可以在 ACK 报文中包含 SACK 选项,列出已接收的数据块。
时间戳选项
时间戳选项包含两个时间戳:
- TS Value:发送方当前时间戳
- Echo Reply:回显的时间戳
时间戳选项有两个用途:
计算 RTT:发送方收到 ACK 后,用当前时间减去 Echo Reply,得到精确的 RTT。
防止序列号回绕(PAWS):序列号是 32 位的,高速网络中可能在短时间内回绕。时间戳可以区分新旧报文。
粘包问题
TCP 是面向字节流的协议,没有消息边界的概念。应用层发送的多个消息可能被合并成一个 TCP 报文段,也可能被拆分到多个报文段中。这就是"粘包"问题。
问题表现
发送方发送两条消息:
- 消息 1:"Hello"
- 消息 2:"World"
接收方可能收到:
- 情况 1:"HelloWorld"(两条消息合并)
- 情况 2:"Hel" + "loWor" + "ld"(拆分成多个报文)
- 情况 3:"Hello" + "World"(正好两条消息)
解决方案
固定长度
每条消息固定长度,不足的用特定字符填充。简单但浪费带宽。
# 发送方
def send_fixed_length(sock, message, length=100):
# 填充到固定长度
padded = message.ljust(length, '\0')
sock.sendall(padded.encode())
# 接收方
def recv_fixed_length(sock, length=100):
data = b''
while len(data) < length:
chunk = sock.recv(length - len(data))
if not chunk:
break
data += chunk
return data.decode().rstrip('\0')
分隔符
使用特定字符作为消息结束标志。需要处理消息中包含分隔符的情况。
# 发送方
def send_with_delimiter(sock, message, delimiter='\n'):
data = message + delimiter
sock.sendall(data.encode())
# 接收方
def recv_with_delimiter(sock, delimiter='\n'):
data = b''
while True:
chunk = sock.recv(1)
if not chunk:
break
data += chunk
if chunk == delimiter.encode():
break
return data[:-1].decode() # 去掉分隔符
长度前缀(推荐)
在消息前加上长度字段,接收方先读长度,再读取对应长度的数据。这是最常用、最可靠的方式。
import struct
# 发送方
def send_with_length(sock, message):
data = message.encode()
# 使用 4 字节表示长度
length = struct.pack('!I', len(data))
sock.sendall(length + data)
# 接收方
def recv_with_length(sock):
# 先读取 4 字节长度
length_data = b''
while len(length_data) < 4:
chunk = sock.recv(4 - len(length_data))
if not chunk:
return None
length_data += chunk
length = struct.unpack('!I', length_data)[0]
# 读取指定长度的数据
data = b''
while len(data) < length:
chunk = sock.recv(length - len(data))
if not chunk:
break
data += chunk
return data.decode()
TCP 编程实战
服务端实现
import socket
import struct
import threading
class TCPServer:
"""TCP 服务器示例"""
def __init__(self, host='0.0.0.0', port=8888):
self.host = host
self.port = port
self.server_socket = None
self.running = False
def start(self):
"""启动服务器"""
# 创建 TCP socket
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 设置地址复用,避免 TIME_WAIT 导致端口占用
self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 绑定地址和端口
self.server_socket.bind((self.host, self.port))
# 开始监听,backlog 为等待队列的最大长度
self.server_socket.listen(5)
self.running = True
print(f"服务器启动,监听 {self.host}:{self.port}")
while self.running:
try:
# 接受新连接
client_socket, client_address = self.server_socket.accept()
print(f"新连接: {client_address}")
# 为每个客户端创建新线程处理
thread = threading.Thread(
target=self.handle_client,
args=(client_socket, client_address)
)
thread.daemon = True
thread.start()
except Exception as e:
if self.running:
print(f"接受连接错误: {e}")
def handle_client(self, client_socket, client_address):
"""处理客户端连接"""
try:
while True:
# 接收消息(使用长度前缀)
message = self.recv_message(client_socket)
if message is None:
break
print(f"收到来自 {client_address} 的消息: {message}")
# 回显消息
response = f"Echo: {message}"
self.send_message(client_socket, response)
except Exception as e:
print(f"处理客户端 {client_address} 错误: {e}")
finally:
client_socket.close()
print(f"连接关闭: {client_address}")
def send_message(self, sock, message):
"""发送带长度前缀的消息"""
data = message.encode('utf-8')
length = struct.pack('!I', len(data))
sock.sendall(length + data)
def recv_message(self, sock):
"""接收带长度前缀的消息"""
# 读取 4 字节长度
length_data = self._recv_exact(sock, 4)
if not length_data:
return None
length = struct.unpack('!I', length_data)[0]
# 读取消息体
data = self._recv_exact(sock, length)
if not data:
return None
return data.decode('utf-8')
def _recv_exact(self, sock, n):
"""精确接收 n 字节数据"""
data = b''
while len(data) < n:
chunk = sock.recv(n - len(data))
if not chunk:
return None
data += chunk
return data
def stop(self):
"""停止服务器"""
self.running = False
if self.server_socket:
self.server_socket.close()
if __name__ == '__main__':
server = TCPServer()
try:
server.start()
except KeyboardInterrupt:
print("\n正在关闭服务器...")
server.stop()
客户端实现
import socket
import struct
import threading
class TCPClient:
"""TCP 客户端示例"""
def __init__(self, host='127.0.0.1', port=8888):
self.host = host
self.port = port
self.socket = None
def connect(self):
"""连接服务器"""
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 设置连接超时
self.socket.settimeout(10)
try:
self.socket.connect((self.host, self.port))
print(f"已连接到 {self.host}:{self.port}")
return True
except socket.timeout:
print("连接超时")
return False
except ConnectionRefusedError:
print("连接被拒绝")
return False
def send_message(self, message):
"""发送消息"""
data = message.encode('utf-8')
length = struct.pack('!I', len(data))
self.socket.sendall(length + data)
def recv_message(self):
"""接收消息"""
# 读取 4 字节长度
length_data = b''
while len(length_data) < 4:
chunk = self.socket.recv(4 - len(length_data))
if not chunk:
return None
length_data += chunk
length = struct.unpack('!I', length_data)[0]
# 读取消息体
data = b''
while len(data) < length:
chunk = self.socket.recv(length - len(data))
if not chunk:
return None
data += chunk
return data.decode('utf-8')
def close(self):
"""关闭连接"""
if self.socket:
self.socket.close()
if __name__ == '__main__':
client = TCPClient()
if client.connect():
try:
while True:
message = input("输入消息 (输入 'quit' 退出): ")
if message.lower() == 'quit':
break
client.send_message(message)
response = client.recv_message()
print(f"服务器响应: {response}")
finally:
client.close()
使用 Socket 调优选项
import socket
def create_optimized_socket():
"""创建优化后的 Socket"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 地址复用:避免 TIME_WAIT 导致端口无法重用
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 禁用 Nagle 算法:减少小数据包的延迟
# 适用于实时性要求高的场景(如游戏、即时通讯)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# 设置发送缓冲区大小
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 256 * 1024)
# 设置接收缓冲区大小
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 256 * 1024)
# 设置 Keep-Alive:检测死连接
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
return sock
TCP 性能优化
内核参数调优
Linux 系统提供了大量 TCP 相关的内核参数,可以根据场景进行调优。
# 查看 TCP 参数
sysctl -a | grep net.ipv4.tcp
# 常用调优参数
# 开启 SYN Cookies,防止 SYN 泛洪攻击
net.ipv4.tcp_syncookies = 1
# 缩短 TIME_WAIT 状态时间(默认 60 秒)
net.ipv4.tcp_fin_timeout = 30
# 允许 TIME_WAIT 状态的端口重用
net.ipv4.tcp_tw_reuse = 1
# 增加最大连接数
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
# 增加本地端口范围
net.ipv4.ip_local_port_range = 1024 65535
# 增加 TCP 读写缓冲区范围
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# 开启 TCP 窗口扩大选项
net.ipv4.tcp_window_scaling = 1
# 开启选择性确认
net.ipv4.tcp_sack = 1
常见性能问题
大量 TIME_WAIT 状态
在高并发短连接场景下,主动关闭连接的一方会积累大量 TIME_WAIT 状态,占用端口资源。
解决方案:
- 开启
tcp_tw_reuse - 使用连接池
- 让客户端主动关闭连接(服务端被动关闭,就不会有 TIME_WAIT)
大量 CLOSE_WAIT 状态
出现大量 CLOSE_WAIT 说明应用层没有及时关闭连接。通常是程序 bug 或处理能力不足。
解决方案:
- 检查代码是否正确关闭连接
- 增加处理能力
- 设置连接超时
连接建立慢
可能原因:
- DNS 解析慢
- SYN 重传
- 服务端处理慢
解决方案:
- 使用 IP 直连或缓存 DNS 结果
- 调整
tcp_syn_retries参数 - 优化服务端性能
TCP 故障排查
常用诊断命令
# 查看网络连接状态
netstat -ant
# 只看 TCP 连接
ss -t
# 查看特定状态的连接
ss -t state time-wait
ss -t state close-wait
# 查看连接统计
netstat -s | grep -i tcp
# 查看网卡统计
ifconfig
ip -s link
# 抓包分析
tcpdump -i eth0 port 80 -w capture.pcap
# 查看内核参数
sysctl -a | grep tcp
使用 tcpdump 分析 TCP 连接
# 抓取特定端口的 TCP 流量
tcpdump -i eth0 tcp port 80
# 只抓 SYN 包(新建连接)
tcpdump -i eth0 'tcp[tcpflags] & tcp-syn != 0'
# 只抓 FIN 包(关闭连接)
tcpdump -i eth0 'tcp[tcpflags] & tcp-fin != 0'
# 只抓 RST 包(连接重置)
tcpdump -i eth0 'tcp[tcpflags] & tcp-rst != 0'
# 显示详细信息
tcpdump -i eth0 -nn -vvv port 80
常见错误码
| 错误码 | 含义 | 可能原因 |
|---|---|---|
| ECONNREFUSED | 连接被拒绝 | 目标端口没有服务监听 |
| ETIMEDOUT | 连接超时 | 网络不通或服务端处理慢 |
| ECONNRESET | 连接被重置 | 对方发送了 RST,可能是服务崩溃或防火墙拦截 |
| EPIPE | 管道断裂 | 向已关闭的连接写数据 |
| ENOTCONN | 未连接 | 在未连接的 socket 上操作 |
总结
TCP 是互联网传输层的核心协议,提供了可靠的、面向连接的字节流传输服务。本章系统介绍了 TCP 的核心机制:
连接管理:三次握手建立连接,四次挥手关闭连接,状态机管理连接生命周期。
可靠性保证:序列号和确认号确保数据有序到达,超时重传和快重传处理丢包,校验和检测数据损坏。
流量控制:滑动窗口机制让接收方控制发送方的发送速率,防止缓冲区溢出。
拥塞控制:慢启动、拥塞避免、快重传、快恢复四大算法动态调整发送速率,避免网络拥塞。
扩展机制:MSS、窗口扩大、SACK、时间戳等选项增强了 TCP 的能力。
实践应用:理解粘包问题及解决方案,掌握 Socket 编程技巧,学会性能调优和故障排查。
TCP 的设计蕴含了深刻的工程智慧,在不可靠的网络上构建了可靠的传输服务。深入理解 TCP,对于开发高性能网络应用和解决网络问题至关重要。
[!TIP] 想了解无连接传输?请看 UDP 协议详解。想了解 TCP 如何支持 Web 应用?请看 HTTP 协议详解。