☰
TCP三次握手与四次挥手:连接生命周期状态机及排障全解析
2026/10/5 3:13:38 网站建设 项目流程

前几天在排查一台业务机的连接问题时,netstat一列出来,TIME_WAIT 状态的连接占了上千条,紧接着另一台客户端就开始报“地址已在使用”。整个排查过程让我意识到一件事:TCP 三次握手建立连接、四次挥手关闭连接,这两个过程看着是教科书里最基础的知识,但真到线上出问题时,能把状态迁移、报文时序、内核参数之间的关系理清楚的人其实不算多。

这篇文章我想把这套连接生命周期逻辑从头到尾捋一遍。TCP 是传输层的核心协议,它的可靠传输建立在连接之上,而连接从出生到销毁,靠的就是三次握手和四次挥手这两套报文交换。我会先讲状态机和整体逻辑,再逐个拆解报文和状态背后的原因,最后落到大家实际会踩的坑:TIME_WAIT 堆积、重连地址被占用、CLOSE_WAIT 泄漏、高并发连接数估算这些。

无论你是写后端服务、做嵌入式设备联网,还是用 LabVIEW、Modbus TCP 这类工控协议做上位机采集,只要程序离不开网络,这部分内容迟早能派上用场。

1. TCP 连接的生命周期:从状态机的角度看才叫真正理解

1.1 一次完整的 TCP 会话到底经历了什么

大部分人学 TCP 时喜欢背流程:先 SYN,再 SYN-ACK,再 ACK;关闭时先 FIN,再 ACK,再 FIN,再 ACK。背完就完事了,遇到线上问题照样抓瞎。我自己吃过这个亏,后来才发现,真正有用的视角是把它当成一台状态机来看,TCP 协议栈内部维护了一堆连接状态,每收到一个报文、每调用一次系统 API,连接就在这些状态之间迁移。

拿最常见的场景举例:一个客户端去连一台服务器的 8080 端口。整个过程如果落到状态机里,大致是这样一串迁移:

  1. 服务器提前创建监听 socket,进入 LISTEN(监听)状态。
  2. 客户端调用 connect,协议栈发出 SYN,客户端进入 SYN_SENT(已发送同步)状态。
  3. 服务器收到 SYN,回复 SYN-ACK,同时进入 SYN_RCVD(已接收同步)状态。
  4. 客户端收到 SYN-ACK,回复 ACK,进入 ESTABLISHED(已建立连接)状态。
  5. 服务器收到这个 ACK,也进入 ESTABLISHED 状态,双方开始传输数据。

关闭时,如果客户端主动断开:

  1. 客户端调用 close 或 shutdown,发出 FIN,进入 FIN_WAIT_1(等待对方确认)状态。
  2. 服务器收到 FIN,回复 ACK,进入 CLOSE_WAIT(等待关闭)状态,客户端收到 ACK 后进入 FIN_WAIT_2(等待对方关闭)状态。
  3. 服务器处理完剩余数据后调用 close,发出 FIN,进入 LAST_ACK(等待最后确认)状态。
  4. 客户端收到 FIN,回复 ACK,进入 TIME_WAIT(等待足够时间确保旧报文消失)状态;服务器收到 ACK 后进入 CLOSED 状态。
  5. 客户端在 TIME_WAIT 状态等待 2MSL 后,才进入 CLOSED。

这一长串状态名看着多,其实只要理解了它们各自代表“我在等什么”,就一点也不难记。比如 SYN_SENT,就是“SYN 发出去后我在等回应”;CLOSE_WAIT 就是“对端要关连接了,我这边先回应一声,但我自己的数据还没发完”。

1.2 为什么推荐用状态机而不是记死流程

因为状态机是排障的地图。netstat或ss命令打印出来的每一行都会带一个状态,你在生产环境看到大量某个特定状态聚积,基本就能定位到是哪一类问题。

比如看到大量 SYN_RCVD,说明有半连接没有完成,大概率是握手阶段被 SYN Flood 攻击或客户端不回 ACK;看到大量 CLOSE_WAIT,说明服务器端应用没有调用 close,连接资源被代码泄漏了;看到大量 TIME_WAIT,说明这一端频繁地主动断开连接,并且短时间内有大量连接结束。这些状态本身就在告诉你答案。

