UDP编程基础:用Python socket实现数据报收发与可靠传输
发布时间:2026/10/21 17:02:08 锦皓数字建站

第一次在教材里看到UDP的时候我心里想的是这也能叫协议不握手、不确认、不重传数据发出去就仿佛从此与发送方无关。等到后来自己动手写了一个基于UDP的日志采集模块才发现这种“简单粗暴”背后藏着很务实的设计哲学。UDP编程基础说白了就两件事怎么用socket把数据报发出去怎么用socket把数据报接住。但要把这两件事做得靠谱牵涉到缓冲区长度、端口复用、超时重试、MTU分片、防火墙规则每一个单拎出来都能写一篇长文。这篇“御神楽的学习记录”UDP编程基础篇我会用Python标准库里的socket模块从零带出一个能跑的UDP服务端和客户端。代码不多但我会把代码背后那些“为什么”讲清楚为什么recvfrom要传缓冲区大小为什么sendto返回成功不代表对方收到为什么UDP天然没有粘包问题却又容易截断。适合两种人看一是刚接触网络编程只会写TCP socket想理解UDP区别的初学者二是已经能跑通UDP demo但遇到“客户端显示发送成功服务端就是收不到”这类问题想快速建立排查思路的人。1. 学UDP之前先想明白它解决什么问题1.1 TCP像打电话UDP像寄明信片TCP是面向连接的、可靠的、基于字节流的传输层协议。调用connect成功之后双方维护一条虚拟连接数据按序到达丢了会重传接收方不需要关心数据从哪里断开、从哪里接上内核已经把字节流的顺序和完整性处理好了。UDP则完全是另一个思路。它在IP层之上只加了8字节头部里面四个字段源端口、目的端口、长度、校验和。没有序列号没有确认应答没有滑动窗口。它把每个数据报都当成一封独立的明信片去投递能不能到、什么时候到、到了之后顺序是否颠倒统统不保证。这种“不保证”换来的好处非常实际不需要三次握手没有连接状态头部开销极小发送一个数据报一次系统调用就完成。对实时性要求高的业务与其等TCP因为丢包重传引入额外延迟不如在应用层自己做轻量补偿。语音通话、在线游戏、DNS查询、局域网设备发现这些场景里UDP都是主流选择。1.2 先接受“不可靠”是常态再谈写代码很多人学UDP时最难受的一点是潜意识里仍然拿TCP的标准来要求它。写代码之前需要先把心态调过来丢包和乱序是常态不是bug。UDP不会因为你用错了某个socket选项就变得可靠它把可靠性这件事完全交给了应用层。你需要自己在协议里设计超时重传、报文序号、去重机制甚至简单的握手校验。学习路线我建议这样走第一轮先掌握最基本的socket API写一个echo回显程序跑通第二轮把它扩展成心跳检测或局域网服务发现第三轮再考虑在UDP之上做可靠化改造。不要一上来就追求“不丢包不重复”那会让你陷入不切实际的幻想也会绕开真正需要掌握的基础。2. 从零实现UDP服务端和客户端Python版2.1 环境准备为什么拿Python做演示我用来演示的语言是Python 3不需要安装任何第三方库标准库里的socket模块就够了。选Python的原因很简单socket API在不同语言里的命名和调用方式非常接近Python能把核心调用链最直接地暴露出来。C语言写一遍要处理大量错误码Java又要套一堆异常体系而Python适合先把概念讲透。你完全可以用C、Go或者别的语言照着做。关键系统调用都是同一批名字socket、bind、sendto、recvfrom。先学会这套通用概念再切到别的语言并不难。2.2 创建UDP socket的关键SOCK_DGRAMimport socket udp_socket socket.socket(socket.AF_INET, socket.SOCK_DGRAM)AF_INET表示使用IPv4地址族SOCK_DGRAM表示数据报套接字。这里最容易犯的错误是想当然地写成SOCK_STREAM那是TCP用的字节流套接字。这一个参数之差决定了内核对待数据的方式完全不同。SOCK_STREAM会维护发送缓冲区和接收缓冲区保证字节流有序到达SOCK_DGRAM则以报文为单位一次sendto对应一个完整数据报内核会保留报文边界recvfrom读取时一次正好读出一整包。理解这个边界是UDP编程最重要的一步。2.3 服务端bind之后循环recvfromimport socket server socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server.bind((0.0.0.0, 8888)) print(UDP server listening on 0.0.0.0:8888) while True: data, addr server.recvfrom(2048) message data.decode(utf-8) print(frecv from {addr}: {message}) response fecho: {message} server.sendto(response.encode(utf-8), addr)bind是服务端必须做的动作它把socket和本机的IP、端口绑定起来。这里绑定的是(0.0.0.0, 8888)0.0.0.0表示本机所有IPv4地址。如果你只希望本机回环地址能访问可以改成(127.0.0.1, 8888)但那样远程机器发来的数据报会被内核直接丢弃服务端根本收不到。recvfrom(2048)里的2048是单次最多读取的用户数据字节数。如果发送方发来的数据报大于2048本次recvfrom会截断超出的部分直接被内核丢弃后面的recvfrom也读不到剩余内容。演示代码里2048足够了生产环境我通常直接设成65535后面会解释原因。recvfrom返回两个值data是收到的bytesaddr是发送方的(ip, port)元组。这个addr是内核从IP头和UDP头里解析出来的服务端不需要自己维护连接表。也就是说UDP天然支持多个客户端同时发消息每次while循环拿到的是谁就把回包发给谁。2.4 客户端sendto之后等待回包import socket client socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server_addr (127.0.0.1, 8888) msg hello udp client.sendto(msg.encode(utf-8), server_addr) data, addr client.recvfrom(2048) print(data.decode(utf-8)) client.close()客户端通常不需要手动bind固定端口操作系统会自动分配一个临时端口作为源端口服务端通过recvfrom拿到的addr就是它回包时使用的目标地址。sendto的返回值是发送的字节数但这个数字只代表内核接收了这串数据代表网卡驱动拿到了它绝对不等于对方已经收到。在收到服务端回包之前没有任何机制能保证数据报已经送达。这一点和TCP完全不同TCP的send成功意味着数据已经进入连接但UDP的sendto成功基本只能说明本机发送路径是通的。客户端的recvfrom是阻塞的。如果服务端没回包程序会一直卡在这里。我强烈建议给客户端设置超时client.settimeout(3.0) try: data, addr client.recvfrom(2048) except socket.timeout: print(timeout, no response)从这一步开始你其实已经踏上了UDP可靠性设计的第一级台阶超时重试。2.5 自己动手跑一遍观察现象开两个终端先运行服务端再运行客户端。服务端窗口会打印收到的消息内容和来源地址客户端窗口会打印服务端回显的内容。整个流程没有listen()没有accept()没有三次握手两边没有建立任何“连接”。服务端只是把socket挂在端口上等待客户端把数据报扔出去就完事了。第一次跑通这段代码的人都会觉得UDP确实清爽但请务必记住清爽的代价是任何一层网络设备都能在不通知你的情况下把数据报扔掉。3. 隐藏在这些基础代码后的核心细节3.1 报文边界UDP最迷人的特点也是事故高发地TCP是字节流连续发送两次数据接收方一次read可能把两段数据一起读走也可能一次只读到半段。为了解决这个问题TCP应用层通常要设计消息帧、长度前缀、粘包拆包逻辑。UDP不需要做这种事。一次sendto对应一个数据报recvfrom恰好接收一个完整数据报。你收到一个包它就是一整包不会出现TCP里那种“只读了一半”的语义。这也是为什么很多从TCP转过来的人会很不适应UDP这边没有粘包问题不需要像TCP那样维护半包状态机。反过来边界也意味着残酷如果你发送10000字节接收方用2048字节的缓冲区去recvfrom那么只会收到2048字节剩下的7952字节被内核丢弃下次recvfrom也读不到剩余部分。所以设计协议时要么限制数据报大小要么保证接收缓冲区足够大。梳理清楚这个边界调试时至少可以排除30%的怪问题。3.2 缓冲区长度选多大从MTU说起以太网的MTU默认是1500字节。在普通IPv4网络中IP头20字节UDP头8字节理论上用户数据最多1472字节才不会触发IP分片。超过这个值IP层会把数据报切成多个分片接收方再做重组。问题在于分片只要丢一片整个数据报就会被全部丢弃UDP本身又没有重传所以实际工程里大UDP包更容易丢。就接收缓冲区而言IPv4 UDP单个数据报的最大有效载荷是65507字节。把recvfrom的缓冲区设成65535可以保证任何合法的UDP数据报都不会因为缓冲区太小而被截断。但发送时要清醒一点不管接收缓冲区多大发送超过一个合理阈值的UDP包在网络传输阶段就可能因为分片而被静默丢弃。常见做法是普通业务把单个数据报控制在MTU内比如1200到1400字节需要传大块数据时应用层拆包给每个数据报加上序号和总数接收方负责按顺序重组并检查丢包。这个思路是后面做可靠UDP传输的基础。3.3 bind与端口谁在监听谁在发包服务端bind固定端口是为了让客户端有明确的投递目标。客户端通常不bind让内核随机分配一个源端口服务端通过addr把回包发给这个临时端口。如果客户端也手动bind了固定端口就容易被多个socket争抢。同一端口默认只能被一个socket使用重复bind会报Address already in use。服务端频繁重启时也可能遇到端口没有及时释放的情况。在Linux下可以给udp socket设置setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)再bind很多局域网服务都会这么做。Windows下同名选项的行为略有差别别把它当成万能钥匙换了平台要重新验证。多进程共享同一个UDP端口在Linux下是可行的高级玩法。开启SO_REUSEADDR后多个socket可以bind同一个端口内核把收到的数据报分配给其中一个socket。注意是分配给其中一个不是广播给所有socket。这在多进程并发处理UDP流量时很有用但初学阶段不要求掌握。3.4 超时、阻塞与非阻塞默认socket是阻塞模式recvfrom没有数据就会停在那里。客户端如果一直等一个永远不会来的回包程序就死等。三种常见解法settimeout设置超时异常setblocking(False)改成非阻塞配合select/poll在可读时再recvfrom或者把收发逻辑丢到独立线程里。初学阶段优先用settimeout简单直接。超时时间我给的建议是局域网环境可以设1秒公网环境建议2至3秒重试次数看业务容忍度通常3次就足够。有人为了追求“响应快”把超时设成0.1秒结果网络稍微抖动一下就直接误判反而制造了很多无意义的重发。3.5 广播与组播UDP的额外武器UDP除了点对点还能直接广播。把消息发到255.255.255.255整个局域网内监听同一端口的所有进程都能收到。发送端需要先设置SO_BROADCAST选项client.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)这个特性特别适合做设备发现。我在一个模拟项目里做过局域网设备自动发现设备启动后向广播地址发一条JSON消息主控端监听9999端口就能自动收集所有设备的IP和状态完全不用手写配置。广播的缺点是几乎所有路由器都不会跨网段转发广播包所以广播只能覆盖同一局域网。如果你想做更大范围的组播比如跨网段给一组特定成员发消息那就要去了解IGMP和组播地址。这已经超出基础篇的范畴先掌握点对点和广播组播留作进阶目标。4. 我踩过的坑和排查工具箱4.1 客户端显示发送成功服务端就是收不到这个坑排在UDP新手踩坑榜第一名。sendto返回值正常本地网络也没有报错但服务端窗口什么都没打印。我最初遇到时差点怀疑操作系统坏了。原因通常集中在几个方向服务端bind的地址和客户端发送的目标地址不在同一个网段服务端绑定了127.0.0.1而客户端发往局域网IP防火墙拦截了UDP端口客户端发往广播地址但服务端绑定了具体IP虚拟机环境里网络隔离策略和宿主机不互通。排查顺序建议先用127.0.0.1回环测试排除中间网络设备再换成局域网IP测试最后检查防火墙入站规则。UDP没有连接状态防火墙很难靠状态识别放行所以需要单独为端口配置允许规则。很多从TCP转过来的人第一次倒就倒在这里。4.2 recvfrom读出来的数据乱码、截断、对不上号乱码最常见的原因是两端编码不一致。发送端如果用的是gbk或gb2312接收端decode(utf-8)很容易直接抛UnicodeDecodeError。这类错误其实是在提醒你协议设计里没有约定编码。解决方法是两端统一用utf-8或者在自定义报文头里带一个字节的编码标识。截断问题则来自缓冲区不够大。接收端缓冲区只要够大永远不会出问题但如果缓冲区偏小数据一多就会丢尾巴而且不是每次都复现特别隐蔽。我现在的习惯是接收缓冲区默认设成65535先把截断这种变量干掉再专心排查其他问题。4.3 端口占用为什么刚才还能启动重启就报错UDP没有TCP那种TIME_WAIT状态端口占用通常是因为进程还活着socket还没关闭。但有一种情况很容易让新手困惑CtrlC退出服务端后立刻重启偶尔也会报Address already in use。这和操作系统释放端口需要一点时间有关解决办法也不复杂在bind之前加一行server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)设置完之后同一端口在Linux下可以更快复用。如果是在Windows上UDP socket使用SO_REUSEADDR的行为有一些差异甚至可能允许两个socket绑定同一端口导致数据竞争所以生产环境一定要在目标平台做充分验证不要想当然。4.4 接收端缓冲区溢出与丢包率飙升如果服务端处理速度跟不上客户端发包速度内核接收缓冲区会被占满。缓冲区满了之后新到的数据报会被直接丢弃。表现是客户端越积极服务端响应越慢丢包率反而越高像极了团队里所有人都忙着回消息导致没人干活。临时缓解手段是调大内核接收缓冲区。Python里可以这样设置server.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1048576)但真正的解法在应用层。不要让主循环一边收包一边同步处理耗时逻辑收到数据后先扔进队列由独立线程或异步任务去消费。处理不过来的请求宁可明确丢弃并记录日志也不要硬堵在接收路径上。4.5 UDP编程问题速查表现象可能原因排查与处理建议sendto成功但对方没收到防火墙拦截、目标地址错误、bind地址不匹配先用127.0.0.1回环排除网络问题再查防火墙入站规则数据乱码或抛编码异常两端编码不一致统一utf-8或协议头里带上编码标识数据被截断recvfrom缓冲区小于发送数据报接收缓冲区直接设65535发送超大数据时拆包重启服务报端口占用旧进程未结束或端口未释放确认进程退出bind前设置SO_REUSEADDR收包延迟越来越大最终丢包socket接收缓冲区溢出调大SO_RECVBUF把耗时处理移出接收线程只能收到部分客户端消息服务端阻塞在同步处理逻辑上收包后立即入队异步处理公网UDP丢包严重MTU分片、运营商丢包策略数据报控制在1472字节以内应用层超时重试5. 把UDP编程从“会调用API”变成“能上线”5.1 设计应用层协议所有可靠性工作的前提裸UDP发消息很容易真正能上线却必须有一个应用层协议。我给自己定的最小协议格式包括固定魔数、版本号、消息类型、序列号、时间戳、数据长度最后才是原始数据。看起来字段很多但有了序列号才能发现丢包有了消息类型才能区分请求、响应和心跳有了长度字段才能做后续校验和解析。这些设计不是凭空来的。早期的模拟项目里我只发纯字符串客户端自动重发结果服务端把重发请求当成新请求处理数据不就重复了吗。后来加上唯一请求ID和服务端去重系统才正常。UDP的不可靠不只是丢包还有重复包和乱序包协议设计必须一开始就把这三种情况都算进去。5.2 一个简易的超时重传模板下面这段代码是一个可以复用的UDP发送函数带超时重试import socket import time def send_with_retry(sock, data, dest, max_retries3, timeout2.0): sock.settimeout(timeout) for i in range(max_retries): sock.sendto(data, dest) try: resp, addr sock.recvfrom(65535) return resp, addr except socket.timeout: continue raise TimeoutError(no response after retries)超时设2秒、重试3次这是我在不同网络环境测下来比较折中的一组参数。局域网内UDP往返通常是毫秒级公网偶尔几百毫秒2秒足够覆盖正常的抖动3次重试能避免一次网络抖动就误判失败。如果你做的是下单或状态更新这类敏感操作不要只做无脑重发还要在协议里带请求ID让服务端能够去重否则重试本身就是新的故障源。5.3 后续可以继续深挖的三个方向基础API跑通之后下一步可以从三个方向深入第一个方向是研究QUIC的可靠传输模型理解它如何在UDP上重建连接、加密和乱序处理第二个方向是看KCP这类轻量可靠UDP方案体验在UDP之上自己控制RTT和重传的灵活性第三个方向是做一次多人实时状态同步的小项目比如用UDP广播同步多个程序的位置和状态亲手重造一遍“应用层可靠性”的轮子。UDP被人误解的地方是它“太简单”。但真用起来你会发现TCP的复杂度被内核封装掉应用层很省心UDP则把选择权还给了开发者要不要重传、要不要过期丢弃、要不要容忍乱序全由业务需求决定。这种自由度才是UDP编程的真正门槛。我写这段学习记录时最大的体会是不要试图让UDP变得像TCP一样稳而是学会在“不可靠”的语义上设计出适合业务的可靠性规则。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。