3. Python UDP 编程入门:socket 模块与核心 API

说实话,很多刚接触网络编程的朋友,一上来就被各种协议栈、状态机给吓住了。但 UDP 不一样——它简单、直接,就像你往邮筒里扔一封信,寄不寄得到、对方收不收得到,你都不太关心。这种「发完就不管」的特性,反而让它在实时通信、视频流、游戏同步这些场景里大放异彩。

我个人习惯,学 UDP 编程的第一步,不是去背 RFC 文档,而是先跑通一个能收发数据的程序。Python 的 socket 模块就是最好的入门工具。今天我们就把它拆开揉碎了讲清楚。

3.1 socket 模块:网络通信的「电话机」

Python 的 socket 模块,说白了就是对操作系统网络接口的一层封装。你不需要去管底层的系统调用有多复杂,只需要知道怎么「拨号」和「接听」就行。

我刚开始做网络编程时,总觉得这玩意儿很神秘。后来在项目里用 UDP 做设备日志采集,才发现它其实就是个文件描述符——你往里面写数据,内核帮你发出去;你从里面读数据,内核把收到的包递给你。

核心概念:UDP 套接字是无连接的。你不需要像 TCP 那样先建立连接,直接发数据就行。这既是优点(低延迟),也是缺点(不可靠)。

3.2 socket():创建你的第一个 UDP 套接字

创建套接字,用的是 socket.socket() 函数。它的签名长这样:

import socket

# 创建一个 UDP 套接字
udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

这里有两个参数:

  • AF_INET:指定使用 IPv4 地址族。如果你要用 IPv6,就换成 AF_INET6。
  • SOCK_DGRAM:指定使用数据报套接字,也就是 UDP。如果是 TCP,这里会写 SOCK_STREAM。

我记得有一次,同事把 SOCK_DGRAM 写成了 SOCK_STREAM,结果程序一直报错,查了半天才发现是套接字类型搞错了。嗯,这种低级错误,谁还没犯过呢?

小技巧:创建套接字后,可以用 udp_sock.type 检查类型。如果返回值是 socket.SOCK_DGRAM,说明你创建对了。

3.3 bind():给套接字绑定一个「门牌号」

套接字创建好了,但它还没有地址。就像一部电话机,你得给它分配一个号码,别人才能打进来。bind() 就是干这个的。

# 绑定到本地地址和端口
udp_sock.bind(('0.0.0.0', 8888))

参数是一个元组:(IP地址, 端口号)。

  • IP 地址:'0.0.0.0' 表示监听所有网络接口。如果你只想监听某个特定 IP,比如 '192.168.1.100',也可以写上去。
  • 端口号:范围是 0-65535。注意,1024 以下的端口需要管理员权限才能绑定。

我在项目中遇到过一个问题:程序启动时 bind() 报 OSError: [Errno 98] Address already in use。这是因为上次程序退出后,端口还没释放。解决方案是在 bind() 之前加上 setsockopt():

udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
udp_sock.bind(('0.0.0.0', 8888))

注意:如果你只是发送数据(客户端模式),可以不调用 bind()。系统会自动分配一个临时端口。但如果你想接收数据,就必须 bind() 到一个固定端口。

3.4 sendto():把数据扔出去

UDP 发送数据用的是 sendto()。它的参数很简单:数据和目标地址。

message = b'Hello, UDP Server!'
server_addr = ('192.168.1.10', 8888)
sent = udp_sock.sendto(message, server_addr)

这里有几个细节要注意:

  • 数据必须是字节串:字符串需要先编码,比如 '你好'.encode('utf-8')。
  • 返回值是发送的字节数:但 UDP 是数据报协议,要么全发出去,要么发不出去。所以返回值通常等于数据长度。
  • 目标地址格式:和 bind() 一样,是一个 (IP, 端口) 元组。

你想想看,sendto() 其实挺「佛系」的——它把数据交给内核就不管了。至于数据能不能到达对端,它不保证。这也是 UDP 和 TCP 最大的区别。

避坑指南:我曾经在项目中用 sendto() 发送超过 65507 字节的数据(UDP 数据报的理论上限),结果数据被截断了。后来才知道,UDP 单个数据报最大长度是 65535 字节,减去 IP 头(20 字节)和 UDP 头(8 字节),实际可用只有 65507 字节。超过这个值,数据会被静默丢弃或分片,分片后只要丢一片,整个数据报就废了。