我在实际排障时,几乎不会先去翻业务日志,而是先执行一条ss -antp | awk '{print $1}' | sort | uniq -c | sort -rn,把状态分布统计出来。看一眼哪个状态异常,就能把排查范围缩小一大半。所以这篇文章虽然标题叫“三次握手和四次挥手”,但实际上整条线都是在讲清楚这几个状态。

2. 三次握手全过程拆解:三次对话为什么缺一不可

2.1 先抓一次包,直观看看握手的三个报文

抽象地讲报文没什么意思,我建议你亲手抓一次。最简单的方式是开两个终端,一个跑nc -l 8080监听,另一个跑nc 127.0.0.1 8080连接,同时在后台抓包:

sudo tcpdump -i any port 8080 -nn

正常会看到类似这样的三行:

23:10:01.123456 IP 192.168.1.10.50000 > 192.168.1.20.8080: Flags [S], seq 1000 23:10:01.123500 IP 192.168.1.20.8080 > 192.168.1.10.50000: Flags [S.], seq 2000, ack 1001 23:10:01.123550 IP 192.168.1.10.50000 > 192.168.1.20.8080: Flags [.], ack 2001

第一个报文是客户端发的 SYN,TCP 头部里 SYN 标志位被置 1,同时携带一个初始序列号 seq=1000。第二个报文是服务器回的 SYN-ACK,标志位是 S.,意思是 SYN+ACK,它也有自己的序列号 seq=2000,同时带了确认号 ack=1001。第三个报文是客户端回的纯 ACK,标志位是点号,确认号 ack=2001。

注意观察 seq 和 ack 的关系:ack 一定是对方的 seq 加 1。这是 TCP 确认的基本规则,因为 SYN 和 FIN 都会消耗一个序列号。意思是“我收到了你的 seq=1000,我期待的下一字节是 1001”,所以回 ack=1001。数据报文里的确认规则也类似,但没有 SYN/FIN 这个“占用一个序号”的机制,一般按字节数累加。

2.2 为什么必须是三次而不是两次:两个绕不开的核心原因

这个问题几乎每次技术面试都会问,但很多人只回答“要确认双方的收发能力”,这个答案不完整。三次握手的核心原因其实有两个。

第一个原因:防止失效的连接请求被服务器误建立连接。想象一下这个场景:客户端发了 SYN,结果网络阻塞了,客户端等不到回应,超时后重新发了一个新的 SYN。可是旧的 SYN 在网络上绕了一圈后,居然先到达了服务器。如果只有两次握手,服务器收到旧 SYN 后立刻分配资源、进入 ESTABLISHED,而客户端根本不知道这回事,它只认新 SYN 建立的连接。服务器这边就白白浪费了一个连接资源,如果这种旧报文很多,资源就被耗尽。三次握手可以从根上解决:旧 SYN 到达后服务器回 SYN-ACK,客户端发现这个连接我没发过、序列号对不上,就回 RST 或直接忽略,服务器等不到最后的 ACK,会超时释放半连接,不会误建连接。

第二个原因:让双方确认彼此的收发能力,并且同步初始序列号。两次握手只能让服务器确认“客户端能发、我能收”,无法让客户端确认“服务器能回、我能收”。更关键的是,TCP 每个方向都要独立维护序列号,如果服务器不回自己的 seq,客户端后续根本无法组装连续的数据流,因为 seq 是乱的对不上,重传和去重逻辑都会崩掉。只有通过“我的 seq 给你,你的 seq 给我,再各自确认一次收到”,双方才能在同一套序列号体系下开始数据传输。

那为什么不是四次?因为三次已经足够。你发你的序列号、我回我的序列号和确认、你再确认一次,这个三角关系把双向能力和序列号都解决了。多一次纯粹浪费往返时间,建立连接的成本会显著增加。

2.3 握手阶段的坑:半连接队列与 SYN Flood

