4. UDP协议与无连接通信

TCP 用三次握手建立可靠连接,而 UDP(User Datagram Protocol,用户数据报协议)走的是完全相反的路线:它不建立连接、不确认、不重传、不排序,只负责把一段数据封装成一个数据报(datagram)交给 IP 层发出去。这种“尽力而为”的模型看似简陋,却换来了低延迟、低开销和天然支持一对多通信的能力,使它成为局域网服务发现、实时消息推送、音视频流、游戏同步等场景的首选传输层协议。

一、UDP 数据报的核心特性

UDP 报文头只有 8 字节,包含源端口、目的端口、长度和校验和四个字段。它把“应用层交下来的整块数据”当作一个独立单元发送,因此具有几个必须牢记的特性:

  • 面向报文,保留边界:发送方一次 sendto() 对应接收方一次 recvfrom()。若接收缓冲区小于数据报长度,多余部分会被直接丢弃,而不是像 TCP 那样留在缓冲区等下次读取。
  • 无连接:不需要握手和挥手,服务端用同一个套接字就能接收来自任意客户端的数据报,客户端也不需要先“连上”服务端。
  • 不可靠:数据报可能丢失、重复、乱序到达。可靠性必须由应用层自己实现(如序号、ACK、超时重传)。
  • 无拥塞控制:UDP 不会因为网络拥塞而主动降速,因此在高丢包链路上可能加剧拥塞,需要应用层自行限速。

一个常见的误区是认为“UDP 一定比 TCP 快”。准确的说法是:UDP 省去了连接管理、确认重传和拥塞控制的额外开销,在允许少量丢包、追求低延迟的场景下更合适;但如果应用层自己实现了完整的可靠机制,其开销未必低于 TCP。

二、单播、广播与组播

UDP 支持三种发送方式,区别在于目的地址的写法:

方式目的地址示例接收者范围典型用途
单播 Unicast192.168.1.20单个主机点对点消息
广播 Broadcast255.255.255.255 或 192.168.1.255同一广播域内所有主机局域网服务发现
组播 Multicast224.0.0.1 ~ 239.255.255.255加入该组播组的主机实时行情、视频分发

广播的局限在于它无法跨越路由器,且会打扰同一网段内所有主机;组播则允许主机通过 IGMP 主动“加入”某个组,只有感兴趣的设备才接收数据,效率更高。在 Python 中,发送广播需要给套接字设置 SO_BROADCAST 选项,接收组播则需要绑定到组播地址并加入组播组。

三、基于 UDP 的局域网服务发现

服务发现要解决的问题是:客户端不知道服务端 IP 和端口,如何自动找到它?典型做法是“广播询问 + 单播应答”。服务端在固定端口(例如 37020)监听广播;客户端向广播地址发送一个探测报文,服务端收到后把自己的服务名、IP、端口回给客户端。

# 服务端:监听广播并应答
import socket

DISCOVER_PORT = 37020
srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("", DISCOVER_PORT))          # 绑定所有网卡的该端口

while True:
    data, addr = srv.recvfrom(1024)
    if data == b"DISCOVER_PY_SERVICE":
        reply = b"PY_SERVICE|chat|37021"
        srv.sendto(reply, addr)        # 单播回给询问者
        print("已应答来自", addr, "的发现请求")
# 客户端:广播探测并等待应答
import socket

cli = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
cli.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
cli.settimeout(2.0)                    # 2 秒内没应答就认为失败
cli.sendto(b"DISCOVER_PY_SERVICE", ("255.255.255.255", 37020))

try:
    data, addr = cli.recvfrom(1024)
    name, svc, port = data.decode().split("|")
    print(f"发现服务 {name},地址 {addr[0]}:{port}")
except socket.timeout:
    print("未发现任何服务,请检查防火墙或网段")

这里有两个关键点:一是服务端绑定的是 ""(即 0.0.0.0),才能收到发往本机任意网卡的广播;二是客户端必须设置超时,因为 UDP 没有“连接失败”的反馈,收不到应答只能靠超时判断。若在 Windows 上测试,还需确认防火墙允许该 UDP 端口入站,否则广播会被静默丢弃。

四、实时消息推送与可靠性补强

服务发现完成后,客户端就可以向服务端的单播地址持续发送消息。UDP 的实时性体现在:消息发出即返回,不必等待确认,适合“状态同步”这类允许丢旧帧的场景。但若消息不能丢,就需要在应用层加一层轻量可靠机制,例如给每个数据报加 4 字节序号,接收方发现序号跳跃就请求重传。

# 带序号的轻量可靠发送(示例)
import struct

seq = 0
def send_reliable(sock, payload, addr):
    global seq
    seq += 1
    packet = struct.pack("!I", seq) + payload
    sock.sendto(packet, addr)          # 接收方按序号检测丢包

此外,UDP 数据报大小需要控制。以太网 MTU 通常为 1500 字节,减去 IP 头 20 字节和 UDP 头 8 字节,应用层单包建议不超过 1472 字节;超过后 IP 层会分片,任一分片丢失都会导致整个数据报作废,可靠性进一步下降。因此实时消息应尽量拆成小包发送。

UDP 局域网服务发现与消息推送流程 客户端 Client SO_BROADCAST=1 settimeout(2.0) recvfrom() 等待应答 后续单播发消息 服务端 Server bind(("", 37020)) SO_REUSEADDR=1 recvfrom() 收广播 sendto() 单播应答 广播域 192.168.1.0/24 255.255.255.255:37020 DISCOVER_PY_SERVICE ①广播探测 ②单播应答 PY_SERVICE|chat|37021 实时消息推送 序号 + 载荷 单包 ≤ 1472 字节 接收与判序 检测序号跳跃 请求重传丢包 ③UDP 单播持续推送 图例: 广播探测 单播应答 消息推送 关键参数:发现端口 37020,服务端口 37021,超时 2s,MTU 上限 1472B

五、实践中的判断依据

测试服务发现是否成功,最直接的观察点是客户端能否在超时前打印出服务名和端口;若一直超时,应按顺序排查:服务端是否真的绑定了 37020 端口、客户端是否设置了 SO_BROADCAST、两端是否在同一网段、防火墙是否放行。对于消息推送,判断依据则是接收端能否按序号连续收到数据;若出现序号跳跃,说明发生了丢包,此时应缩小单包体积或降低发送频率,而不是盲目加大缓冲区。

UDP 的“无连接”意味着服务端不需要为每个客户端维护连接状态,一台主机用单个套接字即可同时服务成百上千个客户端,这正是它在局域网发现和实时推送中不可替代的原因。