TCP协议详解:三次握手与四次挥手、状态转换、滑动窗口与拥塞控制

TCP 这东西,说白了就是网络通信的「定海神针」。我做了这么多年网络编程,踩过最多的坑就在 TCP 的状态管理上。你想想看,一个连接从建立到断开,中间要经历多少种状态?搞不清楚这些,写出来的代码迟早要出问题。

今天咱们就把 TCP 的核心机制掰开揉碎了讲。我会结合自己项目里的真实案例,帮你把这些概念彻底吃透。

三次握手:连接建立的「对暗号」过程

TCP 是面向连接的协议。什么叫面向连接?就是通信前双方得先「对上暗号」,确认彼此都在线、愿意聊、且能听懂对方的话。

这个对暗号的过程,就是经典的三次握手。

核心流程:

  1. 客户端发 SYN:客户端说「嘿,我想跟你建立连接,这是我的初始序列号 x」
  2. 服务端回 SYN+ACK:服务端说「收到,这是我的初始序列号 y,我确认收到了你的 x」
  3. 客户端发 ACK:客户端说「好,我确认收到了你的 y,咱们开始传数据吧」

为什么非得三次?两次行不行?我刚开始学的时候也这么想。后来在项目中遇到过一个诡异的问题:客户端发了个 SYN,网络延迟导致重传,结果旧的 SYN 比新的还晚到。如果是两次握手,服务端收到旧 SYN 就直接建立连接了,白白浪费资源。三次握手就能避免这种「历史连接」的干扰。

下面这张图把三次握手的完整流程画出来了,你一看就明白:

TCP 三次握手流程图 客户端 服务端 CLOSED LISTEN ① SYN=1, seq=x SYN_SENT SYN_RCVD ② SYN=1, ACK=1, seq=y, ack=x+1 ③ ACK=1, seq=x+1, ack=y+1 ESTABLISHED ESTABLISHED 开始传输数据 开始传输数据 双向数据流 三次握手确保双方都具备收发能力,并同步初始序列号

小技巧:抓包时看到 SYN 包重传,别慌。先看看 RTT(往返时间)是不是正常。我曾经遇到一个案例,客户端在深圳,服务端在法兰克福,SYN 超时时间设成了 1 秒,结果频繁重传。后来改成 3 秒,问题就解决了。

四次挥手:优雅地告别

断开连接比建立连接更讲究。因为 TCP 是双工通信,双方都得确认对方没有数据要发了,才能彻底断开。

这就是四次挥手。

挥手流程:

  1. 主动方发 FIN:说「我没数据要发了,但我还能收」
  2. 被动方回 ACK:说「知道了,但我可能还有数据要发」
  3. 被动方发 FIN:说「好了,我也没数据了,可以断了」
  4. 主动方回 ACK:说「收到,那我关闭了」

这里有个关键点:主动方收到 FIN 后,不会立刻关闭,而是进入 TIME_WAIT 状态,等 2MSL(最大报文生存时间)后才彻底关闭。为什么要等?

我遇到过这么个坑:有一次线上服务做灰度发布,老进程关闭后立刻启动新进程,结果新进程 bind 端口失败,报「Address already in use」。查了半天,原来是老进程的 TIME_WAIT 还没结束。从那以后,我养成了在代码里设置 SO_REUSEADDR 选项的习惯。

注意:TIME_WAIT 状态持续 2MSL(通常 1-4 分钟)。高并发短连接场景下,服务器上会堆积大量 TIME_WAIT 连接。我曾经在压测时见过一台机器上挂了 6 万多个 TIME_WAIT,直接把端口号耗光了。解决方案有两个:一是开启 net.ipv4.tcp_tw_reuse,二是改用长连接。

TCP 状态转换:连接的一生

TCP 连接从生到死,要经历十几种状态。我建议你把下面这张状态转换图背下来,面试常考,排障更常用。