每次握手,服务器在收到 SYN 但还没有收到最终 ACK 时,这个连接处于半连接状态,挂在内核的半连接队列里。在 Linux 上,listen socket 背后有两个队列:半连接队列(SYN Queue)和全连接队列(Accept Queue)。半连接队列存的是“SYN 收到了但三次握手还没完成”;全连接队列存的是“握手已经完成但应用还没调用 accept 取走”。

SYN Flood 攻击打的就是半连接队列。攻击者伪造海量 SYN 报文,只发不回 ACK,服务器会一直维护这些半连接,直到队列满。之后合法的 SYN 会被内核丢弃,表现为应用连接超时或直接拒绝。

排查时你可以在服务器上跑:

ss -lnt

看 Send-Q 和 Recv-Q。如果 Recv-Q 长期堆满,说明应用 accept 的速度跟不上连接建立的速度,或者根本没有调用 accept。再配合netstat -s看 SYN 相关的计数,能判断是不是被攻击。对于真被攻击的场景,可以开启 SYN cookies 来避免资源耗尽:

sysctl -w net.ipv4.tcp_syncookies=1

但注意,SYN cookies 是在队列满时才启用的兜底方案,别把它当成常规性能优化手段。

2.4 初始序列号为什么要随机化

最早期的 TCP 实现里,初始序列号是固定的。比如每次握手都从 1 开始,或者从固定的值开始,攻击者很容易伪造一个 TCP 报文打断连接。只要序列号可预测,攻击者就可以向服务器发一个假的 RST 报文,把连接“掐死”,这就是经典的 TCP 会话劫持。

现代协议栈里 ISN(Initial Sequence Number)是随机化的,Linux 用哈希函数结合时间、四元组等信息生成。所以你在抓包时看到两次握手的 seq 完全不同,这不是偶然,而是协议栈的安全机制。做网络模拟实验时也千万别把 seq 当成固定值去写死,抓包脚本里要做差值计算。

3. 四次挥手拆解:关闭连接比建立连接更考验细节

3.1 挥手流程的四个报文与状态切换

挥手过程比握手复杂,关键在于 TCP 是双工通信,两条数据通路互相独立。以客户端主动关闭为例,完整的报文序列是这样的:

  1. 客户端发 FIN,进入 FIN_WAIT_1。
  2. 服务器回 ACK,进入 CLOSE_WAIT;客户端收到 ACK 后进入 FIN_WAIT_2。
  3. 服务器把剩余数据发完后,发 FIN,进入 LAST_ACK。
  4. 客户端回 ACK,进入 TIME_WAIT;服务器收到 ACK 进入 CLOSED。

抓包时你会看到类似:

23:20:01.100000 IP 192.168.1.10.50000 > 192.168.1.20.8080: Flags [F.], seq 5001 23:20:01.100050 IP 192.168.1.20.8080 > 192.168.1.10.50000: Flags [.], ack 5002 23:20:01.700000 IP 192.168.1.20.8080 > 192.168.1.10.50000: Flags [F.], seq 6001 23:20:01.700050 IP 192.168.1.10.50000 > 192.168.1.20.8080: Flags [.], ack 6002

注意这里的时间差,第二个报文和第三个报文之间隔了大概 600 毫秒。这个间隔就是服务器处理剩余数据、准备关闭的时间。很多刚接触的人容易有一个疑惑:服务器为什么不把 ACK 和 FIN 一起发出去,把四次合并成三次?原因就在这里,服务器在回完 ACK 之后可能还有数据要发,ACK 和 FIN 在时间上天然错开。

3.2 为什么挥手几乎是必然的四次

握手时 SYN 和 ACK 可以合并成一个 SYN-ACK,核心原因是握手阶段双方都没有业务数据要携带,纯粹是在交换序列号和确认能力。但挥手不同,被动关闭的一方可能还在传输数据,它收到 FIN 只代表“对端不再发数据了”,不代表“我也立刻没有数据要发”。比如一个文件服务器,客户端发送请求后主动 close,服务器可能还要把整个文件内容发回来,需要时间。

所以四次挥手的本质是:ACK 负责回应 FIN,FIN 负责声明本方向的发送结束。两个方向独立,自然就拆成了两组。你可以把它类比成离职交接:你提交离职申请(FIN),主管回一句“收到”(ACK),但他不能立刻批准你走,还得安排人跟你做工作交接。等工作交接完成,主管才批流程(FIN),你再确认一次(ACK)。

