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(target=receive_loop, args=(sock,), daemon=True) 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字节的数据报,接收端缓冲区只有1024,recvfrom会返回1024字节,剩下的3984字节直接丢掉,而且没有任何提示。你收到的数据看起来是完整的字符串,但实际上是残缺的,解析的时候就会出错。
怎么判断有没有被截断?一个简单的办法是在数据末尾加一个结束标记,接收端检查这个标记是否存在。如果不存在,说明被截断了。更规范的做法是,在数据报头部加一个长度字段,接收端先读长度,再判断缓冲区够不够。
# 发送端:在数据前加4字节的长度头 import struct data = b'x' * 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_BROADCAST:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.sendto(b'hello', ('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(b'DISCOVER', ('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', errors='replace') # 写入文件或队列errors='replace'是为了防止日志里有非法UTF-8字符导致解码失败。这个细节在实际环境里很重要,因为日志里什么字符都可能出现。
4.3 实时状态同步:接受丢包,保证最新
游戏里的位置同步、监控系统里的状态上报,这类场景的特点是:旧数据没有价值,新数据才重要。用UDP正好,丢包了不用重传,因为下一个包很快就来了,重传旧数据反而浪费带宽。
这种场景下,接收端通常只保留最新的状态,不需要缓存历史。发送端可以固定频率发送,比如每秒20次。如果网络抖动导致丢包,接收端看到的状态会跳跃,但不会卡住。这个特性在实时性要求高的场景里比TCP的可靠传输更有优势。
5. 调试UDP程序时我常用的几个手段
5.1 用netstat和ss确认端口状态
程序报"端口被占用"的时候,第一件事是确认谁占用了端口。Linux上用ss -ulnp | grep 8888,Windows上用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; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.sendto(b'test',('127.0.0.1',8888))"这行命令可以快速验证接收端是否正常工作,不需要写完整的发送端程序。调试的时候,我经常用这个命令先确认接收端没问题,再去排查发送端的逻辑。
6. 写在最后:UDP的简单背后是取舍
UDP的代码确实简单,创建socket、bind、sendto、recvfrom,四个方法就能跑起来。但简单不代表简陋,它的每一个"不保证"都是设计上的主动取舍。不保证到达,换来了低延迟;不保证顺序,换来了无连接;不保证可靠,换来了高效率。用UDP的时候,你得清楚自己放弃了什么,以及这些放弃是否值得。
我在实际项目里用UDP,通常是因为三个原因:需要广播或多播、需要极低延迟、或者需要自己控制重传策略。如果只是普通的请求-响应,TCP反而是更省心的选择。选协议就像选工具,没有好坏,只有合不合适。
最后分享一个小技巧:如果你在开发阶段经常需要重启接收端,记得加上SO_REUSEADDR,能省掉很多"端口被占用"的烦恼。另外,把接收缓冲区设成65535,虽然看起来有点浪费,但能避免绝大多数截断问题。这些细节在文档里往往不会强调,但实际用起来,能帮你省下不少调试时间。