3. Python UDP Socket 编程:从零搭建你的收发通道
聊到 UDP 编程,很多人的第一反应是「不可靠、丢包、别用在生产环境」。这话对,也不全对。我在做日志追踪系统时,恰恰是 UDP 的「无连接」特性帮了大忙——你想想看,日志采集端成百上千,如果每个都要先三次握手再发数据,光是连接开销就能把服务端拖垮。UDP 虽然不保证送达,但日志场景下丢几条往往可以接受,换来的是极低的延迟和极高的吞吐。
这一章,我们就手把手把 Python 的 UDP Socket 玩透。从 socket 模块的基本用法,到服务端和客户端的完整实现,再到异常处理——嗯,这部分我踩过不少坑,后面会细说。
3.1 socket 模块:UDP 编程的基石
Python 的 socket 模块是标准库,不需要额外安装。创建一个 UDP Socket 只需要一行代码:
import socket
udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
AF_INET 表示使用 IPv4 地址族,SOCK_DGRAM 就是数据报套接字——说白了就是 UDP。如果你用 SOCK_STREAM,那就是 TCP 了。
UDP Socket 的核心方法其实不多,我整理了一张表,方便你对照:
| 方法 | 作用 | 我在项目中常用的场景 |
|---|---|---|
socket() |
创建套接字 | 指定 AF_INET + SOCK_DGRAM |
bind() |
绑定地址和端口 | 服务端固定监听端口 |
sendto() |
发送数据到指定地址 | 客户端发送日志行 |
recvfrom() |
接收数据并获取来源地址 | 服务端接收日志并记录来源 IP |
close() |
关闭套接字 | 程序退出前释放资源 |
settimeout() |
设置超时 | 防止 recvfrom 永久阻塞 |
3.2 UDP 服务端创建:绑定、接收、循环
服务端的逻辑很清晰:绑定一个端口,然后死循环收数据。来看一个最简版本:
import socket
HOST = '0.0.0.0' # 监听所有网卡
PORT = 8888 # 日志收集端口
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_socket.bind((HOST, PORT))
print(f'UDP 服务端已启动,监听 {HOST}:{PORT}')
while True:
data, addr = server_socket.recvfrom(65535) # 最大接收 64KB
message = data.decode('utf-8')
print(f'收到来自 {addr} 的消息: {message}')
# 这里可以写日志写入文件的逻辑
注意 recvfrom(65535) 这个参数。65535 是 UDP 数据包的最大理论长度。我曾经在生产环境遇到过一个问题:客户端发了一条超过 64KB 的日志,服务端只收到了前半段,后半段直接丢了。后来排查才发现,UDP 本身就不支持超过 64KB 的单包传输,需要应用层自己分片。
3.3 UDP 客户端创建:发送、无连接、轻量
客户端更简单——不需要 connect,直接 sendto 就行。这也是 UDP 和 TCP 最大的区别:无连接,发完就走。
import socket
HOST = '127.0.0.1' # 服务端地址
PORT = 8888
client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
message = '这是一条测试日志'
client_socket.sendto(message.encode('utf-8'), (HOST, PORT))
print('消息已发送')
client_socket.close()
你可能会问:没有连接,服务端怎么知道是谁发的?答案就在 recvfrom 返回的 addr 里。UDP 协议本身会在每个数据包中携带源 IP 和源端口,服务端拿到这个地址后,如果想回复,直接用 sendto 发回去就行。
我在做日志采集 Agent 时,客户端往往运行在几百台机器上。每个 Agent 启动时创建一个 UDP Socket,然后每隔几秒发一条日志。因为没有连接维护成本,服务端可以轻松扛住几千个并发客户端。换成 TCP 的话,光 keepalive 和连接池就能让人头疼死。
3.4 数据收发与编码:别在编码上翻车
UDP 收发的是字节流,不是字符串。所以 encode() 和 decode() 是绕不开的坎。我见过太多新手直接发字符串,然后服务端收到一堆乱码。
我的建议是:统一用 UTF-8。客户端 encode,服务端 decode,两边对齐。如果你处理的是二进制日志(比如 protobuf 序列化后的数据),那就直接发 bytes,不要做编解码。
# 客户端发送二进制数据
binary_data = b'\x00\x01\x02\x03'
client_socket.sendto(binary_data, (HOST, PORT))
# 服务端接收
data, addr = server_socket.recvfrom(65535)
# data 就是 bytes,直接处理即可
3.5 异常处理:UDP 编程的保命符
UDP 虽然简单,但异常处理绝不能省。我总结了几种最常见的异常场景:
- 端口被占用:
OSError: [Errno 98] Address already in use。服务端重启时经常遇到,解决方案是设置SO_REUSEADDR。 - 网络不可达:
OSError: [Errno 101] Network is unreachable。客户端发数据时目标网络不通。 - 接收超时: 设置了
settimeout后,在规定时间内没收到数据会抛出socket.timeout。 - 数据截断: 接收缓冲区小于发送数据长度,数据会被静默截断——UDP 不会报错,这是最坑的。
来看一个带异常处理的完整服务端示例:
import socket
import sys
HOST = '0.0.0.0'
PORT = 8888
try:
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind((HOST, PORT))
server_socket.settimeout(5.0) # 5 秒超时
except OSError as e:
print(f'套接字创建/绑定失败: {e}')
sys.exit(1)
print(f'服务端已启动,监听 {HOST}:{PORT}')
while True:
try:
data, addr = server_socket.recvfrom(65535)
print(f'收到 {addr} 的数据,长度 {len(data)} 字节')
# 处理数据...
except socket.timeout:
# 超时不是错误,可以继续循环
continue
except OSError as e:
print(f'接收数据时发生错误: {e}')
# 根据错误类型决定是否退出
break
注意 SO_REUSEADDR 这个选项。我刚开始写服务端时,每次 Ctrl+C 退出后马上重启,都会报端口被占用。加上这个选项后,端口可以立即重用,开发调试时特别方便。
3.6 本章知识体系总览
下面这张图把 UDP Socket 编程的核心流程和关键点串起来了。建议你对照着代码看,理解每个环节的作用。
从图上可以清楚看到:客户端只管发,服务端只管收,中间的网络层不维护任何连接状态。虚线箭头表示服务端可以回复,但这不是必须的——很多日志系统就是单向的,客户端发完就不管了。
3.7 小结与避坑清单
这一章的内容其实不多,但都是实战中绕不开的细节。最后给你一份我自己的避坑清单:
- 缓冲区大小: 服务端 recvfrom 的缓冲区统一用 65535,别省这点内存。
- 端口重用: 开发环境一定要加 SO_REUSEADDR,否则重启就报错。
- 编码统一: 客户端和服务端约定好编码格式,推荐 UTF-8。
- 超时设置: 服务端一定要设超时,否则一个坏客户端不发数据,你的服务端就永远卡在 recvfrom 上。
- 异常捕获: 不要只捕获 Exception,要区分 OSError 和 socket.timeout,不同异常处理方式不同。
嗯,这些坑我基本都踩过一遍。尤其是超时那个,早期版本没设超时,结果某个客户端网络断了,服务端线程直接挂死,整个日志收集链路都停了。从那以后,我所有 UDP 服务端都加了超时和心跳检测。