有一个值得提的细节:被动关闭方如果把回 FIN 的操作拖久了,主动方会一直停在 FIN_WAIT_2 状态。这个状态受内核参数 tcp_fin_timeout 控制,默认大约 60 秒。如果 60 秒内对方还不发 FIN,连接会被强制关闭,这就是为什么 FIN_WAIT_2 大量堆积时,通常是被动方代码里有资源泄漏、close 一直没执行。

3.3 TIME_WAIT 为什么必须等 2MSL:最容易被误解的状态

四次挥手结束后,主动关闭方并不会立刻进入 CLOSED,而是停留在 TIME_WAIT 状态,等待约 2MSL 才彻底释放。MSL 全称是 Maximum Segment Lifetime,即报文段在网络中的最大存活时间。RFC 793 的建议值是 2 分钟,Linux 上常见实现通常在 30 秒到 60 秒左右,不同内核版本略有差异。

设计这个等待有两个作用。第一个作用是保证最后一个 ACK 能到达对方。你想一下,如果第四个报文丢失了,被动方收不到最后的 ACK,它会认为自己的 FIN 没被收到,于是超时重发 FIN。主动方如果已经进入 CLOSED,看到新的 FIN 会不知所措,根本没法回复 ACK。而有了 TIME_WAIT 缓冲,对方重发的 FIN 到达后,这边还能基于原来的状态重新回一个 ACK,完成最后的确认。

第二个作用,是让旧连接中还在网络上漂移的报文自然消亡。如果主动方立即关闭端口,网络里延迟到达的旧数据包就可能被新建立的连接误读为有效数据,导致数据混乱。等上 2MSL,能保证所有旧报文要么已经到达被处理,要么已经在网络里死亡。你可以把它理解成搬进新家前先给上一任租客留一段清空快递的时间,避免东西串门。

正是因为这个状态,主动关闭方在连接结束后会短暂占用一个四元组。大量短连接频繁主动断开,就会在服务器上堆积海量 TIME_WAIT。这不是协议栈出问题,而是协议为了可靠性做的权衡。但如果你负责维护的服务端是被动关闭方,反而不会产生 TIME_WAIT,这个特性后面我们还会用到。

3.4 半关闭与 shutdown 的实战意义

说到四次挥手,必须讲一下半关闭。很多人以为 close 就是关闭连接的唯一方式,其实 close 一调用,马上就会尝试发送 FIN,并且把本地描述符彻底释放。而 shutdown 可以选择只关闭一个方向,比如调用 shutdown(sock, SHUT_WR) 之后,发送方向关闭、协议栈会发出 FIN,但接收方向仍然开着,还能继续读数据。

这个能力在实际业务里非常有用。比如客户端发完请求后调用 shutdown(SHUT_WR),服务器读到 EOF 就知道“对方请求发完了”,可以开始响应,而不需要额外约定一个报文结束标志。再比如一些命令通道协议,客户端发完指令后半关闭,服务器可以持续推送结果,客户端只收不发。

我在项目里见过不少踩坑的,就是用了 shutdown 之后忘了 close,fd 泄漏。shutdown 只是断了一个方向的数据流,并没有释放 socket 资源,最终还是要 close。这是很常见的低级但致命的错误。

3.5 挥手卡住的两个典型异常:CLOSE_WAIT 和 FIN_WAIT_2

CLOSE_WAIT 堆积是我在线下排查中最常见的问题,几乎都是代码问题。正常情况下,服务器收到客户端的 FIN 后会回 ACK,然后进入 CLOSE_WAIT,此时如果应用逻辑及时调用 close,协议栈就会发 FIN,很快离开这个状态。但如果代码里忘记 close,或者因为线程池阻塞、锁竞争等原因导致 close 一直没执行,CLOSE_WAIT 就会堆积。它不像 TIME_WAIT 那样过一会自己消失,除非进程重启或者手动处理,否则会一直占着 fd,直到 fd 耗尽。

排查命令很简单:

