资讯动态

TCP网络编程实战:回环测试、粘包半包与CLOSE_WAIT排查

发布时间:2026/10/20 10:46:51 来源:尧图企业网站定制
2月26日我把压箱底的网络编程笔记重新翻了出来。起因是近期要接手一个跨平台通信模块服务端、客户端和网关之间的链路需要整体梳理一遍。网络编程这门课有个特点理论听一遍觉得自己全懂了真到写代码跑线上粘包、半包、TIME_WAIT、缓冲区上限、连接超时随便哪个点没处理好程序都会死得很难看。这篇文章就是我这一天的复习记录和实操日志不是教材式的知识罗列而是我实际敲代码、抓包、踩坑的全过程。如果你正在学网络编程或者已经写过一些Socket代码但总觉得底层原理隔着一层膜完全可以按我这一天的节奏走一遍先从回环地址把基础链路跑通再逐步验证握手、挥手、分帧、调优最后落到真实网络的性能差距上。整个过程用一个Python脚本就能复现不需要额外装什么重型环境。1. 今天先从回环地址跑通一个最基础的TCP回显服务1.1 为什么拿回环实验当开胃菜很多人学网络编程的第一课就是看三次握手、四次挥手的图解看完了还是写不出一个能正常通信的程序。我今天的做法反过来先让代码跑起来再回头验证理论。回环地址127.0.0.1最大的好处是纯粹。数据包不会经过物理网卡、交换机、路由器没有跨网段的延迟和丢包干扰RTT基本在亚毫秒级。在这个环境里调通代码逻辑等于给整个程序打了一个稳定的地基。等代码确认无误后再挪到局域网、公网环境去测试遇到问题时才能把代码bug和网络环境这两种因素分开定位。1.2 最小可用的回显服务端与客户端我用Python的内置socket模块写了一个最简回显服务客户端发什么服务端原样返回什么。服务端代码长这样import socket HOST 127.0.0.1 PORT 9001 srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((HOST, PORT)) srv.listen(5) print(f[server] listening on {HOST}:{PORT}) while True: conn, addr srv.accept() print(f[server] accepted {addr}) with conn: while True: data conn.recv(1024) if not data: break conn.sendall(data) print(f[server] closed {addr})客户端代码更短import socket sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(3) sock.connect((127.0.0.1, 9001)) sock.sendall(bhello network programming) resp sock.recv(1024) print(f[client] received: {resp}) sock.close()运行结果很直观服务端打印出accepted (127.0.0.1, 53210)客户端打印出[client] received: bhello network programming。链路通了。1.3 四个API背后容易被忽略的细节第一是bind。服务端必须绑定固定端口客户端通常不需要显式bind由内核自动分配一个临时端口所以客户端连接建立后你会看到对端端口是一个五位数的随机值。第二是listen(5)里的backlog参数。它表示内核为这个监听socket维护的已完成握手、但还没被accept取走的连接队列长度。如果队列满了新的连接请求会被内核直接拒绝或丢弃这在线上就是客户端连不上服务端却没崩的经典现象。第三是accept的返回值它返回的是一个全新的socket专门用于和这个客户端通信原来的监听socket继续负责接收新连接。第四是recv返回空字节串b的含义——这表示对端已经正常关闭连接发出了FIN此时服务端必须也关闭这个socket否则连接状态就会卡在CLOSE_WAIT这个问题后面专门讲。2. 三次握手、四次挥手与那些越积越多的CLOSE_WAIT2.1 握手不是面试题专利它决定了你的连接能否建立三次握手的本质是让通信双方各自确认对方能收到我的数据和我能收到对方的数据。SYN报文里携带的初始序列号ISN是随机的目的是防止旧连接中的延迟报文被误认为是新连接的合法数据。更关键的是握手成功后才会有数据发送所以握手包的传输质量直接决定用户体验。用抓包视角看整个过程客户端发SYNseqx服务端回SYN, ACKseqy, ackx1客户端再回ACKseqx1, acky1连接进入ESTABLISHED。如果服务端的SYN队列和accept队列都满了连接请求会被丢弃或者返回RST客户端表现就是connect超时或者拒绝连接。排查这类问题时先去ss -lnt看队列长度再检查服务端代码处理连接的速度别一上来就怀疑网络。2.2 四次挥手的两个关键点主动关闭方和2MSL挥手过程比握手更容易被忽视因为它的状态机细节多。主动关闭方先发FIN被动关闭方回ACK被动关闭方把剩余数据发完后再发自己的FIN主动关闭方回最后一个ACK然后进入TIME_WAIT等待2个MSL报文最大生存时间后才彻底关闭。很多人不理解为什么TIME_WAIT要等那么久。原因有两个一是万一最后一个ACK丢了对端会重发FIN主动关闭方必须留出时间重新回ACK二是防止同一个四元组源IP、源端口、目标IP、目标端口的新连接收到旧连接延迟到达的报文。理解这一点比死背主动关闭方进TIME_WAIT要有用得多。2.3 用ss命令观察CLOSE_WAIT是怎么堆出来的我在实验里故意写过一段有问题的代码——处理完一次请求后忘记调用conn.close()while True: conn, addr srv.accept() data conn.recv(1024) # 处理数据... # 这里少写了 conn.close()客户端正常发完数据后主动关闭连接内核收到FIN后把连接置为CLOSE_WAIT但这个状态一直要等应用层调用close()才会继续走向LAST_ACK和关闭。应用不关这个连接就永远卡在这里fd和内存一直被占着。线上服务如果每秒有几百个短连接请求CLOSE_WAIT会像雪球一样越滚越大最后把进程的fd耗尽新连接全部失败。用ss -ant可以看到这样的输出State Local Address:Port Peer Address:Port LISTEN 0.0.0.0:9001 0.0.0.0:* ESTAB 127.0.0.1:9001 127.0.0.1:53210 CLOSE_WAIT 127.0.0.1:9001 127.0.0.1:53211TCP状态表大概是这么个对应关系状态含义常见出现原因LISTEN服务端等待连接正常ESTABLISHED连接已建立正常FIN_WAIT_1主动关闭方发出FIN等ACK短暂出现FIN_WAIT_2主动关闭方收到ACK等对方FIN对方迟迟不关闭就会出现CLOSE_WAIT被动关闭方收到FIN等应用调close应用代码没关socketLAST_ACK被动关闭方发出FIN等最后一个ACK短暂出现TIME_WAIT主动关闭方等待2MSL正常连接多时会明显排查CDOE_WAIT的方法很直接写一个定时任务检查ss -ant一旦CLOSE_WAIT数量持续增长直接翻业务日志看哪个逻辑分支没有关闭连接。修的时候优先用with conn:上下文管理器或者try/finally保证每条路径都会close别指望内核帮你回收。3. 粘包与半包给字节流加上消息边界3.1 TCP是水管不是快递箱把TCP想成一根水管你往一头灌水对端从另一头接水但水流会被分成什么形状完全取决于中间的过程。应用层调两次sendall(bA)和sendall(bB)对端的recv完全可能一次性读到bAB也可能一次只读到bA——这中间没有任何消息边界。所谓粘包本质是多个消息被合并到一次读取里半包则是一个消息被拆成了多次读取。两者是同一个问题的两面TCP不保证应用层消息的完整性。3.2 三种常见的分帧方案对比解决这个问题必须在应用层定义消息的边界。主流做法有三种方案实现方式优点缺点定长帧每条消息固定N字节实现最简单浪费空间无法传输任意长度数据分隔符消息末尾加特殊字符如\n、0x00简单适合文本协议二进制数据可能天然包含分隔符长度前缀固定长度的头部记录负载长度通用、可靠、适合二进制协议需要处理粘包半包代码稍复杂我实际做协议设计绝大多数场景都会用长度前缀。头部固定4个字节用大端序存一个无符号整数表示后面负载的字节数。HTTP/2这类的二进制协议本质思路也是类似的。3.3 手写一个带四字节长度头的收发函数import socket import struct def send_msg(sock: socket.socket, payload: bytes) - None: sock.sendall(struct.pack(I, len(payload)) payload) def recv_exact(sock: socket.socket, n: int) - bytes: chunks [] remaining n while remaining 0: chunk sock.recv(remaining) if not chunk: raise ConnectionError(connection closed while reading) chunks.append(chunk) remaining - len(chunk) return b.join(chunks) def recv_msg(sock: socket.socket) - bytes: header recv_exact(sock, 4) length struct.unpack(I, header)[0] return recv_exact(sock, length)这里最容易被新手忽略的就是recv_exact。很多人写完header sock.recv(4)就直接往下走以为一次能读满4个字节。实际上recv(4)的意思是最多读4个字节很可能只返回1个或者2个字节。同理读完了4字节头后续读负载也必须循环直到凑够length。我用一个1KB大小的消息做了测试客户端把数据拆成几十个sendall发送服务端用recv_msg依然能完整无误地还原粘包半包问题彻底消失。这也是生产环境里最容易被验证的可靠性设计。4. 今天实测踩中的三个坑Nagle、缓冲区与连接超时4.1 Nagle算法和那个莫名其妙的40毫秒延迟Nagle算法的作用是把多个小数据包攒在一起发送减少网络中微小报文的数量。它本身没问题但它和TCP延迟确认delayed ACK机制叠加时就会出现经典的低速请求响应场景客户端发一个小请求因为Nagle算法要等收到上一个包的ACK才发下一个服务端则故意延迟ACK期待能合并响应。两个机制互相等待就产生了接近40毫秒的停顿。在交互性强、对延迟敏感的场景比如聊天消息、交易指令、游戏操作我会在socket上直接关掉Naglesock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)做批量文件传输时反而可以开着Nagle用带宽换取报文数量减少。一句话结论默认不放NODELAY但你要知道它存在遇到诡异的小请求延迟先想到它。4.2 recv可能读到一半send也可能发不完recv(n)的行为是最多读取n字节返回值可能小于n。你以为读到了完整消息实际上只是消息的一部分这就会造成半包。同样send(data)也不会保证一次性把data发完尤其是在发送缓冲区快满、对端接收速度跟不上的时候它只返回实际写入缓冲区的字节数。所以收消息要写循环拆数据发消息要用sendall或者自己封一个发送循环。我在调试中遇到过一个很隐蔽的问题客户端发了一个2KB的包服务端用recv(1024)只读了前1KB就继续往下处理结果数据错位、协议解析失败。这个bug在回环环境下偶发放到跨主机环境就变成了高频因为真实网络的数据包到达时序更不规则。4.3 收发缓冲区与背压别让生产者的热情压垮消费者每个TCP socket在内核里都有发送缓冲区和接收缓冲区大小可以用SO_SNDBUF和SO_RCVBUF调整。如果服务端不读数据接收缓冲区会逐渐填满内核会把TCP接收窗口减小进而限制发送方的发送速率最终发送方被阻塞在send调用上。这其实是网络协议栈自带的一种背压机制在系统设计上非常有用它让生产者的速度自动匹配消费者的处理能力避免了内存无限膨胀。所以不要一看到客户端发送阻塞就认为是bug先检查对端是不是没及时读数据。反之如果业务上确实需要大吞吐又不希望被慢消费拖住就该考虑改异步IO、增大缓冲区、或者干脆做应用层的丢弃策略而不是堵在send上死等。4.4 超时与心跳别让连接死得不明不白默认情况下TCP socket在没有数据交互时是永远不超时的连接可以静默悬挂很久。TCP自带的keepalive探测在Linux上默认要2小时才开始第一次探测对应用层来说太迟钝了。我一般的做法是两层配合TCP层设置settimeout兜底应用层自己设计心跳。sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) # connect和recv都受这个超时保护 sock.connect((127.0.0.1, 9001))应用层心跳的做法是客户端每隔N秒发一个PING服务端回PONG如果客户端连续几次没收到PONG就判定连接失效主动重连。这样既能探测链路断掉的情况也能在服务端假死时快速反应。心跳间隔要按实际网络环境调局域网可以2秒一次公网建议10秒以上太频繁反而浪费带宽。5. 用tcpdump和Wireshark验证前面讲的状态机5.1 先把抓包环境准备好理论说得再多都不如亲眼看一眼报文。这一节我用tcpdump在回环网卡上抓包把之前回显服务的握手和挥手过程完整记录下来sudo tcpdump -i lo -nn -S port 9001 -w /tmp/date0226.pcap参数说明-i lo表示抓回环网卡-nn不做域名和端口名解析-S显示绝对序列号方便人工比对-w把原始报文写入文件。抓包期间重新跑一遍客户端然后按CtrlC停止抓包用Wireshark打开pcap文件查看。5.2 从报文里读出手握手的完整证据打开抓包文件按时间顺序看最前面的三个报文客户端发SYN序列号是某个随机大数例如seq3829452135服务端回SYN, ACKseq2213450411, ack3829452136客户端回ACKseq3829452136, ack2213450412注意到没有ack的值等于对方的seq加1这就是消耗掉一个序列号来确认SYN标志位的含义。再看最后四个报文客户端先发FIN, ACK服务端回ACK然后服务端发自己的FIN, ACK客户端最后回ACK——和教科书完全一致。自己抓一次包这些状态就不是死记硬背了而是亲眼见过的东西。5.3 在回环上加人为丢包观察重传过程回环环境默认零丢包想亲眼看TCP重传可以用tc工具往回环网卡上加人为干扰sudo tc qdisc add dev lo root netem loss 10% delay 20ms再跑客户端发几次数据tcpdump里就能看到TCP Retransmission提示以及重复的序列号。实验结束后一定要删掉规则sudo tc qdisc del dev lo root netem这个结论非常重要TCP所谓的可靠传输靠的是数据重传和确认机制而不是网络不会丢数据。在跨主机场景里丢包、乱序、延迟都是常态只有理解了这一点你才会在写代码时自觉地处理读超时、重试和幂等。6. 回到真实网络从回环到跨主机差距比想象中更大6.1 RTT、MTU与MSS的数字感受回环实验里RTT几乎为零而到了局域网是零点几毫秒跨地域公网则是几十到几百毫秒。每次发送的数据还会被切分成TCP段段的大小由MSS决定MSS又由MTU决定。普通以太网MTU是1500字节扣掉IP头20字节和TCP头20字节应用层每个TCP段最多携带1460字节。这意味着你调用一次sendall发送10KB数据在内核里会被拆成7个左右的TCP段分别发送。做网络编程的人必须养成这个直觉一次应用层发送不等于一次网络传输。数据会被分段、合并、重组这是分帧协议必须存在的根本原因。6.2 丢包、重传与拥塞控制的门道真实网络里丢包不可避免TCP通过确认号和重传机制保证可靠。发送方发出一个段后启动重传定时器RTO如果定时器到期没收到ACK就重传。快速重传的逻辑是收到3个重复的ACK就直接重发丢失的段不用等RTO超时。拥塞控制则决定了发送窗口的增长速度和收缩策略网络通畅时窗口呈指数增长出现丢包或网络延迟增大时窗口迅速收缩。理解这一层之后你再看到为什么公网大文件传输忽快忽慢这类问题就不会只怀疑硬件了。6.3 一天的实验下来我最想提醒新手的三件事第一调试顺序永远是回环→局域网→公网逐个环境暴露问题不要一上来就在公网环境里裸奔。第二任何阻塞IO都必须配超时否则线上事故现场无法复现你连从哪个进程开始排查都不知道。第三不要看进程活着就认定系统健康要看连接状态、fd数量、重传率这些网络层指标。这一天下来最有价值的体会是网络编程的很多所谓深坑根子上都是没把字节流模型、状态机转换和内核缓冲区机制吃透。代码出问题时第一反应应该是我要抓包看证据而不是猜。等你看过几次真实的SYN、FIN、重传报文很多网上玄乎的说法自己就能判断真假了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