简介:这份PDF文档聚焦TCP与UDP Socket编程,面向具备Python语法基础和简单网络概念的学习者,适合课程配套实验或自学实践。文档从PyCharm安装与环境配置讲起,完整梳理Socket开发流程,重点演示UDP套接字的数据发送接收、超时设置以及Ping应用中的丢包模拟;同时给出TCP客户端与服务端的连接创建、数据收发和关闭套接字的全流程代码。读者按步骤操作,可以独立写出UDP Pinger客户端和TCP通信程序,直观对比两种协议在连接建立、可靠性、传输效率等方面的差异。文中提供可直接运行的参考代码和关键注释,便于排查常见错误。资源仅包含1个PDF文件,压缩包体积735KB,内容精炼、章节清晰,目前已吸引170人学习,可作为实验报告参考或教学演示材料,帮助读者在较短时间内掌握网络编程的核心方法,并为后续深入学习奠定基础。
1. TCP和UDP在Python里,差的不是API,是数据边界
接手过一个用Python写的数据采集服务,局域网里跑得好好的,一上跨网段就“玄学”断连。后来确认是TCP连接被防火墙静默重置,而对面设备只支持UDP。这件事让我意识到:TCP与UDP的Socket编程,表面是同一个socket模块的几句调用,实际是两个完全不同的调试世界。TCP有严格连接状态、有重传、有粘包问题;UDP无连接、无边界之外的任何保证。这篇笔记面向要用Python做数据采集、设备通信、后端消息转发的从业者,按“先立规则、再写代码、最后排坑”的顺序,把可直接照抄的实现和必须知道的参数陷阱一起讲清楚。适合新手照着写,也适合熟手确认自己没踩漏:是否处理了半包、心跳、端口复用、UDP无回包这三种最容易翻车的场景。
2. 先跑通TCP:用Python验证三次握手,并拆解阻塞模型
写TCP socket代码前,需要把三个状态搞清楚:socket()只是拿到一个文件描述符,connect()返回时TCP栈已经走完三次握手,accept()拿到的连接则是已经完成握手的成品。很多人把accept看成“创建连接”,其实它更像“从队列里取出连接”。我先写最小实现,再讲状态和超时。
2.1 最小服务端:bind、listen、accept背后发生了什么
import socket server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 9000)) server.listen(10) print("TCP server listening on 0.0.0.0:9000") while True: conn, addr = server.accept() print(f"accept from {addr}") data = conn.recv(1024) print(f"recv {len(data)} bytes: {data!r}") conn.sendall(b"pong") conn.close()这段代码能跑,但它有几个关键点值得掰开说。socket(AF_INET, SOCK_STREAM)组合固定了“IPv4 + TCP”这个协议族,SOCK_STREAM就是“基于字节流的可靠传输”;如果想改用UDP,把SOCK_STREAM换成SOCK_DGRAM,后半段代码全部要改,这点后面再展开。setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)的作用是允许TIME_WAIT状态下端口被重新绑定,第5章会专门讲避坑,但开发环境建议直接写上。bind的“0.0.0.0”表示监听所有网卡,换成“127.0.0.1”则只能本机访问,做端口测试时最容易在这个地方看反。
listen(10)里的10经常被误会成“最大并发连接数”,实际上它只控制内核里已完成三次握手的队列长度。客户端完成握手而服务端还没accept时,连接会堆在这个队列里;队列满了之后,新的连接请求会被内核直接丢弃或返回拒绝,表现就是客户端connect超时但进程明明还活着。所以业务代码里要么快速accept,要么accept完立即交给线程池,不要让握手成功的连接在队列里等太久。accept()返回的conn是服务端一侧的新socket,负责这条连接上收发,原server socket只负责继续接收新连接;这个“一服务一连接”的关系也是初学最容易晕的点。
2.2 客户端connect与sendall:握手完成,但recv仍然没有消息边界
客户端的代码更短:
import socket client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.settimeout(3) client.connect(("192.168.1.50", 9000)) client.sendall(b"ping") try: data = client.recv(1024) except socket.timeout: print("recv timeout, close it") client.close() else: print("recv:", data) client.close()connect()成功返回,只代表三次握手完成了:本端发SYN、对端回SYN+ACK、本端回ACK这三次交换已经结束。这时候如果立刻抓包,会看到连接状态是ESTABLISHED,但这只表示“TCP栈认为连接可用”,不代表对端应用已经准备好消息。connect可能抛出的异常有三种值得记:ConnectionRefusedError说明对端回了RST,通常是端口没服务;TimeoutError说明SYN发出后没人理,常见于防火墙丢弃;OSError里最常见的子类是“Network is unreachable”或“Operation now in progress”,前者是路由问题,后者多半是在非阻塞socket上重复connect。
sendall和send是另一个高频坑。send()未必一次发完所有字节,返回值是本次实际写入内核发送缓冲区的字节数;sendall()内部循环发送,保证全部字节进入缓冲区,但同recv一样,“发送成功”不代表对端收到了,只代表本端内核收了。TCP的可靠传输由内核的ACK/重传机制保证,应用层能观测到的可靠信号只有对端recv返回非空、或者对端正常close。recv(1024)在阻塞模式下会一直等到本连接上有数据或对端关闭;对端关闭后recv返回空字节串,这个信号后面要单独讲。
阻塞模式还有一个必须接受的事实:一个recv只能属于一个线程。上面这版服务端在while True里accept后阻塞在recv,第二个客户端连上来时,即使三次握手已经完成,accept也取不出来,因为第一个客户端还没断开。这就是为什么生产级TCP服务端不能用这种写法。看到这里不要急着学并发,先记住这个瓶颈,第3章会把帧协议和心跳补上,第6章再写事件驱动。
2.3 用抓包和空字符串确认握手、挥手时序
想在代码层面“看”到三次握手,最直接的办法是抓回环包。服务端和客户端在同一台机器时,用tcpdump能精确看到整个过程:
tcpdump -i lo -nn tcp port 9000运行服务端和客户端后,输出里会出现SYN、SYN+ACK、ACK三行,这就是三次握手;随后如果客户端先close,会看到FIN、ACK、FIN、ACK四行,也就是常说的四次挥手。这里有一个容易误导新手的现象:服务端代码里看不到任何握手状态,因为握手由TCP协议栈自动完成;但这不代表“没有握手”,只是内核替你做了。排错时,与其盯着客户端打印,不如用ss -tnp看连接状态:LISTEN表示服务端还在监听,ESTABLISHED表示握手完成,TIME_WAIT表示主动关闭方进入的2MSL等待。
挥手阶段的代码语义也要对齐:客户端close后,服务端下一次recv会拿到b"",这不是数据,是EOF信号。很多人在recv里没判断空串,直接拿空数据去解析,然后就翻车。所以处理TCP关闭的正确姿势是:recv返回空串 -> 对端已关闭 -> 服务端也执行close释放连接;如果服务端在收到EOF前直接close,而客户端还在等回包,可能触发RST而不是优雅的FIN挥手。这里提到的空串判断,会和RST问题一起在第5章展开。
3. 把TCP做稳:帧协议、粘包处理与三层保活参数
TCP能保证字节顺序和最终交付,但它不关心你的消息边界。你要发一句“hello”和一句“world”,内核可能把8个字节连续放在缓冲区里,接收方一次读走,这就是粘包;反过来,一条“helloworld”也可能被拆成两次读,这是拆包。解决思路只有一个:在应用层定义“帧”,让接收方知道一条消息从哪里开始、在哪里结束。
3.1 粘包与拆包:先弄清内核缓冲区和应用缓冲区
先解释现象:发送端连续sendall三个100字节的数据,接收端一次recv(1024)可能收到300字节,也可能收到70字节。原因不是“TCP把数据粘在一起”,而是recv()的语义是“从内核接收队列里取出不超过指定长度的字节”,内核队列里有多少字节,和你的sendall次数没有对应关系。TCP是字节流协议,它只保证顺序和可靠性,不保证把每条sendall当作独立消息;这就是为什么很多从UDP转过来的人刚写TCP就会翻车。
另一个容易混淆的点是“tcp协议包如何修改”这类问题。粘包是应用层边界问题,改不了TCP头,也不能通过调整TCP_NODELAY完全消除。TCP_NODELAY只关闭Nagle算法,减少小包延迟,但不等同于消息边界;真正决定边界的是你自己的帧格式。tcp dup ack机制则是TCP栈在丢包重传时的ACK行为,跟粘包无关,调试时别把这两件事搅在一起。
选择帧格式时,固定长度最简单,但短包要补零,浪费带宽;分隔符适合文本协议,但payload里不能出现分隔符,要转义;长度前缀最通用,适合二进制协议。Modbus TCP的MBAP头就是典型长度前缀结构,“事务标识+协议标识+长度+单元标识”4个字段里,长度字段让接收方知道后面PDU有多少字节;如果你要对接PLC设备,几乎绕不开这套结构。
3.2 长度前缀帧协议:send_frame / recv_frame直接复制
import socket import struct def send_frame(sock: socket.socket, payload: bytes): header = struct.pack("!I", len(payload)) sock.sendall(header + payload) def recv_exact(sock: socket.socket, length: int) -> bytes: chunks = b"" while len(chunks) < length: piece = sock.recv(length - len(chunks)) if not piece: raise ConnectionError("peer closed during frame") chunks += piece return chunks def recv_frame(sock: socket.socket) -> bytes: header = recv_exact(sock, 4) (length,) = struct.unpack("!I", header) if length > 4 * 1024 * 1024: raise ValueError("frame too large, refuse it") return recv_exact(sock, length)这个封装是TCP应用层协议常见做法。struct.pack("!I", len(payload))把长度压成4字节大端整数,大端网络序是约定俗成,跨机器不需要考虑字节序;recv_exact循环读满length字节,是因为底层recv一次未必返回所有字节,拆包会被这里吸收掉。recv_frame里对length做上限校验是生产环境必须加的:如果不限制,攻击方只要发一个声明长度为4GB的帧头,你的recv_exact就会一直空等或申请巨大内存,把进程拖死。4MB不是硬标准,但一定要有。
帧格式可以列成一张小表:
| 字段 | 长度 | 字节序 | 用途 |
|---|---|---|---|
| length | 4字节 | 大端 | 记录payload字节数 |
| payload | 0~4MB | - | 实际业务数据 |
服务端主循环里配合使用:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 9000)) server.listen(16) while True: conn, addr = server.accept() print("connected:", addr) try: while True: payload = recv_frame(conn) print(addr, "->", payload.decode(errors="replace")) except (ConnectionError, ValueError): pass finally: conn.close()这里把ConnectionError和ValueError都当作“断开或非法帧”,因为一次非法帧后,应用层状态已经不可信,直接关闭连接比试图恢复更稳。如果业务需要区分是掉线还是非法数据,可以分别except后再打日志。send_frame里的sendall会循环发送,所以上层不需要关心写缓冲满的问题;真正需要关心的是sendall也可能长时间阻塞,配合后面的settimeout才能避免一个慢客户端拖死服务端。
3.3 心跳与断线检测:SO_KEEPALIVE、TCP_KEEPIDLE、业务心跳
TCP连接断开,应用层不是立刻知道的。如果对端直接断电或网线断开,本端可能一直觉得连接还在,直到recv超时或发送失败才反应。内核内置的TCP保活能缓解,但默认2小时才开始探测,太慢了。可以用setsockopt调参数:
import socket s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) if hasattr(socket, "TCP_KEEPIDLE"): s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) if hasattr(socket, "TCP_KEEPINTVL"): s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5) if hasattr(socket, "TCP_KEEPCNT"): s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)参数含义:TCP_KEEPIDLE是空闲多久开始探测,TCP_KEEPINTVL是每次探测间隔,TCP_KEEPCNT是连续失败几次判定断开。设成空闲60秒、每5秒探测、3次失败后判定死连接,一共耗时75秒左右,比默认2小时快得多。但内核保活只能保证“TCP栈层面探测到对端消失”,应用层业务仍然可能卡在处理逻辑里,所以更可靠的是应用级心跳。
业务心跳常见做法:连接建立后双方约定一个心跳帧,比如每5秒客户端发PING,服务端回PONG或者只记录最后活跃时间;服务端每次收到任意帧都更新last_seen,后台线程每10秒扫一遍,发现超过阈值就close。这样即使对端不响应,也能在应用层主动断开。底层的SO_KEEPALIVE可以继续开着,但它作用有限,不要当作唯一保活手段。调参时还可以留意Windows上netsh interface tcp show global查看系统级keepalive配置,但应用层心跳参数不受它控制,两者是独立的。
4. 切到UDP:收发、端口探测与多客户端会话管理
UDP在Python里的代码量比TCP少,但坑一点不少。少的是connect、listen、accept这些连接状态管理,多的是收发边界、端口探测和广播。它的定位是“接受数据报丢了再补”的场景,比如设备状态上报、日志传输、视频流、以及很多控制协议的广播发现。
4.1 UDP的recvfrom与sendto:无连接模型下的最小实现
import socket s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.bind(("0.0.0.0", 8000)) print("UDP listening on 0.0.0.0:8000") while True: data, addr = s.recvfrom(2048) print(f"from {addr}: {data!r}") s.sendto(b"ack", addr)这里没有listen,没有accept,s就是唯一的socket。recvfrom返回两个值:data是这一条数据报的内容,addr是来源(ip, port);sendto把应答发回同一个addr,就能天然回给那个客户端。这跟TCP完全不同:TCP每个连接有自己的socket,UDP所有包都从同一个socket进。第二个参数2048是单次接收缓冲区上限,超过的部分会被内核丢弃,这会造成“收到了包但不是完整包”的假象。如果业务字段可能超过1500字节,建议把缓冲区调到4096或更高,但别盲目调大,超过路径MTU的包仍可能在IP层被分片。
UDP选型还要知道它的“假更快”:UDP少了握手和重传,单包延迟低,但应用层自己补重传、排序、去重的成本并不低。做端口测试时,UDP的“无状态”也是一把双刃剑——TCP connect能立刻告诉你端口通不通,UDP只能靠应用回包判断。另外,UDP socket其实也可以调用connect(),它的作用是记录默认目标地址,之后sendto可以只传数据,但recvfrom仍然能从任何来源收包,这个行为不要和TCP的connect混为一谈。
4.2 UDP端口探测:为什么“没回包”不等于不通
import socket def udp_probe(host, port, timeout=1.0): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(timeout) s.sendto(b"probe", (host, port)) try: data, _ = s.recvfrom(2048) return True except socket.timeout: return None它先发一条“probe”给目标,等回包;如果目标主机不存在,或者端口没有服务,常见情况下内核会回ICMP Port Unreachable,但Python的UDP socket默认收不到这个ICMP,因为ICMP属于另一层协议,所以recvfrom只能等超时。不能把“无回包”直接判定为“端口不通”,因为防火墙可以静默丢弃UDP,目标应用也可以选择不回应任何未知数据。这个脚本真正的用途是“探测服务是否存活”:如果目标服务设计为必须响应特定UDP报文,回包就是活着的证据;如果只是往空端口灌数据,只能得到“未知”这个结论。
这个差异在做udp端口测试时非常关键。TCP端口测试只要connect()收到RST,就能确定端口不可达;UDP没有等价物。所以生产环境做UDP连通性监控时,要在应用层设计一个握手包:客户端发特定魔数,服务端必须回另一个魔数,双方约定好,探测结论才可信。UDP网络调试工具也是这个思路,先确认对端会不会回应,再谈丢包率。
4.3 广播与多客户端:地址字典和SO_BROADCAST的用法
广播在局域网设备发现里很常用。发送广播包需要显式打开SO_BROADCAST:
import socket s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) s.sendto(b"DISCOVER", ("255.255.255.255", 8000))服务端收到DISCOVER后,用recvfrom得到的addr回包,客户端就能知道设备在哪。注意广播只能覆盖同一广播域,路由器不会转发,跨网段要改用组播或单播探测。嵌入式设备做以太网UDP测试时,通常也是先用广播发现设备,再转单播传数据。
多客户端会话管理在UDP下有点特殊:没有连接对象,只能靠地址字典。常见做法是维护一个dict,key是addr元组,value是会话状态和最后活跃时间:
sessions = {} while True: data, addr = s.recvfrom(2048) sessions[addr] = time.time() if data == b"keepalive": s.sendto(b"alive", addr) now = time.time() for a in [a for a, t in sessions.items() if now - t > 15]: del sessions[a]注意dict的key必须是recvfrom返回的addr原样,不能自己拼“ip:port”再当作元组。另外,客户端在NAT后面时,这个addr是NAT网关分配的临时映射,长时间不发包会被回收,所以UDP应用层心跳间隔要比NAT超时短很多,局域网内则不用太担心。清理会话时要避免在遍历dict时删除,先取待删地址快照再删除,代码里我用列表推导式生成待删地址。
5. 避坑清单:端口占用、空串返回与RST重连的排查记录
这一章是我自己踩过不少次的坑,每一条都按“现象-原因-解决”来写,方便你遇到问题直接对号入座。
5.1 bind失败:Address already in use / Windows套接字地址已使用
现象:服务端代码第二次启动时报OSError: [Errno 98] Address already in use,或者Windows下报“通常每个套接字地址(协议/网络地址/端口)只允许使用一次。”原因通常有三类:上一个进程还没退出;进程退出了但连接还在TIME_WAIT;另一个程序占用了同一端口。
解决:先用lsof -i :9000(Linux)或netstat -ano | findstr :9000(Windows)查占用进程,确认没有僵尸进程后再换端口重试。代码层面,bind前设置SO_REUSEADDR能解决TIME_WAIT导致的重复绑定;但要注意Windows上SO_REUSEADDR语义和Linux不完全一样。Linux上SO_REUSEADDR允许在TIME_WAIT状态下重用端口,Windows上多个socket同时设置它也能绑定同一端口,后果是数据可能被随机分给其中一个socket。所以Windows上不要为了省事给所有socket都开SO_REUSEADDR,只有明确需要“TIME_WAIT后立刻重启服务端”时才开。如果要用多进程负载均衡,Linux的SO_REUSEPORT是另一个选项,不能和SO_REUSEADDR混为一谈。
顺带说一句,部署到容器环境时见过的“error response from daemon: ports are not available: exposing port tcp 0.0.0.0”本质也是端口被占用或未释放,不是网络问题。在宿主机上用ss -lntp看监听端口,确认没有冲突再启动,比反复重启容器有效。
5.2 recv返回空字符串:是正常关闭还是被重置?
现象:服务端recv()返回b"",日志上没有异常,客户端进程也还在跑。原因:这是对端正常调用close()后,FIN到达本端,recv返回EOF信号。它不是一个错误,是“连接关闭”的标准通知。
解决:把b""当成“关闭连接”分支,不要交给业务解析。继续在这个连接上recv会一直返回b"",毫无意义。如果还想区分“正常关闭”和“异常RST”,可以尝试再写一次:写的时候抛BrokenPipeError或ConnectionResetError,说明对端已经发送了RST;写成功但recv还是空串,则可能是对端只关闭了读半端。实际业务里不需要太纠结,记一条warn日志即可。这里最容易翻车的写法是data = conn.recv(1024); if data:然后省略else,导致关闭信号被误认为空消息处理。
5.3 客户端重连被RST:先查未读数据和close顺序
现象:客户端断开后立即重连,connect抛ConnectionResetError,或服务端accept到的连接一收就报ECONNRESET。原因常见的是:上一次连接中,一端close时另一端还有未读取的数据,TCP栈于是不回FIN而是回RST,把连接强制重置。
解决:改close顺序。规范做法是“对端先close,本端读到空串后再close”,或者本端先shutdown(SHUT_WR)告诉对端“我不再发数据”,等对端close后再close。不要两端同时执行close,尤其不要在自己还有未读数据时直接close。客户端重连也一样,如果老的conn已经处于异常状态,重新new一个socket,不要试着把旧的conn再connect一遍。客户端重连时报地址已在使用,本质也是旧连接还占着本地端口,新连接想复用相同四元组会被拒绝,所以重连逻辑一定要“换socket、换本地端口”,不要复用旧fd。
5.4 UDP丢包与乱序:先确认缓冲区,再怀疑网络
现象:UDP客户端每秒发100个包,服务端只收到70个,收到的顺序还有跳号。原因可能有两个层次:应用太慢,收包循环没及时从内核队列取数据,内核缓冲区溢出,后到的包被丢弃;或者网络路径本身丢包。UDP不保证顺序,乱序是正常的,丢包则要看是发生在哪一侧。
解决:先调大接收缓冲区:
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024)注意Linux上内核会把设置值翻倍或者受net.core.rmem_max限制,设完可以用getsockopt看一下实际生效值。然后把“收包”和“业务处理”解耦:recvfrom循环只负责把包放进queue.Queue,业务线程再消费,这样收包循环永远不堵。乱序则要靠应用层加序号,接收端检查序号缺口并做缓存重排,不要假设先到先处理。排查时用netstat -su看UDP buffer errors,如果这个计数器在增长,说明本端丢包是应用处理慢导致的,跟网络没关系。
5.5 shutdown socket创建失败:同机端口池耗尽的长尾问题
现象:某些服务框架停机时日志出现“failed to create server shutdown socket on address [localhost] and port [802]”之类的报错,看起来像网络崩溃,随后端口迟迟起不来。原因:很多框架会在本机临时端口上创建控制socket,用来通知停机;如果这个端口被其他进程占用,或者系统端口池因为大量TIME_WAIT连接耗尽,创建就会失败。
解决:先查端口占用,再查TIME_WAIT数量。Linux上用ss -tan state time-wait | wc -l看堆积;Windows下netsh interface tcp show global只能看全局TCP参数,查TIME_WAIT还是用netstat -ano | findstr TIME_WAIT更直观。更实际的做法是给服务设置固定的shutdown端口,并通过配置文件下发,避免每次启动随机撞端口;同时启动前先尝试绑定,绑定失败就快速失败并给出明确错误,而不是让应用内部把网络故障误报成“无法创建shutdown socket”。这个问题日志上很吓人,但解决起来就是端口管理四件事:谁占用,谁释放,谁在等待,谁在复用。
6. 进阶:用selectors做事件驱动,再用UDP打流验证吞吐
6.1 selectors替换多线程阻塞模型
当连接数到几百,线程池反而是瓶颈:每个线程默认8MB栈空间,上下文切换吃CPU,还要处理线程安全的收发缓冲。用事件驱动可以让一个线程管住上千连接。Python标准库的selectors模块在这里最合适,它底层按平台选select/poll/epoll,Windows也能跑:
import selectors import socket sel = selectors.DefaultSelector() def on_accept(server): conn, addr = server.accept() conn.setblocking(False) sel.register(conn, selectors.EVENT_READ, on_read) def on_read(conn): data = conn.recv(1024) if not data: sel.unregister(conn) conn.close() return conn.sendall(b"echo:" + data) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 9000)) server.listen(64) server.setblocking(False) sel.register(server, selectors.EVENT_READ, on_accept) while True: for key, mask in sel.select(timeout=1): key.data(key.fileobj)这段代码里每个socket注册了一个回调,select()返回可读事件后,由回调处理。非阻塞模式下recv不会傻等,所以一个线程就能循环服务多个连接。selectors模块不是性能最强的,但足够覆盖绝大多数业务,且跨平台。
6.2 半包缓冲是事件驱动的分水岭
上面这个echo例子埋了一个雷:on_read里如果一次recv只读到半个长度前缀帧,直接把data当完整消息处理,协议就错了。事件驱动模式下,on_read会被反复触发,所以必须有一个半包缓冲器:
class FrameBuffer: def __init__(self): self.buf = b"" def feed(self, chunk): self.buf += chunk frames = [] while len(self.buf) >= 4: length, = struct.unpack("!I", self.buf[:4]) if len(self.buf) < 4 + length: break frames.append(self.buf[4:4 + length]) self.buf = self.buf[4 + length:] return frames每次on_read把recv到的chunk丢进feed,拿出来的frames是已经收完整的若干帧,不够一帧就留在self.buf里等下一次事件。这正是阻塞模型里由recv_exact循环做的事,事件模型下必须自己管理剩余状态。我最开始写selectors时没加这一层,半包一来就乱拼,日志里全是坏帧,后来才意识到:事件循环不等于自动处理拆包,帧边界只能靠应用状态维护。
6.3 用UDP打流验证设计
TCP性能可以直接用iperf3压,但UDP更需要自己定义“设计验证”:打流前先约定回包格式,接收端统计到达包的序号。在Python里做局域网UDP打流,发送端给每个包带自增序号,接收端每秒打印收到的总数和最大连续缺口。用iperf3时习惯iperf3 -u -c 192.168.1.50 -b 100M来压网卡,但应用层丢包率还是要自己的序号统计才准,因为iperf3测的是内核栈能收多少,而Python应用还要考虑GIL和业务耗时。
我最常犯的错是用阻塞模型搭完就上线,等到并发上来才补selectors,结果半包、超时一起炸。现在无论多小的工具,我都会先把帧缓冲器和超时策略写进去,再开始写业务逻辑。希望这些实现和踩坑记录能帮到你,少走这几段弯路。
本文还有配套的精品资源,点击获取