ss -antp | grep CLOSE_WAIT | wc -l

如果这个数字持续增长而不是归零,基本可以断定是被动关闭方有泄漏。顺着进程 PID 去找对应的代码栈,十有八九是异常分支里没有执行 close 或者没有 finally 释放资源。

与之相对的 FIN_WAIT_2 堆积,表现为主动关闭方一直等不到对方的 FIN。常见原因是对端应用不关闭连接,或者对端内核认为本端已经不应该再发数据而把连接丢弃了。如果你看到 FIN_WAIT_2 大量堆积,可以去查对端是不是进程卡死,也可以观察是否触发了 tcp_fin_timeout 强制清理。这个状态下的连接一般不会永久存在,但大量出现说明对端行为不正常。

4. 连接管理的底层维度:四元组、端口号与超时重传

4.1 什么决定了一条 TCP 连接的唯一性

TCP 连接不是用端口号单独标识的,而是用四元组:源 IP、源端口、目的 IP、目的端口。服务器可以有一个固定监听端口,比如 8080,但它可以同时维护成千上万条连接,每条连接的源 IP 和源端口各不相同。

端口号的范围是 0 到 65535,其中 0 到 1023 是特权端口,通常需要 root 权限才能监听;1024 到 49151 是注册端口;49152 到 65535 是动态或私有端口,客户端发起连接时操作系统一般从这段范围里挑一个作为源端口。这也是“一台客户端机器到同一台服务器的同一个端口最多能建立多少连接”的理论瓶颈所在——如果只看四元组,最多约 6.5 万个。当然实际还会受 fd、内存、绑定策略限制,远到不了这个上限。

我见过一个典型的误会:有人担心自己服务器监听 8080,会不会只能处理 6 万多个连接。其实不会,因为服务器视角看,源 IP 可以来自很多不同的客户端,每个客户端可以有不同的源端口,四元组的组合空间很大。真正受限的往往是内存和 fd 上限,而不是监听端口本身。要验证这一点,你可以做一次 tcp 单连接实验:只用一个客户端连着服务器,然后不断创建新连接,把状态一列,观察端口消耗和连接数。

4.2 连接建立不起来的超时与重传逻辑

三次握手的报文也是会丢的。TCP 协议栈在发出 SYN 之后,如果一段时间内没收到 SYN-ACK,会启动重传。Linux 下这个重传次数由 tcp_syn_retries 控制,默认大概是 6 次,总耗时接近 1 到 2 分钟。所以你写客户端代码时,如果 connect 一直阻塞,不要以为是程序卡死,它很可能正在做 SYN 重传,等待网络恢复后完成握手。

重传间隔是退避的:第一次约 1 秒,第二次约 2 秒,第三次约 4 秒,指数增长。这其实和 TCP 拥塞控制的基本思路一致,为了不在网络上放大拥塞。排查这类问题时,可以用 tcpdump 抓包看 SYN 是否持续重发。如果一直有 SYN 出去但没有 SYN-ACK 回来,说明中间网络丢包或者对端端口不可达;如果 SYN 根本没出去,说明本地路由有问题或者防火墙直接丢弃了。

另一个容易被混淆的概念是 dup ack 快速重传机制,那是数据传输阶段的事——接收方发现收到的数据包序号不连续,会连续回重复的 ACK 来触发发送方快速重传丢失的报文。它和握手挥手没有直接关系,但线上排查时经常有人把“TCP 重传多”和“连接建立失败”混为一谈。记住:连接阶段看 SYN/FIN 的丢失,数据传输阶段才看 dup ack。

4.3 TCP 与 UDP 的对比:为什么 TCP 要搞得这么复杂

很多人学习时会不理解,TCP 为什么要做连接建立、序列号、确认重传、流量控制、拥塞控制这一整套复杂机制。对比一下 UDP 就清楚了,UDP 是无连接不可靠协议,报文发出去就不管了,没有确认、没有重传、没有顺序保证、没有拥塞控制。UDP 头部只有 4 个字段共 8 字节,TCP 头部至少 20 字节。

我做了一个简易的对比表,方便项目选型时参考:

