Python UDP Socket编程入门:从零实现简单信息传输
1. 为什么我建议你从UDP入手socket编程很多人学网络编程第一反应是去啃TCP三次握手、四次挥手结果被状态机绕得头晕眼花代码还没跑起来就先放弃了。我带过不少新人发现一个规律先拿UDP练手的人对socket的理解反而更透彻。原因很简单UDP把发送和接收这两件事拆得干干净净没有连接状态、没有重传机制、没有拥塞控制你写下的每一行代码都直接对应网络上的一个动作反馈特别快。这篇文章要聊的就是用Python的socket模块实现一套最简单的UDP信息传输。说白了就是两台机器或者同一台机器的两个进程之间互相发消息一个发、一个收或者双向对发。听起来简单但里面藏着不少新手容易踩的坑端口占用、缓冲区大小、阻塞与非阻塞、编码问题、数据报边界……这些细节在教科书里往往一笔带过实际写代码时却能让你卡上半天。适合谁看如果你刚学完Python基础语法想找个能立刻看到效果的网络编程练手项目这篇就是为你准备的。如果你已经写过TCP的echo服务想搞清楚UDP到底和它差在哪也能从这里找到答案。我会从整体设计思路讲起把每个参数为什么这么设、每行代码为什么这么写都掰开揉碎最后再分享几个我实际调试时踩过的坑。全程代码可直接复制运行不需要任何第三方库标准库socket就够了。2. 整体设计思路与方案选型2.1 为什么选UDP而不是TCP做入门练手先把这个说清楚免得有人觉得UDP不可靠学它有什么用。UDP全称是用户数据报协议它和TCP最大的区别在于UDP不建立连接直接把数据打包扔出去。你可以把它想象成寄明信片——写上地址丢进邮筒就完事对方收没收到、路上丢没丢寄件人默认不管。TCP则像打电话先拨号、等对方接听、确认双方都在线然后才说话。对于入门练习来说UDP有三个天然优势。第一代码结构极简服务端只需要一个socket绑定端口客户端直接sendto没有listen、accept、connect这些环节。第二调试直观你发一条消息对面立刻能收到中间没有握手延迟出问题也容易定位。第三天然支持一对多一个服务端可以同时接收多个客户端发来的数据报不需要为每个客户端开线程这对理解数据报这个概念特别有帮助。当然UDP的缺点也得心里有数它不保证送达、不保证顺序、不保证不重复。所以它适合的场景是那些丢一两个包无所谓的场合比如视频直播、在线游戏的位置同步、DNS查询、日志上报。反过来文件传输、网页请求这种一个字节都不能少的场景就得用TCP。我个人的建议是先用UDP把socket的基本API摸熟再去学TCP你会发现TCP多出来的那些步骤每一步都有它存在的理由。2.2 核心API选型与职责划分Python的socket模块提供了两套UDP相关的调用方式一套是sendto/recvfrom另一套是send/recv配合connect。这两套都能用但适用场景不同我一般这样区分调用方式是否需要connect适用场景特点sendto/recvfrom不需要一个socket对多个目标收发每次都要指定地址灵活send/recv需要先connect固定和一个目标通信代码简洁性能略好对于简单信息传输这个需求我推荐用sendto/recvfrom这一套。原因是它把目标地址显式地写在每次调用里你能清楚地看到数据发往哪里、来自哪里对理解UDP的无连接特性非常有帮助。等你以后要写一个服务端同时响应多个客户端这套API直接就能用不用改结构。整个方案的角色划分是这样的服务端负责绑定一个固定端口然后循环调用recvfrom等待数据收到后可以选择回一条消息客户端不需要绑定端口操作系统会自动分配一个临时端口直接sendto把数据发到服务端的地址然后recvfrom等待回复。这个请求-响应模型虽然简单但已经涵盖了UDP通信的全部核心动作。2.3 地址与端口的规划原则地址这块有个新手常犯的错误把服务端绑定到127.0.0.1然后换一台机器就连不上了。这里要区分两个概念127.0.0.1是回环地址只有本机自己能访问适合本机测试。0.0.0.0表示绑定本机所有网卡局域网内其他机器可以通过你的实际IP访问。你的实际局域网IP比如192.168.x.x也可以绑定但换网络环境就会失效。我的建议是开发调试阶段用127.0.0.1需要跨机测试时改成0.0.0.0。端口方面尽量选1024以上的因为1024以下通常需要管理员权限。我习惯用9999、8888这类好记的端口做练习但要注意别和你机器上已经跑着的服务撞车。判断端口是否被占用后面排查章节会讲具体方法。3. 核心细节解析与实操要点3.1 socket对象的创建与参数含义创建UDP socket的标准写法是import socket s socket.socket(socket.AF_INET, socket.SOCK_DGRAM)这两个参数必须理解透。AF_INET表示使用IPv4地址族对应的还有AF_INET6IPv6和AF_UNIX本机进程间通信。SOCK_DGRAM表示数据报套接字也就是UDP如果是SOCK_STREAM那就是TCP。这两个参数的组合决定了你拿到的是什么样的socket写错了后面所有调用都会报错。有个细节值得说创建socket时其实还可以传第三个参数protocol但通常省略因为AF_INETSOCK_DGRAM已经唯一确定了UDP协议。我见过有人画蛇添足写protocolsocket.IPPROTO_UDP虽然没错但没必要。创建完socket后服务端必须绑定端口客户端可以不绑定。这是UDP和TCP一个很大的不同点。TCP客户端调用connect时系统会隐式绑定UDP客户端如果直接sendto系统也会自动分配一个临时端口。但如果你希望客户端也能收到别人主动发来的消息那就得显式bind一个端口。3.2 数据编码与缓冲区大小的选择UDP传输的是字节流Python的字符串必须先编码成bytes才能发送。最常用的是UTF-8msg 你好这是一条测试消息 data msg.encode(utf-8)接收端拿到bytes后要解码回字符串text data.decode(utf-8)这里有个坑如果发送端和接收端编码方式不一致就会乱码或者抛异常。我建议统一用UTF-8它是目前兼容性最好的方案。另外decode的时候可以加errorsignore或errorsreplace来容错避免因为个别字节损坏导致整个程序崩溃。缓冲区大小是另一个关键参数。recvfrom(1024)里的1024表示一次最多接收1024字节。如果发送的数据超过这个长度超出的部分会被直接丢弃而且不会有任何提示。UDP的理论最大数据报长度是65507字节65535减去IP头和UDP头但实际网络中超过MTU通常1500字节的数据报会被分片分片后只要丢一片整个数据报就废了。所以我的经验是练习阶段缓冲区设1024或2048足够真要传大块数据自己在上层做分片和重组别指望UDP帮你搞定。3.3 阻塞模式下的收发行为默认创建的socket是阻塞的。这意味着调用recvfrom时如果没数据到达程序会一直卡在那里直到收到数据或者出错。这对简单的请求-响应模型没问题但如果你想同时做别的事就会很尴尬。举个例子服务端在recvfrom上阻塞着你想让它每隔几秒打印个心跳做不到。解决办法有两个一是用settimeout设置超时超时后抛socket.timeout异常你捕获后继续循环二是用多线程或select做IO多路复用。对于入门练习我推荐先用settimeout简单直接s.settimeout(5.0) # 5秒超时 try: data, addr s.recvfrom(1024) except socket.timeout: print(等待超时继续循环)注意settimeout设置的是每次recvfrom的等待时间不是整个程序的总时长。每次调用都会重新计时。3.4 数据报边界UDP和TCP最本质的区别这一点必须单独拎出来讲因为它是UDP编程思维的核心。TCP是字节流你发三次abc接收端可能一次收到abcabcabc也可能分十次收到边界完全由系统决定。UDP是数据报你发一次接收端就收一次一次sendto对应一次recvfrom边界天然保留。这意味着什么呢意味着你不需要在应用层做粘包处理。TCP里常见的消息长度前缀那套东西在UDP里根本不需要。但反过来如果你一次sendto发了1000字节接收端用recvfrom(100)去接只会拿到前100字节剩下900字节直接丢失。所以接收缓冲区一定要大于等于可能收到的最大数据报长度。我实测过一个案例发送端发了一条500字节的消息接收端缓冲区设成1024正常收到改成256就只收到256字节剩下的没了。这个特性决定了UDP编程里一发一收的对应关系特别清晰调试起来也容易。4. 完整实操过程与核心环节实现4.1 服务端实现绑定、接收、回复先上完整代码然后逐段拆解import socket def udp_server(host127.0.0.1, port9999): # 1. 创建UDP socket server_sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 2. 绑定地址和端口 server_sock.bind((host, port)) print(f[服务端] 已启动监听 {host}:{port}) # 3. 设置超时避免永久阻塞 server_sock.settimeout(30.0) try: while True: try: # 4. 接收数据同时拿到发送方地址 data, client_addr server_sock.recvfrom(2048) text data.decode(utf-8) print(f[服务端] 收到来自 {client_addr} 的消息: {text}) # 5. 回复一条消息 reply f已收到你的消息长度{len(data)}字节 server_sock.sendto(reply.encode(utf-8), client_addr) print(f[服务端] 已回复 {client_addr}) except socket.timeout: print([服务端] 等待超时继续监听...) continue except KeyboardInterrupt: print([服务端] 手动停止) finally: server_sock.close() if __name__ __main__: udp_server()逐段说明。第1步创建socket参数前面讲过了。第2步bind是服务端必须做的客户端不做也行。注意bind的参数是一个元组(host, port)host写字符串port写整数。第3步设超时这里设了30秒。为什么设这么长因为服务端通常要长时间运行超时太短会频繁打印等待超时刷屏。设30秒既能保证程序不会永久卡死又不会太吵。你也可以设成None表示永不超时但那样CtrlC之外没法优雅退出。第4步recvfrom返回两个值数据和发送方地址。地址是一个元组(ip, port)这个信息很重要因为UDP服务端要回复的话必须知道往哪发。这也是UDP和TCP的一个区别TCP的accept会返回一个新的连接socketUDP没有连接只能靠recvfrom拿到的地址来回复。第5步用sendto把回复发回去注意目标地址就是刚才拿到的client_addr。整个流程就是收一条、回一条非常清晰。4.2 客户端实现发送、等待、解析回复客户端代码import socket def udp_client(server_host127.0.0.1, server_port9999): # 1. 创建UDP socket不需要bind client_sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 2. 设置超时避免回复丢失时永久等待 client_sock.settimeout(5.0) try: while True: msg input(请输入要发送的消息输入quit退出: ) if msg.lower() quit: break # 3. 发送数据 client_sock.sendto(msg.encode(utf-8), (server_host, server_port)) print(f[客户端] 已发送: {msg}) # 4. 等待回复 try: data, server_addr client_sock.recvfrom(2048) print(f[客户端] 收到回复: {data.decode(utf-8)}) except socket.timeout: print([客户端] 等待回复超时可能服务端未启动或回复丢失) finally: client_sock.close() if __name__ __main__: udp_client()客户端第1步创建socket后没有bind这是故意的。操作系统会在第一次sendto时自动分配一个临时端口这个端口在程序运行期间保持不变所以服务端回复时能准确找到你。第2步设超时5秒。为什么客户端超时比服务端短因为客户端是交互式的用户发完消息等着看回复等太久体验差。5秒足够局域网内往返好几次了。第3步sendto把数据发到服务端地址。第4步recvfrom等待回复。注意这里有个潜在问题如果服务端没启动sendto不会报错UDP不建立连接发出去就不管了但recvfrom会一直等到超时。所以你会看到已发送打印了但迟迟没有回复最后打印超时提示。这是UDP的正常行为不是bug。4.3 双向通信与多客户端测试上面的模型是客户端发、服务端回已经能跑通。如果你想测试多个客户端同时发消息直接开多个终端运行客户端脚本就行。服务端不需要改任何代码因为它每次recvfrom都能拿到不同的client_addr回复时各回各的互不干扰。这就是UDP天然支持一对多的体现换成TCP你就得为每个连接开线程或者用select了。我实测过同时开三个客户端分别发AAA、BBB、CCC服务端依次收到并回复三个客户端各自收到自己的回复没有串线。这个实验很能说明问题UDP虽然不保证可靠但在局域网这种低丢包环境下实际表现相当稳定。如果你想做双向对发两边都能主动发消息那就把服务端和客户端的代码合并一下用一个socket同时做sendto和recvfrom配合多线程分别处理发送和接收。不过那是进阶内容了入门阶段先把请求-响应模型跑熟。4.4 参数计算与选择依据这里集中说一下几个关键参数是怎么定的。缓冲区大小2048我选2048而不是1024是因为要留余量。假设你发一条中文消息UTF-8编码下每个汉字占3字节1024字节只能装341个汉字。加上回复消息可能更长2048更保险。如果你确定消息很短1024也够。但记住缓冲区宁大勿小大了不浪费小了会丢数据。超时时间服务端30秒、客户端5秒这个前面解释过了。核心原则是交互式场景超时短后台服务超时长。端口9999选它是因为大于1024不需要权限又好记。实际项目中要避开已知服务端口比如80、443、3306、6379这些。练习用9999、9998、8888都行。编码UTF-8兼容性最好中英文都支持。如果你的消息全是ASCII用ascii也行但没必要省那点空间。5. 常见问题与排查技巧实录5.1 端口被占用怎么办报错信息通常是OSError: [Errno 98] Address already in use。原因有两种一是你上次运行的程序没完全退出端口还在TIME_WAIT状态二是别的程序占用了这个端口。排查方法Linux/macOS下lsof -i :9999 # 或者 netstat -anp | grep 9999Windows下netstat -ano | findstr 9999找到占用进程的PID后要么杀掉它要么换个端口。我个人的习惯是每次测试换个端口比如9999、9998、9997轮着用省得等TIME_WAIT。另外UDP其实很少出现TIME_WAIT因为UDP没有连接状态所以这个报错在UDP里更多是真的被别的程序占了。5.2 收不到消息的几种可能这是新手最常遇到的问题我整理了一个排查表现象可能原因排查方法客户端sendto正常服务端无反应服务端没启动或端口不对确认服务端已运行端口一致服务端收到但客户端收不到回复客户端防火墙拦截临时关闭防火墙测试跨机通信失败服务端绑定的是127.0.0.1改成0.0.0.0重新绑定收到乱码编码不一致两端统一用UTF-8收到部分数据缓冲区太小增大recvfrom参数跨机通信失败这个坑我踩过。当时服务端绑的127.0.0.1本机客户端能通换台机器就死活连不上。后来改成0.0.0.0立刻就好了。记住127.0.0.1只接受本机连接要让局域网其他机器访问必须绑0.0.0.0或者本机实际IP。5.3 消息截断与粘包问题前面讲过UDP不会粘包但会截断。如果你发现收到的消息比发的短八成是缓冲区小了。解决办法很简单把recvfrom的参数调大。但要注意UDP单个数据报最大65507字节超过这个数sendto会直接报错。另一个容易混淆的点有人把TCP的粘包概念套到UDP上以为要加长度前缀。完全不需要。UDP一次sendto就是一条完整消息接收端一次recvfrom就是一条边界清清楚楚。如果你非要在UDP上做分片传输大文件那得自己设计协议头记录序号和总长度但那是另一个话题了。5.4 程序无法退出的处理有时候CtrlC按了没反应程序卡在recvfrom上。这是因为阻塞调用不会响应键盘中断得等它超时或者收到数据。解决办法就是前面说的settimeout。设了超时后最多等超时时间就能退出。如果连超时都设了还是退不出检查一下是不是在finally里又调用了阻塞操作。我见过有人在finally里写recvfrom想收尾结果又卡住了。收尾操作只做close别做任何可能阻塞的事。提示调试阶段可以在recvfrom前后加print确认程序到底卡在哪一步。这招虽然土但特别管用。5.5 独家避坑经验三条第一条先跑通本机再试跨机。本机127.0.0.1通了说明代码逻辑没问题再去调网络和防火墙。顺序反了会浪费大量时间。第二条用两个终端分别看输出。服务端一个终端客户端一个终端两边日志对照着看谁没打印一目了然。别把两个程序塞一个终端里跑输出混在一起根本没法排查。第三条发送前先print确认数据。我遇到过编码报错导致sendto根本没执行的情况但程序没崩因为异常被吞了。后来养成习惯sendto之前先print一下要发的bytes确认没问题再发。6. 从练习到实战的扩展方向把上面这套跑通之后你可以往几个方向继续深入。第一个方向是加一个简单的应用层协议比如消息格式定为类型|内容服务端根据类型做不同处理这就有点RPC的味道了。第二个方向是用select做IO多路复用让一个服务端同时处理收发不用多线程。第三个方向是加心跳和超时重传在UDP之上自己实现可靠性理解TCP到底帮你做了哪些事。我个人在实际操作中的体会是UDP入门最大的价值不在于学会发消息而在于建立数据报和字节流的思维区分。很多人写网络程序出问题根源就是没搞清楚自己面对的是哪种模型。把UDP这套收发练熟再去写TCP你会对connect、listen、accept这些调用有更深的认同感而不是死记硬背。最后分享一个小技巧调试UDP的时候可以用nc -u 127.0.0.1 9999Linux/macOS自带当客户端手动发消息验证服务端是否正常。这比写脚本快多了改一行命令就能测一次。Windows下可以用PowerShell的Send-UdpDatagram或者干脆写个三行的Python脚本。工具不在多顺手就行。