状态 含义 常见场景
CLOSED 初始状态,没有连接 刚创建 socket 时
LISTEN 服务端等待客户端连接 调用 listen() 后
SYN_SENT 客户端发了 SYN,等待回复 调用 connect() 后
SYN_RCVD 服务端收到 SYN,发了 SYN+ACK 三次握手第二步
ESTABLISHED 连接建立成功,可以传数据 正常通信中
FIN_WAIT_1 主动方发了 FIN,等待 ACK 主动关闭第一步
FIN_WAIT_2 主动方收到 ACK,等待对方 FIN 半关闭状态
TIME_WAIT 主动方收到 FIN,等 2MSL 后关闭 主动关闭最后一步
CLOSE_WAIT 被动方收到 FIN,等待应用关闭 被动关闭第一步
LAST_ACK 被动方发了 FIN,等待最终 ACK 被动关闭最后一步

排障时最常看到的是 ESTABLISHED、TIME_WAIT 和 CLOSE_WAIT。如果 CLOSE_WAIT 数量暴涨,说明你的应用层代码没正确关闭 socket。我见过一个同事写的 HTTP 服务器,收到 FIN 后忘了调用 close(),结果 CLOSE_WAIT 堆到上千个,新连接全被拒绝。

滑动窗口与流量控制:别把对方撑死

TCP 的流量控制,说白了就是「发太快了,你慢点」。接收方会告诉发送方自己还剩多少缓冲区,发送方就按这个量来发。

这个机制叫滑动窗口。窗口大小就是接收方还能收多少数据。

滑动窗口的核心概念:

  • 已发送已确认:数据安全到达,可以丢掉了
  • 已发送未确认:还在路上,等着 ACK
  • 可发送但未发送:窗口内的空闲空间
  • 不可发送:窗口外的数据,得等窗口滑动

我做过一个文件传输工具,上传大文件时发现速度忽快忽慢。抓包一看,接收方的窗口经常变成 0。原来是接收端处理太慢,缓冲区满了。解决方案是优化接收端的处理逻辑,把 I/O 操作改成异步的。

避坑指南:我曾经遇到过一个「窗口为 0」的 bug。接收方发了窗口为 0 的报文后,发送方会定期发「窗口探测」报文。如果接收方恢复后发的窗口更新报文丢了,双方就会死等。TCP 协议里有个机制叫「持续计时器」,就是专门解决这个问题的。写代码时别把窗口探测的间隔设得太长,我一般用 500ms。

拥塞控制:别把网络堵死

流量控制管的是「接收方能吃多少」,拥塞控制管的是「网络能扛多少」。这是两个不同的维度。

TCP 的拥塞控制有四个阶段:

阶段 触发条件 行为
慢启动 连接刚建立 cwnd 从 1 开始,每收到一个 ACK 翻倍
拥塞避免 cwnd 达到 ssthresh cwnd 线性增长,每 RTT 加 1
快重传 收到 3 个重复 ACK 立即重传丢失的包,不等超时
快恢复 快重传后 ssthresh 减半,cwnd 设为新 ssthresh

慢启动这个名字其实有点误导。它启动得并不慢,指数增长很快的。我见过一个新手写的代码,把初始窗口设成了 10,结果在弱网环境下频繁触发拥塞控制,反而更慢。Linux 3.0 以后默认初始窗口是 10,但实际效果要看网络状况。

重要提醒:拥塞控制和流量控制是协同工作的。实际发送窗口 = min(接收方窗口, 拥塞窗口)。别只调一个。我曾经优化一个跨国传输的场景,光调大了接收窗口没用,还得把 ssthresh 也调大,配合 TCP 时间戳选项,才把吞吐量提上去。

嗯,TCP 的这些机制,说白了就是一套「既要快又要稳」的博弈策略。三次握手保证建立可靠连接,滑动窗口控制收发节奏,拥塞控制应对网络波动。搞懂了这些,你写网络程序时心里就有底了。