3.5 recvfrom():等着收数据

接收数据用的是 recvfrom()。它会阻塞当前线程,直到收到一个数据报。

data, client_addr = udp_sock.recvfrom(1024)
print(f'收到来自 {client_addr} 的消息:{data.decode("utf-8")}')

参数 1024 是缓冲区大小,单位是字节。如果收到的数据报超过这个大小,多余的部分会被丢弃。所以,缓冲区要设得足够大。

返回值有两个:

  • data:收到的数据,字节串类型。
  • client_addr:发送方的地址,格式是 (IP, 端口)。

我建议在实际项目中,缓冲区至少设到 4096 或 8192。如果你不确定数据大小,可以设成 65507——UDP 数据报的最大值。

小技巧:如果你不想让 recvfrom() 一直阻塞,可以用 settimeout() 设置超时:

udp_sock.settimeout(5.0)  # 5 秒超时
try:
    data, addr = udp_sock.recvfrom(1024)
except socket.timeout:
    print('超时了,没收到数据')

3.6 完整示例:一个简单的 UDP 回显程序

光说不练假把式。我们来写一个完整的 UDP 回显服务器和客户端。服务器收到什么,就原样返回什么。

UDP 服务器端:

import socket

# 创建 UDP 套接字
server_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# 允许端口复用
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 绑定到所有接口的 8888 端口
server_sock.bind(('0.0.0.0', 8888))

print('UDP 服务器已启动,等待数据...')

while True:
    # 接收数据,缓冲区 4096 字节
    data, client_addr = server_sock.recvfrom(4096)
    print(f'收到来自 {client_addr} 的消息:{data.decode("utf-8")}')
    
    # 原样返回
    server_sock.sendto(data, client_addr)

UDP 客户端:

import socket

# 创建 UDP 套接字
client_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

# 服务器地址
server_addr = ('127.0.0.1', 8888)

# 发送数据
message = '你好,UDP 服务器!'
client_sock.sendto(message.encode('utf-8'), server_addr)
print(f'已发送:{message}')

# 接收回显
data, _ = client_sock.recvfrom(4096)
print(f'收到回显:{data.decode("utf-8")}')

# 关闭套接字
client_sock.close()

先启动服务器,再运行客户端。你会看到客户端打印出「收到回显:你好,UDP 服务器!」。嗯,就是这么简单。

3.7 核心 API 对比

为了方便你记忆,我把这几个 API 的关键点整理成了一张表:

API 作用 关键参数 返回值
socket() 创建套接字 地址族、套接字类型 套接字对象
bind() 绑定地址和端口 (IP, 端口) 元组 无
sendto() 发送数据报 数据(字节串), 目标地址 发送的字节数
recvfrom() 接收数据报 缓冲区大小(字节) (数据, 发送方地址)

3.8 知识体系总览

下面这张图,把 UDP 编程的核心流程和 API 调用关系串起来了。你可以把它当作一张「地图」,写代码时对照着看:

UDP 编程核心 API 流程图 UDP 编程核心 API 调用流程 socket(AF_INET, SOCK_DGRAM) 是客户端还是服务器? 客户端 sendto(data, addr) 服务器 bind(('0.0.0.0', port)) recvfrom(bufsize) 处理数据 + sendto() recvfrom(bufsize) 客户端可以不 bind(),系统自动分配临时端口;服务器必须 bind() 到固定端口

从这张图可以看得很清楚:客户端和服务器都从 socket() 开始,但服务器多了一步 bind()。客户端直接 sendto() 发数据,然后 recvfrom() 等回复;服务器则是先 bind(),再 recvfrom() 等数据,收到后处理并 sendto() 回去。

这个流程,说白了就是 UDP 编程的「骨架」。你把这个骨架记住了,后面再学多线程、超时重传、数据校验这些高级话题,就只是往骨架上添肉而已。

我的建议:初学者可以先把这个回显程序跑通,然后试着改一改——比如改成客户端连续发多条消息,或者服务器把收到的消息广播给所有客户端。动手改代码,比看十遍文档都管用。