跳到主要内容

TCP 协议详解

传输控制协议(Transmission Control Protocol,简称 TCP)是互联网传输层最核心的协议。它为应用层提供面向连接、可靠、基于字节流的传输服务,是互联网能够稳定运行的基石。理解 TCP 的工作原理,对于网络编程、性能优化、故障排查都至关重要。

TCP 的核心特性

TCP 的设计目标是解决网络传输中的三大问题:可靠性、流量控制和拥塞控制。为此,TCP 具备以下核心特性:

面向连接

TCP 在传输数据之前,必须先建立连接。这就像打电话:拨号等待对方接听,确认双方都能听到对方的声音后,才开始正式通话。连接建立的过程称为"三次握手",它确保了双方的发送和接收能力都正常。

可靠传输

TCP 通过多种机制保证数据的可靠传输:

  • 确认应答:接收方收到数据后,必须发送确认(ACK)
  • 超时重传:发送方如果在规定时间内未收到确认,则重传数据
  • 序列号:每个字节都有序列号,保证数据按序到达
  • 校验和:检测数据在传输过程中是否损坏

面向字节流

TCP 将数据视为连续的字节流,而不是独立的消息。这意味着应用层交给 TCP 的数据,可能会被 TCP 分成多个段发送,也可能将多次发送的数据合并成一个段。应用层需要自己处理消息边界问题,这就是著名的"粘包"问题。

全双工通信

TCP 连接建立后,双方可以同时向对方发送数据。这就像电话通话,双方可以同时说话(虽然通常不会这么做),每个方向的传输都独立管理。

与 UDP 的对比

特性TCPUDP
连接面向连接无连接
可靠性可靠传输,保证送达尽力而为,不保证送达
顺序保证顺序不保证顺序
流量控制
拥塞控制
传输效率较低(头部 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 Port16 位源端口号,标识发送端的应用进程
Destination Port16 位目的端口号,标识接收端的应用进程
Sequence Number32 位序列号,标识本报文段所发送数据的第一个字节的序号
Acknowledgment Number32 位确认号,期望收到下一个报文段的第一个字节的序号
Data Offset4 位数据偏移,指出 TCP 首部的长度,以 4 字节为单位
Reserved4 位保留,目前置为 0
Flags8 位控制位,包含多个重要的标志位
Window16 位窗口大小,接收方的接收窗口大小
Checksum16 位校验和,检验首部和数据是否出错
Urgent Pointer16 位紧急指针,与 URG 标志配合使用

标志位(Flags)

TCP 首部包含 6 个重要的标志位,控制着连接的建立、数据传输和连接的关闭:

标志名称说明
URGUrgent紧急指针有效,表示有紧急数据需要优先处理
ACKAcknowledgment确认号有效,连接建立后所有报文都必须设置此位
PSHPush接收方应尽快将数据交付给应用层
RSTReset重置连接,用于中断异常连接
SYNSynchronize同步序列号,用于建立连接
FINFinish终止连接,用于关闭连接

常见 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=1
  • seq=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 的序列号中:

  1. 服务器收到 SYN,不存储半连接状态
  2. 服务器计算一个特殊的序列号:序列号 = hash(源IP, 目的IP, 端口, 时间戳) + MSS编码
  3. 服务器发送 SYN+ACK,使用这个特殊序列号
  4. 客户端返回 ACK 时,服务器验证序列号是否正确
  5. 如果正确,才真正分配资源建立连接

这样,服务器在收到 ACK 之前不需要分配任何资源,有效防止了 SYN 泛洪攻击。

四次挥手:关闭连接

TCP 是全双工通信,每个方向的连接需要单独关闭。关闭连接的过程称为四次挥手(Four-way Wave)。

为什么需要四次挥手

建立连接时,SYN 和 ACK 可以合并在一个报文中发送(SYN+ACK),所以只需要三次握手。但关闭连接时,FIN 和 ACK 通常不能合并:

  • 当一方发送 FIN 时,表示它没有数据要发送了,但它仍然可以接收数据
  • 另一方收到 FIN 后,可能还有数据需要发送
  • 所以另一方先发送 ACK 确认收到 FIN,等自己发送完数据后,再发送自己的 FIN

这就是为什么关闭连接需要四次挥手。

四次挥手过程

第一步:客户端发送 FIN

客户端主动关闭连接,发送 FIN 报文:

  • FIN=1, ACK=1
  • seq=u,客户端当前序列号
  • 客户端进入 FIN_WAIT_1 状态

第二步:服务器发送 ACK

服务器收到 FIN 后,发送 ACK 确认:

  • ACK=1
  • ack=u+1,确认收到客户端的 FIN
  • 服务器进入 CLOSE_WAIT 状态
  • 客户端收到 ACK 后进入 FIN_WAIT_2 状态

此时,客户端到服务器方向的连接已关闭,但服务器还可以向客户端发送数据。

第三步:服务器发送 FIN

服务器发送完所有数据后,发送 FIN 请求关闭:

  • FIN=1, ACK=1
  • seq=w,服务器当前序列号
  • 服务器进入 LAST_ACK 状态

第四步:客户端发送 ACK

客户端收到 FIN 后,发送最后的 ACK:

  • ACK=1
  • ack=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(像超时那样),而是执行快恢复:

  1. ssthresh = cwnd / 2
  2. cwnd = ssthresh + 3 * MSS(+3 是因为有 3 个重复 ACK 对应的数据已离开网络)
  3. 重传丢失的报文段
  4. 每收到一个重复 ACK,cwnd += MSS
  5. 收到新的 ACK 后,cwnd = ssthresh,进入拥塞避免

拥塞控制流程图

不同拥塞控制算法

RFC 5681 定义的是经典的 TCP Reno 算法。后来还出现了多种改进算法:

算法特点
TCP Reno经典算法,使用快重传/快恢复
TCP NewReno改进快恢复,处理多个丢失
TCP CUBICLinux 默认,适合高带宽长延迟网络
BBRGoogle 开发,基于带宽和 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 协议详解