维度TCPUDP
连接性面向连接,需三次握手无连接,直接发数据
可靠性确认、重传、有序不保证送达和顺序
流量控制有滑动窗口机制无
拥塞控制有慢启动、拥塞避免等无
头部开销20 字节起8 字节
典型场景网页、文件传输、数据库DNS、音视频流、游戏同步

你可以把 TCP 想成挂号信,每一封都有回执、有编号、投递失败会重发;UDP 就是明信片,寄出去就不管了,对方收没收到完全看你运气。所以如果不是对可靠性有硬性要求,能做 UDP 就别硬上 TCP,因为连接管理的开销和拥塞控制会带来延迟。反过来也一样,做应用请求响应时如果用 UDP,就得自己在应用层实现确认和重传,这是很麻烦的事。

4.4 协议栈参数:Linux 和 Windows 上到底有哪些可以调

遇到连接问题,大家第一反应是调内核参数,但很多参数不是随便开的。Linux 上常见的几个:

  • net.ipv4.tcp_syn_retries:控制 SYN 重传次数,默认 6,调小可以让握手失败快速返回。
  • net.ipv4.tcp_max_syn_backlog:半连接队列大小,调大可以扛一波瞬时连接高峰。
  • net.ipv4.tcp_fin_timeout:控制 FIN_WAIT_2 的超时时间,不是 TIME_WAIT 的时长,别记错了。
  • net.ipv4.tcp_tw_reuse:允许处于 TIME_WAIT 的连接用于新的连接请求,只对主动发起方有效。开启后可以缓解客户端大量 TIME_WAIT 导致的端口不足。
  • net.ipv4.tcp_tw_recycle:老版本内核里的参数,对 NAT 环境有严重副作用,很多新版内核已经移除,不建议再折腾它。
  • net.ipv4.tcp_keepalive_time:TCP 保活探测开始时间,默认 7200 秒,调小可以让死连接更快被探测出来。

Windows 上可以通过命令查看全局 TCP 配置:

netsh interface tcp show global

这个命令可以显示自动调优级别、接收窗口大小、时间戳是否开启等。Windows 的动态端口范围也可以用命令调整:

netsh int ipv4 set dynamicport tcp start=10000 num=55536

当客户端大量主动连接服务器后断开,出现端口号耗尽时,调整动态端口范围是一种比较直接的缓解手段。

5. 从原理回到工程实践:三个高频问题完整复盘

5.1 客户端重连时报“地址已在使用”到底怎么解

有个非常典型的场景:Java 客户端连接服务器,服务异常断开后,客户端程序立刻重连,结果抛出java.net.BindException: Address already in use: connect。这个问题的根子就是四元组的 TIME_WAIT 状态。

如果你在客户端代码里显式绑定了固定端口,比如new Socket("127.0.0.1", 8080, "127.0.0.1", 20000),那么每次连接使用的源端口都是 20000。第一次连接主动断开后,那个四元组会进入 TIME_WAIT,持续约 60 秒。在这段时间里用同样的源端口去连接同一个目标端口,内核就不会允许复用,直接报地址在使用。

解决办法有几种。最彻底的是客户端不要绑定固定端口,让操作系统自动分配动态端口,这样每次连接一般不会撞车。第二种是给 socket 设置 SO_REUSEADDR,允许立即重用处于 TIME_WAIT 的四元组。第三种是在服务端启用tcp_tw_reuse,但这只对客户端主动发起的连接场景有效。实际项目里,更好的办法是不要让客户端这么频繁地重连,做成长连接,或者重连前加一点退避时间。

我还见过一种更隐蔽的报错,不是客户端,而是服务器重启时报BindException: Address already in use: bind。这是因为旧连接还处于 TIME_WAIT,监听端口还没完全释放。这种情况直接在监听 socket 上设置 SO_REUSEADDR 就能解决,几乎所有的 TCP 服务器代码都应该设置这个选项。

5.2 Nginx 反向代理的 TCP 最大连接数,瓶颈根本不在 Nginx

