
1. 从一个发出去就不管的协议说起很多人第一次接触网络编程都是从TCP开始的。三次握手、四次挥手、可靠传输、流量控制一套流程走下来脑子里全是状态机。等到某天需要做一个局域网内设备发现、日志上报、或者游戏里的位置同步才发现TCP那套必须建立连接、必须确认收到的机制反而成了累赘。这时候UDP就该登场了。UDP全称用户数据报协议它最大的特点就是无连接。你不需要先跟对方打招呼直接把数据打包成一个数据报扔出去就行。对方收没收到、收到的顺序对不对UDP本身一概不管。听起来好像很不靠谱但正是这种不靠谱让它在某些场景下比TCP好用得多。比如你要在局域网里广播一句谁在线用TCP你得挨个去连用UDP直接往广播地址发一个包所有设备都能收到简单粗暴。这篇文章要聊的就是用Python实现一个最简单的UDP通信。说它简单是因为Python标准库里的socket模块已经把底层系统调用封装得很友好了核心代码加起来可能不到二十行。但简单不等于没有坑。我在实际用UDP做设备发现和日志收集的时候踩过不少坑端口被占用、数据包被截断、广播发不出去、防火墙拦截等等。这些问题在教科书里往往一笔带过但在真实环境里能让你调试一下午。所以这篇内容不会只给你两段代码就完事。我会从UDP的工作机制讲起把发送端和接收端的每一行代码为什么这么写说清楚然后重点讲那些跑通Demo之后才会遇到的问题——比如怎么处理粘包、怎么设置超时、怎么在局域网里做广播、怎么避免程序卡死。适合刚学完socket基础想动手写点东西的读者也适合已经会写但总在细节上翻车的朋友。你不需要有很深的网络功底只要会基本的Python语法跟着走一遍就能把UDP通信跑起来并且知道它为什么能跑起来。2. UDP到底把数据变成了什么数据报的发送与接收逻辑2.1 无连接不等于没有地址IP和端口的分工要理解UDP通信先得把地址这件事想明白。在网络里一台机器用IP地址来标识但一台机器上可能同时跑着浏览器、聊天软件、游戏客户端它们都在收发数据。操作系统怎么知道某个数据包该交给哪个程序靠的就是端口号。IP负责找到机器端口负责找到机器上的具体程序。一个UDP通信的端点就是IP端口这个组合socket编程里管它叫地址族和套接字地址。Python里创建一个UDP socket的代码是这样的import socket udp_socket socket.socket(socket.AF_INET, socket.SOCK_DGRAM)这里两个参数很关键。AF_INET表示使用IPv4地址如果你要用IPv6就换成AF_INET6。SOCK_DGRAM表示这是一个数据报套接字也就是UDP。对比一下TCP用的是SOCK_STREAM流式套接字。这个区别不是随便起的名字TCP是面向字节流的你发100次对方可能一次收完也可能分10次收UDP是面向数据报的你发一次就是一个完整的数据报对方要么收到完整的要么什么都没收到不会出现收到半个包的情况当然如果缓冲区太小超长数据报会被截断这个后面会讲。创建完socket之后发送端和接收端的写法就不一样了。发送端通常不需要绑定端口操作系统会随机分配一个临时端口。接收端则必须绑定一个明确的端口否则别人不知道往哪发。这就像寄信寄信人不需要固定住址但收信人必须有个固定的信箱。2.2 发送端bind是可选的但sendto必须指定目标先看发送端的最小实现import socket # 创建UDP socket send_socket socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 目标地址接收端的IP和端口 target_addr (127.0.0.1, 8888) # 要发送的数据必须是bytes类型 message hello udp.encode(utf-8) # 发送数据报 send_socket.sendto(message, target_addr) # 关闭socket send_socket.close()这段代码里sendto是UDP发送的核心方法。它接收两个参数数据和目标地址元组。注意数据必须是bytes类型Python3里字符串和字节是严格区分的所以要用encode转一下。如果你直接传字符串会报TypeError。发送端没有调用bind这是可以的。操作系统会在第一次发送时自动分配一个本地端口。但这里有个细节如果你发完就close那个临时端口会被回收。如果对方需要给你回消息你必须在发送前bind一个端口并且保持socket打开否则回消息发过来的时候你已经不在了。这个逻辑在做请求-响应模式的时候特别重要。还有一个容易忽略的点sendto的返回值。它返回实际发送的字节数。对于UDP来说正常情况下这个值等于你传入的数据长度。但如果数据报太大超过了MTU最大传输单元以太网通常是1500字节IP层会进行分片。分片会增加丢包的概率因为只要有一个分片丢了整个数据报就废了。所以实际项目中我一般会把单次发送的数据控制在1400字节以内留点余量给IP头和UDP头。2.3 接收端bind之后进入recvfrom循环接收端的代码稍微多一点import socket # 创建UDP socket recv_socket socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 绑定地址和端口 recv_socket.bind((0.0.0.0, 8888)) print(等待接收数据...) while True: # 接收数据和发送方地址 data, client_addr recvfrom(1024) print(f来自 {client_addr} 的消息: {data.decode(utf-8)})bind的参数是(0.0.0.0, 8888)。0.0.0.0表示监听本机所有网卡不管是本地回环还是局域网网卡都能收到。如果你只想监听本地可以写127.0.0.1。端口8888是自定义的只要不被其他程序占用就行。recvfrom是阻塞方法程序会停在这一行直到有数据到达。它返回两个值数据和发送方的地址。参数1024是缓冲区大小表示最多接收1024字节。如果发送方发来的数据超过1024字节超出的部分会被丢弃而且不会报错。这就是前面说的截断问题。所以接收端的缓冲区大小最好和发送端的数据大小匹配或者干脆设大一点比如65535这是UDP数据报的理论最大值。这里有个实际经验recvfrom的缓冲区不要设得刚刚好。我曾经写过一个日志收集服务发送端每条日志大概200字节我就把缓冲区设成了256。结果偶尔有超长日志比如异常堆栈发过来直接被截断排查了半天才发现是缓冲区的问题。后来统一改成4096再也没出过事。2.4 一个完整的双向通信示例把发送端和接收端合在一起做一个可以互相发消息的小工具这样更容易理解UDP的双向性。下面这个例子两个程序分别运行都能发也能收import socket import threading def receive_loop(sock): while True: try: data, addr sock.recvfrom(4096) print(f\n收到来自 {addr} 的消息: {data.decode(utf-8)}) except OSError: break # 创建socket并绑定本地端口 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((0.0.0.0, 9999)) # 启动接收线程 t threading.Thread(targetreceive_loop, args(sock,), daemonTrue) t.start() # 主线程负责发送 target (127.0.0.1, 9998) # 对方的地址 while True: msg input(输入消息q退出: ) if msg q: break sock.sendto(msg.encode(utf-8), target) sock.close()这个例子里接收放在子线程里主线程负责输入和发送。两个程序分别监听9999和9998互相把对方设为目标地址就能聊天了。这个结构在真实项目里很常见一个线程专门收主线程处理业务逻辑。注意UDP的recvfrom在socket关闭时会抛出OSError所以接收循环里要捕获这个异常否则程序退出时会打印一堆错误信息。3. 跑通Demo之后才会遇到的六个真实问题3.1 端口被占用Address already in use的根因与解法第一次运行接收端大概率没问题。但如果你改了代码重新运行可能会遇到这个错误OSError: [Errno 98] Address already in use明明程序已经退出了为什么端口还被占用这跟TCP的TIME_WAIT状态类似UDP socket关闭后操作系统会保留端口一段时间确保没有残留的数据报。解决办法是设置SO_REUSEADDR选项sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((0.0.0.0, 8888))这个选项告诉操作系统即使端口处于占用状态也允许绑定。在开发和调试阶段这个设置几乎是必备的。但要注意SO_REUSEADDR在多个程序同时绑定同一个端口时的行为不同操作系统不一样。在Linux上多个UDP socket绑定同一端口数据报会随机分给其中一个在Windows上行为又不同。所以生产环境里还是老老实实保证端口唯一比较好。3.2 数据被截断缓冲区大小的隐形陷阱前面提过缓冲区截断的问题这里展开说一下怎么发现和解决。假设发送端发了一个5000字节的数据报接收端缓冲区只有1024recvfrom会返回1024字节剩下的3984字节直接丢掉而且没有任何提示。你收到的数据看起来是完整的字符串但实际上是残缺的解析的时候就会出错。怎么判断有没有被截断一个简单的办法是在数据末尾加一个结束标记接收端检查这个标记是否存在。如果不存在说明被截断了。更规范的做法是在数据报头部加一个长度字段接收端先读长度再判断缓冲区够不够。# 发送端在数据前加4字节的长度头 import struct data bx * 5000 header struct.pack(!I, len(data)) sock.sendto(header data, target) # 接收端先收长度再收数据 # 注意UDP是一次性接收这里只是演示思路 buf bytearray(65535) n, addr sock.recvfrom_into(buf) if n 4: length struct.unpack(!I, buf[:4])[0] if n - 4 length: print(数据被截断)实际项目中我一般直接把缓冲区设成65535这是UDP数据报的理论上限省得算来算去。代价是每个socket多占一点内存但在现代机器上完全可以忽略。3.3 广播发不出去SO_BROADCAST选项和网段限制UDP的一大优势是支持广播可以一次性给局域网里所有设备发消息。但直接往广播地址发会报Permission denied。原因是操作系统默认禁止普通程序发广播防止广播风暴。要发广播必须显式开启SO_BROADCASTsock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.sendto(bhello, (255.255.255.255, 8888))255.255.255.255是受限广播地址只在本地网段有效路由器不会转发。如果你想跨网段广播需要用子网广播地址比如192.168.1.255。这个地址是根据你的IP和子网掩码算出来的不是随便写的。广播在实际用的时候还有两个坑。第一有些交换机会过滤广播包尤其是企业网络里IT管理员可能禁了广播。第二接收端绑定0.0.0.0才能收到广播如果绑定的是具体IP广播包收不到。我做设备发现功能的时候就是靠广播发谁在线设备收到后单播回复自己的信息。这个模式比维护一个设备列表简单多了。3.4 程序卡死超时设置与阻塞的应对recvfrom默认是阻塞的没有数据就一直等。如果你的程序既要收数据又要做别的事就会卡住。解决办法是设置超时sock.settimeout(5.0) # 5秒超时 try: data, addr sock.recvfrom(4096) except socket.timeout: print(5秒内没有收到数据)设置超时后recvfrom最多等5秒超时抛异常。你可以在异常处理里做其他事情然后继续循环。这个模式在需要定期上报状态的程序里很有用。另一种方案是用setblocking(False)把socket设成非阻塞但这样recvfrom会立刻返回没有数据时抛BlockingIOError需要配合select或epoll使用。对于简单的UDP程序超时机制已经够用了非阻塞模式更适合高并发的场景。提示超时时间不要设得太短。局域网内UDP的往返延迟通常在1毫秒以内但设备响应可能需要几十毫秒。我一般设2到5秒既能及时处理异常又不会误判。3.5 乱序与丢包UDP的固有特性怎么应对UDP不保证顺序也不保证到达。在局域网里丢包率通常很低但乱序是可能发生的。比如你连续发了三个数据报A、B、C接收端可能按A、C、B的顺序收到。如果你的业务逻辑依赖顺序就必须自己在应用层处理。最简单的办法是在数据里加一个序列号import struct seq 0 while True: data f消息{seq}.encode(utf-8) packet struct.pack(!I, seq) data sock.sendto(packet, target) seq 1接收端根据序列号排序如果发现序号跳跃就知道中间丢了包。对于实时性要求高的场景比如语音、视频丢包了就直接跳过不重传对于可靠性要求高的场景可以在应用层实现确认和重传机制。但如果你真的需要可靠传输为什么不直接用TCP呢用UDP做可靠传输通常是因为需要TCP没有的特性比如多播、低延迟、或者自定义拥塞控制。3.6 防火墙与安全软件本地能通跨机不通的排查最让人头疼的问题两个程序在同一台机器上跑得好好的换到两台机器就不通了。十有八九是防火墙拦了。Windows防火墙默认会拦截入站的UDP包除非你手动放行。Linux的iptables也可能有规则。排查步骤是这样的先在接收端用tcpdump或Wireshark抓包看数据报有没有到达网卡。如果到了网卡但程序收不到就是防火墙或绑定地址的问题。如果根本没到就是网络路由或发送端的问题。这个排查思路能帮你快速定位问题在哪一层。另外绑定地址也要注意。如果接收端绑定的是127.0.0.1那只有本机发的包能收到局域网其他机器发的包会被丢弃。要接收所有网卡的包必须绑定0.0.0.0。这个细节我在第一次做跨机通信的时候忽略了折腾了好久才发现。4. 把UDP用在正确的地方三个典型场景的落地思路4.1 局域网设备发现广播加单播回复设备发现是UDP最经典的应用之一。思路很简单控制端往广播地址发一个探测包局域网里所有设备都收到。设备收到后用自己的单播地址回复一个包含自身信息的包。控制端收集这些回复就得到了设备列表。# 控制端发广播收回复 import socket import time sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.settimeout(2.0) # 发送探测包 sock.sendto(bDISCOVER, (255.255.255.255, 8888)) # 收集回复 devices [] start time.time() while time.time() - start 2.0: try: data, addr sock.recvfrom(1024) devices.append((addr, data.decode(utf-8))) except socket.timeout: break print(f发现 {len(devices)} 个设备) for addr, info in devices: print(f{addr}: {info})设备端的逻辑就是收到DISCOVER后往发送方地址回一个包。这个模式的好处是设备不需要知道控制端的地址控制端也不需要维护设备列表每次发现都是实时的。我在智能家居项目里用过这个方案比扫描IP段快得多而且不受网段变化的影响。4.2 日志收集高吞吐下的缓冲区管理日志收集是另一个适合UDP的场景。应用把日志发到收集端收集端写入文件或转发到消息队列。用UDP的好处是应用不会因为日志系统故障而阻塞发出去就不管了。代价是可能丢日志但对于调试日志来说丢几条无所谓。收集端的核心是缓冲区管理。如果日志量很大recvfrom的调用频率会很高每次都要分配新缓冲区。更好的做法是用recvfrom_into复用同一个缓冲区buf bytearray(65535) while True: n, addr sock.recvfrom_into(buf) log_line buf[:n].decode(utf-8, errorsreplace) # 写入文件或队列errorsreplace是为了防止日志里有非法UTF-8字符导致解码失败。这个细节在实际环境里很重要因为日志里什么字符都可能出现。4.3 实时状态同步接受丢包保证最新游戏里的位置同步、监控系统里的状态上报这类场景的特点是旧数据没有价值新数据才重要。用UDP正好丢包了不用重传因为下一个包很快就来了重传旧数据反而浪费带宽。这种场景下接收端通常只保留最新的状态不需要缓存历史。发送端可以固定频率发送比如每秒20次。如果网络抖动导致丢包接收端看到的状态会跳跃但不会卡住。这个特性在实时性要求高的场景里比TCP的可靠传输更有优势。5. 调试UDP程序时我常用的几个手段5.1 用netstat和ss确认端口状态程序报端口被占用的时候第一件事是确认谁占用了端口。Linux上用ss -ulnp | grep 8888Windows上用netstat -ano | findstr 8888。-u表示UDP-l表示监听-n表示显示数字端口-p表示显示进程。找到进程ID后再决定是杀掉进程还是换端口。这个命令我几乎每次调试网络程序都会用。有时候是之前的程序没退干净有时候是别的程序占用了端口看一眼就清楚了。5.2 用tcpdump抓包验证数据流向当程序收不到数据时抓包是最直接的验证手段。在接收端运行tcpdump -i any -n udp port 8888 -X-i any表示所有网卡-n表示不解析域名-X表示以十六进制和ASCII显示数据内容。如果能看到发送端发来的包说明网络是通的问题在接收端程序如果看不到说明包根本没到问题在发送端或网络。抓包的时候要注意有些系统需要root权限。另外如果数据量很大可以加-c 10只抓10个包避免刷屏。5.3 日志里记录发送方地址和字节数在接收端打印发送方地址和接收到的字节数是最简单的调试手段。我习惯在recvfrom之后加一行日志data, addr sock.recvfrom(4096) print(f收到 {len(data)} 字节来自 {addr})这行日志能告诉你三件事有没有收到数据、数据多大、谁发的。如果字节数总是等于缓冲区大小说明可能被截断了。如果地址不对说明发送端配置错了。这些信息在排查问题时非常有用。5.4 用Python自带的socket测试工具快速验证Python的socket模块自带一个简单的UDP测试工具可以在命令行直接发UDP包python -c import socket; ssocket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.sendto(btest,(127.0.0.1,8888))这行命令可以快速验证接收端是否正常工作不需要写完整的发送端程序。调试的时候我经常用这个命令先确认接收端没问题再去排查发送端的逻辑。6. 写在最后UDP的简单背后是取舍UDP的代码确实简单创建socket、bind、sendto、recvfrom四个方法就能跑起来。但简单不代表简陋它的每一个不保证都是设计上的主动取舍。不保证到达换来了低延迟不保证顺序换来了无连接不保证可靠换来了高效率。用UDP的时候你得清楚自己放弃了什么以及这些放弃是否值得。我在实际项目里用UDP通常是因为三个原因需要广播或多播、需要极低延迟、或者需要自己控制重传策略。如果只是普通的请求-响应TCP反而是更省心的选择。选协议就像选工具没有好坏只有合不合适。最后分享一个小技巧如果你在开发阶段经常需要重启接收端记得加上SO_REUSEADDR能省掉很多端口被占用的烦恼。另外把接收缓冲区设成65535虽然看起来有点浪费但能避免绝大多数截断问题。这些细节在文档里往往不会强调但实际用起来能帮你省下不少调试时间。