Python UDP套接字编程入门:从零实现本地消息收发Demo
1. 为什么我劝你先从UDP入手而不是直接啃TCP很多人学socket编程第一反应是去啃TCP的三次握手、四次挥手结果被一堆状态机绕得头晕眼花最后连一个能跑通的Demo都写不出来。我带过不少新人发现一个很普遍的现象大家把网络编程这四个字想得太重了总觉得必须先搞懂协议底层才能动手写代码。其实完全反过来了——你应该先用一个能跑起来的最小案例建立直觉再去补理论。UDP就是那个最适合建立直觉的切入点。它没有连接建立的过程没有重传机制没有拥塞控制你发出去一个数据包它就尽力帮你送到送不到也不告诉你。听起来好像很不靠谱但恰恰因为这种简单你能把注意力集中在socket API本身的使用逻辑上怎么创建套接字、怎么绑定地址、怎么收发数据、怎么关闭资源。这些操作在TCP里也是一样的套路只是多了连接管理而已。这篇文章面向的是刚接触网络编程的Python开发者或者之前一直用HTTP库、从来没碰过原生socket的人。我会用一个本地消息收发的案例把UDP传输的完整链路拆开讲清楚包括代码怎么写、为什么这么写、实际跑的时候会遇到什么坑。看完之后你至少能做到三件事独立写出UDP收发端代码、理解每个API调用背后的含义、知道UDP适合什么场景不适合什么场景。提醒本文所有代码都在本地回环地址上运行仅用于学习socket编程的基本原理不涉及任何实际网络部署场景。2. UDP套接字的工作方式先把寄信这个类比吃透2.1 无连接到底意味着什么UDP的无连接用生活化的类比来说就是寄平信。你把信写好、装上信封、写上收件地址往邮筒里一扔就完事了。你不需要提前跟收件人打招呼说我要给你寄信了也不需要对方签收确认。信可能到也可能在路上丢了邮局不负责追踪。TCP则像打电话。你得先拨号对方接了双方建立通话通道然后才能说话。说话过程中如果信号不好系统会自动帮你重传丢失的语音包你不需要关心。挂电话之前还要说一声拜拜来释放连接。这个区别直接决定了编程模型的不同。UDP的发送端和接收端是完全独立的发送端甚至不需要知道接收端是否在运行。你启动发送端往某个地址发数据如果那个地址上没有程序在监听数据包就被丢弃了发送端不会收到任何错误提示除非你用了某些特殊的socket选项。这一点跟TCP完全不同——TCP连接如果目标端口没有监听你会立刻收到一个连接拒绝的错误。2.2 数据报边界UDP的一封一封信UDP还有一个很重要的特性它是面向数据报的。什么意思呢你调用一次sendto()发送100字节接收端调用一次recvfrom()就会收到这100字节不会多也不会少。这跟TCP的字节流模型完全不同——TCP是水流你发100字节可能分三次到达接收端读到的可能是305020。这个特性在实际编程中影响很大。用UDP的时候你必须自己保证每次发送的数据是一个完整的、有意义的消息单元。如果你试图发送一个很大的数据比如超过MTU的几万字节UDP会帮你分片但接收端需要自己重组而且只要有一个分片丢了整个数据报就废了。所以实践中UDP单次发送的数据一般控制在1400字节以内保守一点用1024字节避免IP层分片。2.3 核心API一览在Python里用UDP核心就是socket模块的几个方法。我先把它们列出来后面再逐个展开方法作用使用方socket.socket(AF_INET, SOCK_DGRAM)创建UDP套接字收发双方bind((host, port))绑定本地地址和端口接收方必须发送方可选sendto(data, addr)发送数据到指定地址发送方recvfrom(bufsize)接收数据并获取发送方地址接收方close()关闭套接字释放资源收发双方这里有个容易混淆的点SOCK_DGRAM对应UDPSOCK_STREAM对应TCP。DGRAM是datagram数据报的缩写STREAM是字节流的意思。记住这两个参数创建套接字的时候就不会搞错。3. 从零写一个UDP收发Demo每一步都在干什么3.1 接收端的完整实现先看接收端代码因为接收端必须先跑起来发送端才有目标可发。import socket # 创建UDP套接字 # AF_INET 表示使用IPv4地址族 # SOCK_DGRAM 表示使用UDP协议数据报 recv_sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 绑定地址和端口 # 0.0.0.0 表示监听本机所有网卡 # 9999 是自定义端口号选1024以上避免权限问题 recv_sock.bind((0.0.0.0, 9999)) print(接收端已启动等待消息...) try: while True: # recvfrom 返回两个值数据和发送方地址 # 1024 是缓冲区大小表示一次最多接收1024字节 data, client_addr recv_sock.recvfrom(1024) # data是bytes类型需要解码成字符串 message data.decode(utf-8) print(f收到来自 {client_addr} 的消息: {message}) # 可选回复一条确认消息 reply f已收到你的消息: {message} recv_sock.sendto(reply.encode(utf-8), client_addr) except KeyboardInterrupt: print(\n接收端关闭) finally: recv_sock.close()这段代码有几个细节值得展开说。第一bind的地址用0.0.0.0而不是127.0.0.1。0.0.0.0表示绑定本机所有网络接口这样无论是从本机回环地址还是局域网地址发来的数据都能收到。如果你只写127.0.0.1那就只有本机自己发的数据能收到局域网内其他机器发的就收不到了。学习阶段两者都行但理解这个区别很重要。第二端口号选了9999。为什么不用80或者443因为在Linux和macOS上绑定1024以下的端口需要管理员权限。你如果写bind((0.0.0.0, 80))普通用户运行会直接报PermissionError。所以练习的时候选1024以上的端口9999、8888、6666这些都行只要不被其他程序占用。第三recvfrom(1024)的1024是缓冲区大小。如果发送方发的数据超过1024字节超出的部分会被截断丢弃在UDP里。所以这个值要根据你的实际数据大小来设。一般聊天消息设1024或2048足够了如果要传文件块可以设65535UDP理论最大数据报大小。第四data.decode(utf-8)这一步不能省。socket收发的都是bytesPython 3里str和bytes是严格区分的。发送端编码用什么字符集接收端就得用同样的字符集解码否则会乱码或者报UnicodeDecodeError。统一用utf-8是最省事的做法。3.2 发送端的完整实现发送端比接收端还简单因为它不需要bind系统会自动分配一个随机端口。import socket # 创建UDP套接字 send_sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 目标地址接收端的IP和端口 target_addr (127.0.0.1, 9999) try: while True: message input(请输入要发送的消息输入quit退出: ) if message.lower() quit: break # 编码后发送 send_sock.sendto(message.encode(utf-8), target_addr) # 接收对方的回复 # 设置超时避免对方没回复时一直卡住 send_sock.settimeout(3) try: reply, server_addr send_sock.recvfrom(1024) print(f收到回复: {reply.decode(utf-8)}) except socket.timeout: print(等待回复超时) except KeyboardInterrupt: print(\n发送端关闭) finally: send_sock.close()发送端这里我加了一个settimeout(3)这是个很实用的技巧。默认情况下recvfrom是阻塞的如果对方没回复你的程序就永远卡在那里。设置3秒超时后超过3秒没收到回复就抛socket.timeout异常你可以捕获它然后继续。这在调试阶段特别有用不然你都不知道是对方没收到还是对方没回复。3.3 跑起来的正确顺序很多人第一次跑的时候会搞反顺序先启动发送端再启动接收端结果发送端发出去的消息全部丢失因为接收端还没绑定端口。正确的顺序是先在一个终端窗口运行接收端脚本看到接收端已启动等待消息...的提示再在另一个终端窗口运行发送端脚本在发送端输入消息按回车观察接收端窗口是否打印出收到的消息观察发送端窗口是否打印出回复消息如果你是在同一台机器上测试目标地址用127.0.0.1就行。如果想在局域网内两台机器之间测试把发送端的target_addr改成接收端机器的局域网IP比如192.168.1.100同时确保接收端bind的是0.0.0.0并且两台机器在同一个网段、防火墙没有拦截。注意如果局域网测试收不到消息九成是防火墙的问题。Windows Defender防火墙默认会拦截入站的UDP数据。你可以在防火墙设置里为Python解释器添加一条入站规则允许UDP流量通过。这只是本地测试环境的配置实际部署时请遵循所在网络的安全管理规范。4. 那些文档里不会写的坑我踩过的五个典型问题4.1 端口被占用Address already in use这是新手遇到最多的报错。你运行接收端脚本报OSError: [Errno 98] Address already in use。原因通常有两种一是你之前运行的接收端还没完全退出比如用CtrlZ挂起了而不是CtrlC终止端口还被占着二是你频繁重启接收端操作系统还没来得及释放端口。解决办法有几个。最简单的就是换一个端口号9999被占了就用9998。但如果你非要复用同一个端口可以设置SO_REUSEADDR选项recv_sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) recv_sock.bind((0.0.0.0, 9999))SO_REUSEADDR的作用是允许套接字绑定到一个处于TIME_WAIT状态的地址。虽然UDP没有TIME_WAIT状态那是TCP的概念但在某些操作系统上这个选项对UDP也有帮助。更彻底的办法是找到占用端口的进程并杀掉它。在Linux/macOS上用lsof -i :9999在Windows上用netstat -ano | findstr 9999找到PID然后结束那个进程。4.2 中文乱码编码不一致的隐蔽问题如果你发送端用encode(utf-8)接收端用decode(gbk)中文就会变成乱码。更隐蔽的情况是发送端和接收端都用了默认编码但在不同操作系统上Python的默认编码可能不同。Windows中文版的默认编码历史上是gbkLinux和macOS通常是utf-8。我的建议是永远显式指定utf-8不要依赖默认值。发送端写message.encode(utf-8)接收端写data.decode(utf-8)。这样无论代码跑在什么系统上行为都是一致的。如果收到的数据可能包含非utf-8的内容可以用decode(utf-8, errorsreplace)这样遇到无法解码的字节会用替换字符代替而不是直接抛异常导致程序崩溃。4.3 数据被截断缓冲区大小的陷阱前面提过recvfrom(1024)的1024是缓冲区大小。如果你发送的数据超过这个值接收端只会收到前1024字节剩下的直接丢弃。而且UDP不会告诉你数据被截断了你拿到的是一个不完整的数据如果直接decode可能报错也可能得到一个看似正常但实际残缺的字符串。怎么判断数据有没有被截断一个实用的技巧是发送端在消息末尾加一个结束标记接收端检查这个标记是否存在。或者更简单粗暴——把缓冲区设大一点比如65535这是UDP数据报的理论最大值。但要注意缓冲区设太大不会有什么性能问题只是多占一点内存而已。# 接收端把缓冲区设大 data, client_addr recv_sock.recvfrom(65535)4.4 发送端不bind会怎样发送端不调用bind操作系统会自动分配一个随机端口。这通常没问题但有一种情况会出问题如果接收端需要主动给发送端回消息它需要知道发送端的地址。recvfrom返回的client_addr里包含了发送端的IP和端口所以接收端可以直接用这个地址回复。但如果你希望发送端每次都使用固定的端口比如为了绕过某些防火墙规则或者为了让接收端能识别特定的发送方那就需要显式bindsend_sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) send_sock.bind((0.0.0.0, 8888)) # 发送端也绑定一个固定端口注意发送端bind的端口不能和接收端相同如果在同一台机器上否则会冲突。4.5 阻塞与超时程序卡死的根源默认情况下recvfrom是阻塞调用。如果没有任何数据到达程序就停在那行代码不动了。这在简单的请求-响应模式里问题不大但在复杂的交互场景里会导致程序假死。除了前面提到的settimeout还有一种非阻塞模式send_sock.setblocking(False)设置非阻塞后recvfrom如果没有数据会立刻抛BlockingIOError异常你需要用try-except捕获它然后继续做别的事。这种方式适合需要在等待数据的同时处理其他任务的场景但代码会复杂一些。对于初学者我建议先用settimeout够用且好理解。5. UDP到底适合什么场景判断与选型思路5.1 适合UDP的三类场景第一类是实时性优先、能容忍少量丢包的场景。比如局域网内的设备发现、心跳广播、实时游戏中的位置同步。这些场景里丢一两个包用户几乎感知不到但如果因为重传导致延迟增加体验反而更差。第二类是一对多或多对一的广播/组播场景。UDP天然支持广播发送到255.255.255.255和组播发送到D类地址一个发送端可以同时向多个接收端发送数据。TCP只能一对一要做广播得自己维护多个连接效率低很多。第三类是简单的请求-响应查询。比如DNS查询就是典型的UDP应用——客户端发一个域名查询请求服务器回一个IP地址一问一答简单高效。如果丢了客户端自己超时重发就行不需要TCP那种复杂的重传机制。5.2 不适合UDP的场景文件传输、网页浏览、邮件发送这些场景不适合UDP。因为这些场景要求数据完整、有序、不丢失。用UDP的话你得自己在应用层实现确认、重传、排序、去重相当于把TCP的活重新干一遍而且大概率干得不如TCP好。还有一种情况要注意如果你的数据包需要经过多个路由器、跨越公网UDP的丢包率可能会比较高。有些网络设备会对UDP流量做限速或者直接丢弃。这种环境下除非你有明确的理由必须用UDP否则还是用TCP更稳妥。5.3 一个简单的选型对照表判断维度选UDP选TCP数据完整性要求可容忍少量丢失必须完整实时性要求高不能等重传可以等连接模式一对多/广播一对一实现复杂度简单但需自己处理可靠性复杂但可靠性由协议保证典型应用DNS、视频流、游戏同步HTTP、文件传输、邮件这张表不是绝对的实际选型还要考虑具体网络环境、数据量大小、开发成本等因素。但对于初学者来说先按这个框架来判断基本不会选错。6. 把Demo改造成一个能用的局域网聊天工具6.1 需求拆解前面那个一问一答的Demo太简陋了只能发一条收一条。我们把它改造成一个更像样的工具两个程序都能同时发消息和收消息不需要轮流来。这就需要用多线程——一个线程负责收一个线程负责发。6.2 接收线程的实现import socket import threading def receive_loop(sock): 持续接收消息的线程函数 while True: try: data, addr sock.recvfrom(2048) print(f\n[来自 {addr[0]}:{addr[1]}] {data.decode(utf-8)}) print(请输入消息: , end, flushTrue) except OSError: break # 创建套接字并绑定 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((0.0.0.0, 9999)) # 启动接收线程 recv_thread threading.Thread(targetreceive_loop, args(sock,), daemonTrue) recv_thread.start() # 主线程负责发送 target (127.0.0.1, 8888) # 对方的地址 print(聊天工具已启动输入消息后回车发送) while True: msg input(请输入消息: ) if msg.lower() quit: break sock.sendto(msg.encode(utf-8), target) sock.close()这里有几个关键点。daemonTrue表示这个线程是守护线程主线程退出时它会自动结束不需要手动join。flushTrue在print里确保提示信息立刻显示出来不然可能被缓冲区卡住。接收线程里捕获OSError是为了在套接字关闭时优雅退出不然会报一堆异常。6.3 两台机器互聊的配置如果要在两台机器上互聊每台机器都要跑这个程序但配置要对称机器Abind端口9999target指向机器B的IP和端口8888机器Bbind端口8888target指向机器A的IP和端口9999这样A发给B的消息到达B的8888端口B的接收线程在8888上监听就能收到。B回复的消息发到A的9999端口A的接收线程也能收到。两边各绑一个端口互不干扰。6.4 加一个简单的消息格式纯文本消息有个问题你分不清哪条消息是谁发的如果超过两个人。可以约定一个简单的消息格式比如用户名:消息内容接收端解析一下# 发送时 msg f{username}:{content} sock.sendto(msg.encode(utf-8), target) # 接收时 raw data.decode(utf-8) if : in raw: name, content raw.split(:, 1) print(f[{name}] {content}) else: print(f[未知] {raw})这只是一个最简陋的协议设计。真实的聊天协议要考虑消息类型、时间戳、消息ID、加密等等但作为学习练习这个程度足够了。7. 调试UDP程序时我常用的几个手段7.1 用tcpdump抓包确认数据有没有发出去当你怀疑数据没发出去或者没收到时抓包是最直接的验证手段。在Linux/macOS上sudo tcpdump -i lo udp port 9999 -X-i lo指定监听回环接口udp port 9999过滤UDP 9999端口的流量-X以十六进制和ASCII形式显示数据内容。如果你看到发送端执行sendto后tcpdump有输出说明数据确实发出去了如果接收端没收到那问题就在接收端的bind或者防火墙。Windows上可以用Wireshark图形化抓包操作更直观。7.2 打印socket的本地地址有时候你不确定socket绑到了哪个地址可以打印出来看print(sock.getsockname()) # 返回 (ip, port) 元组如果打印出来是(0.0.0.0, 9999)说明绑定成功。如果是(127.0.0.1, 9999)说明只绑了回环地址局域网访问不了。7.3 用一个最简单的回声测试当你怀疑代码逻辑有问题时先写一个最小的回声测试接收端收到什么就原样发回去。发送端发hello如果收到hello就说明链路通了。这个测试能快速排除编码问题、地址问题、端口问题把范围缩小到业务逻辑上。# 回声接收端 while True: data, addr sock.recvfrom(2048) sock.sendto(data, addr) # 原样发回7.4 注意操作系统的防火墙日志如果抓包看到数据发出去了但接收端就是收不到去查一下防火墙日志。Windows的事件查看器里有防火墙日志Linux上可以用iptables -L -v -n查看规则和计数器。很多时候问题就出在一条默认的DROP规则上。8. 从UDP再往前走接下来可以学什么UDP这个点打通之后你其实已经掌握了socket编程最核心的几个操作创建、绑定、发送、接收、关闭。接下来往哪个方向走取决于你的实际需求。如果你想做Web开发那应该去学TCP socket和HTTP协议的关系理解一个HTTP请求在socket层面是怎么发出去的。如果你想做物联网或者设备通信可以深入研究UDP组播和广播这是设备发现协议的基石。如果你想做游戏开发那需要了解UDP之上如何实现可靠传输比如KCP、QUIC这些协议的设计思路。但不管往哪个方向走我建议你先把这篇文章里的代码亲手敲一遍、跑一遍、改一遍。把端口号改一改把缓冲区大小调一调把编码换成gbk试试看会出什么问题。这种折腾的过程比看十篇文章都管用。网络编程是一门实践性极强的技能光看不动手永远学不会。我在带新人的时候经常说一句话socket编程的门槛不在理论而在调试。你知道sendto是发送、recvfrom是接收这只要五分钟。但你能在数据收不到的时候快速定位是绑定问题、防火墙问题还是编码问题这需要大量的实践积累。希望这篇文章能帮你少走一些弯路把精力花在真正有价值的调试经验积累上。