Nginx 做 TCP 反向代理时,一个客户端连接进来,Nginx 还要去连后端服务,所以一条客户端链路实际占用两个 socket:客户端到 Nginx,Nginx 到后端。有人问 Nginx 最大能承担多少连接,答案不是 Nginx 软件本身决定的,而是由三个硬性条件决定的。

第一个是文件描述符上限。Linux 下每个 socket 都是一个文件描述符,所以整个进程能打开多少文件就限制了多少连接。通过ulimit -n可以查看用户级限制,Nginx 配置里可以用worker_rlimit_nofile提高进程级限制。第二个是 worker 的 event loop 能力,Nginx 的每个 worker 可以支撑数万并发连接,配置里worker_connections也会限制每个 worker 的连接数。第三个是内存,每个 TCP 连接有内核 socket 缓冲区,连接多起来,内存消耗会非常可观。

大致估算方法:设 worker 数为 4,每个 worker 允许 10240 个连接,那么理论上最多 40960 个并发连接,然后再乘上系统 fd 上限符不符合。但这是非常粗略的估算。实际生产里观察指标很简单:连接数上升到一定程度后,如果出现Too many open files错误,就是 fd 不够;如果内存吃掉大量,但 fd 还远没到上限,可能是每个连接的缓冲区设置过大。

5.3 长连接还是短连接:工控与嵌入式的现实选择

很多工控场景对 TCP 的理解停留在“能连上就行”,但在 Modbus TCP、PLC 通信、传感器上报这些场景里,连接策略往往比业务逻辑更影响稳定性。

比如三菱 FX5U 做 Modbus TCP 主站时,PLC 作为客户端去连上位机或网关。如果每次数据采集都新建连接、传输完就断开,短时间内可能没问题,长时间跑下来,PLC 一侧的端口就会逐渐耗尽,更麻烦的是网络里的中间设备还要处理大量 TCP 连接的建立和销毁。这种情况下应该做长连接,把 Modbus TCP 请求复用在同一条连接上,配合应用层的心跳或者 TCP 保活机制及时发现通信中断,然后再重连。

嵌入式那边更明显。我调试 ESP01S 这类的 WiFi 模块时,它有内置 TCP 协议栈,但如果每次发一条消息就 AT+CIPSTART 建连、发完再 AT+CIPCLOSE 断开,模块很快会出现连接失败或响应变慢。原因是模块作为主动断开方,会积累很多 TIME_WAIT,而这类小模块的资源非常有限。我的经验是尽量保持一条连接持续发送,或者让服务器主动断开连接,这样模块就不需要承担 TIME_WAIT 的负担。

LabVIEW 上位机与 NI 实时机之间做 TCP 交互时,如果想查连接上的信息量,除了在 Windows 资源监视器里看 TCP 连接的收发字节数,也可以在代码里统计调用 TCP Read 的累计字节数,更直接的方式是在中间链路上加一个抓包点,用 Wireshark 的tcp.len > 0过滤条件统计实际业务报文大小。NI 实时机上一般也能执行ss -t看连接状态。

5.4 用最小代码把状态机跑起来:Python 和 C 的对照

说了这么多原理,还是动手跑一遍最实在。最小可运行的实验可以这样:先起一个监听 socket,再起一个客户端连接,每执行一步就用ss -antp | grep 8080看状态变化。

Python 版:

import socket # server server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 8080)) server.listen(5) # client client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect(("127.0.0.1", 8080)) conn, addr = server.accept() # 观察状态:LISTEN, SYN_SENT, SYN_RCVD, ESTABLISHED client.close() conn.close()

每次调用 socket API,都能对上状态机里的一次迁移:listen()进入 LISTEN,connect()发出 SYN 进入 SYN_SENT,accept()从全连接队列取走一条 ESTABLISHED 连接,close()触发 FIN 流程。

C 语言实现时对应的 API 映射也很清晰:socket()创建套接字、bind()绑定地址、listen()进入监听、accept()接受连接、connect()发起连接、close()关闭套接字。理解了 API 和状态机的关系,写网络程序时就不会再凭着感觉来了。很多人问 C 语言怎么写 TCP 客户端,核心其实就这几步,难点从来不在 API 本身,而在于理解这些调用背后对应的协议行为。

6. 现场排障速查表与个人心得

6.1 TCP 常见状态问题速查表

很多时候线上出了问题,第一条命令就能顶大半天的排查。把状态和排查动作对应起来,效率会高很多:

状态含义常见原因排查动作
SYN_SENT已发出 SYN,等待 SYN-ACK对端不可达、丢包、防火墙拦截tcpdump 看 SYN 是否重传;ping/端口连通性
SYN_RCVD已收到 SYN,等待 ACK半连接队列满、SYN Flood看 tcp_max_syn_backlog;开启 syncookies
ESTABLISHED连接已建立正常状态关注数量是否异常增长导致 fd 耗尽
FIN_WAIT_1已发出 FIN,等待 ACK正常流程中的短暂状态大量堆积说明对端不回复,抓包看 ACK 是否回来
FIN_WAIT_2已收到 ACK,等待对端 FIN对端应用不关闭查被动方代码是否泄漏 close
CLOSE_WAIT已收到 FIN,等待本地 close被动关闭方应用忘记 close顺进度查代码,这是 fd 泄漏主凶
LAST_ACK已发出 FIN,等待最后 ACK正常流程中的短暂状态大量堆积说明最后的 ACK 丢失,排查中间设备
TIME_WAIT等待 2MSL 后释放主动关闭方正常状态数量大说明短连接多,考虑长连接或调参
CLOSED连接完全关闭正常最终状态无需处理

6.2 抓包观察与线上快速验证技巧

抓包是理解 TCP 最直接的手段。抓握手包可以用:

sudo tcpdump -i any tcp port 8080 -nn -S

-S显示绝对序列号,方便对照。Wireshark 里过滤tcp.flags.syn == 1 && tcp.flags.ack == 0可以只看 SYN 请求,过滤tcp.flags.fin == 1只看 FIN。想跟踪某一整条连接,右键“追踪 TCP 流”可以汇总这个连接上的所有报文。

还有一个小技巧:做抓包实验时最好用隔离的测试环境,关掉浏览器、即时通讯这类会乱发数据的程序,只保留你的测试客户端,否则抓包里混着一堆后台连接,很难看清。这也是我为什么强调用最小实验脚本的原因,干扰越少越容易看到纯粹的现象。

6.3 踩过几次坑之后,我总结的几个原则

这些年排查 TCP 连接问题,最重要的体会是:先看内核状态,再看应用日志。应用日志记录的往往是业务层的失败,而内核的 TCP 状态能告诉你失败发生在协议栈的哪一步。比如客户端报“连接超时”,去看对端有没有 SYN_RCVD,有的话说明报文到了对端,只是握手没完成;没有的话可能是路由或防火墙的问题。这几步就能缩小一大半排查范围。

第二个原则是:内核参数不是越高越好。我见过有人为了消灭 TIME_WAIT,把tcp_tw_recycle也开了,结果在 NAT 环境下产生了大量异常断连。老版本的tcp_tw_recycle会破坏 NAT 后面多个客户端的序列号抑制逻辑,很多内核版本已经悄悄废弃了它。调参之前先搞明白参数解决什么问题、又会带来什么问题,千万别为了一时的指标好看把协议的底线牺牲掉。

第三个原则是:TIME_WAIT 不可怕,CLOSE_WAIT 才是需要警惕的。TIME_WAIT 是协议栈为了安全正常等待,只要数量不导致端口耗尽,一般不用太紧张;而 CLOSE_WAIT 长期堆积几乎都是上层应用泄漏,会实打实地拖垮服务,必须当成告警来处理。

第四个原则是:抓包永远比猜测高效。很多连接问题,尤其是跨设备的场景,两端看起来都没问题,但就是连不上,中间的网络设备做了什么只有报文能告诉你。我最后再分享一个自己的习惯:现在只要做一个新的网络方案,我都会先把状态机画出来,把期望的时间点标清楚,再照着抓包去验证。这个习惯帮我省了很多“看起来连上了但数据传不了”这类莫名其妙的问题。每次画状态机,其实就是在逼自己回答一个问题:这条连接的每一步,我到底知道不知道它在等什么。把这个问题想清楚,TCP 对你来说就不再是黑盒了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询