
之前在做一些局域网内的实时工具时想用最轻量的方式实现两个节点互相发消息。第一反应是 TCP后来发现很多场景其实用不到 TCP 的可靠流式传输反而 UDP 的“无连接 数据报”模型更简单直接。本文就来完整拆解如何用 UDP 实现一个可运行的单聊程序覆盖协议差异、Socket API、多线程收发、退出机制和常见坑点。本文适合学过 Python 基础语法、想进一步接触网络编程的读者。看完之后你不仅能跑通一个 UDP 单聊程序还能理解 UDP 与 TCP 在程序设计上的本质差异知道为什么 UDP 丢包时需要自己做确认和重传以及在实际项目中如何给这种“裸 UDP 通信”加上可靠性和安全边界。1. 背景与核心概念UDP 单聊到底在解决什么问题1.1 什么是 UDP 单聊单聊就是两个终端之间的一对一消息通信。TCP 可以实现单聊UDP 同样可以实现单聊。区别在于协议的传输模型TCP 是面向连接的字节流双方必须先建立连接然后像读写文件一样收发数据UDP 是面向无连接的数据报发送方只需要知道对方的 IP 和端口就可以把一段独立的数据报发出去不需要提前“握手”。所以 UDP 单聊的本质是两个 socket 端点之间互相发送数据报每个数据报是一个完整的消息。由于没有连接状态服务端和客户端的身份不像 TCP 那样固定两个端点都可以主动给对方发消息也可以随时监听对方发来的消息。1.2 UDP 与 TCP 的核心差异要真正理解 UDP 单聊必须先分清 UDP 和 TCP 的几个关键差异。对比维度TCPUDP连接状态面向连接通信前必须三次握手无连接直接发送数据报传输单位字节流无消息边界数据报每个报文独立可靠性可靠传输支持确认、重传、排序不可靠不保证送达、不保证顺序传输效率有握手和确认开销相对慢无握手和确认开销相对快应用场景文件传输、网页访问、远程登录实时音视频、DNS 查询、局域网发现其中最关键的一点是“消息边界”。TCP 是流式协议发送方调用一次 send 发送的内容可能在接收方多次 recv 才能读完整也可能多次 send 的内容被一次 recv 读出来所以业务层必须自己定义消息边界。UDP 则不同每次 sendto 发送一个数据报接收方每次 recvfrom 读取一个完整的数据报消息边界由操作系统维护。对于聊天这种天然以“一条消息”为单位的场景UDP 的数据报模型反而更直观。另外UDP 的不可靠性也需要重点理解。UDP 不保证数据报一定能到达对端也不保证多个数据报的到达顺序和发送顺序一致。在局域网内丢包率往往很低所以很多人第一次跑 UDP 通信会觉得“很可靠”但这是一种错觉。在跨公网、跨 Wi-Fi、网络拥塞的场景下UDP 丢包和乱序问题会明显暴露出来。本文先实现一个基础可用的单聊程序第 6 章再讨论如何在应用层解决可靠性问题。1.3 UDP 单聊的典型应用场景UDP 单聊看起来不如 TCP 聊天那么“标准”但它在很多场景下非常实用局域网工具两个内网节点之间快速互发信令不需要建连不需要维护连接状态。实时性优先的通信比如游戏内的语音指令、视频通话的信令通道实时性要求高于可靠性要求。嵌入式与 IoT 设备很多物联网设备资源有限UDP 实现简单、占用资源少。自定义协议的原型验证先快速验证消息收发逻辑再决定是否要增加可靠层。如果你的场景需要严格的可靠消息、大文件传输、消息顺序保证那么请选择 TCP 或基于 TCP 的协议如 WebSocket、MQTT。如果只是轻量级消息互发且能接受消息偶尔丢失UDP 会是一个更简单的选择。2. 环境准备与前置知识2.1 运行环境本文示例使用 Python 实现因为它语法简洁、标准库直接支持 Socket 编程适合快速理解和验证 UDP 通信模型。项目说明操作系统Windows / Linux / macOS 均可Python 版本3.6 及以上依赖库仅使用 Python 标准库 socket、threading、sys环境要求本机或局域网内两台机器本文不再赘述 Python 安装步骤如果还没有 Python 环境可以从 Python 官网下载并安装安装时记得勾选“Add Python to PATH”。2.2 需要提前掌握的 Socket 概念在写代码之前先梳理几个必须掌握的 Socket 概念。IP 地址标识网络中的一台主机。本机回环地址是 127.0.0.1局域网地址通常是 192.168.x.x。端口标识主机上的一个网络进程。两个程序不能同时绑定同一个端口。Socket操作系统提供的一个网络通信句柄应用层通过它读写网络数据。AF_INET地址族表示使用 IPv4 地址。SOCK_DGRAM套接字类型表示使用数据报方式也就是 UDP。这些概念不需要背但要在看到代码时能对上号。接下来我会逐个拆解 UDP 编程中会用到的核心 API。3. UDP Socket 核心 API 拆解3.1 创建套接字socket.socket(AF_INET, SOCK_DGRAM)UDP 套接字的创建方式和 TCP 一样都是调用socket.socket()但类型参数不同。import socket udp_socket socket.socket(socket.AF_INET, socket.SOCK_DGRAM)这里的AF_INET表示 IPv4 地址族SOCK_DGRAM表示数据报套接字。创建成功后udp_socket就是一个可以用于 UDP 收发数据的对象。3.2 绑定地址bind()bind()的作用是把套接字绑定到一个本地地址和端口上。只有绑定后操作系统才知道把收到的 UDP 数据报交给哪个进程。udp_socket.bind((0.0.0.0, 8001))第一个参数是本地 IP 地址0.0.0.0表示监听本机所有网卡这样无论是从 127.0.0.1 还是局域网 IP 发来的数据报都能收到。如果只想监听回环地址可以改成127.0.0.1。第二个参数是本地端口号。注意如果端口已经被其他程序占用bind()会抛出OSError。另外UDP 不需要像 TCP 一样调用listen()因为 UDP 没有连接队列的概念。3.3 发送数据sendto()UDP 发送数据用的是sendto()它需要指定目标和端口。message 你好UDP.encode(utf-8) udp_socket.sendto(message, (127.0.0.1, 8002))sendto()的第一个参数是字节串所以字符串要用encode(utf-8)转成字节。第二个参数是一个(ip, port)元组表示对端的地址和端口。这里有一个重要细节UDP 的sendto()即使对端不存在本地也不会立刻报错。因为 UDP 是无连接协议发送方只负责把数据报交给操作系统至于数据报能不能到达对端发送方无法马上知道。这在调试时很容易造成“消息发出去了但对方没收到”的假象。3.4 接收数据recvfrom()UDP 接收数据用的是recvfrom()它会阻塞等待数据报到来并在收到数据后同时返回数据和发送方地址。data, addr udp_socket.recvfrom(1024)1024表示缓冲区大小单位是字节。UDP 是数据报协议一次recvfrom()读取一个完整的数据报。如果数据报的大小超过缓冲区大小超出的部分会被截断。所以在设计协议时要控制单条消息大小尽量小于缓冲区值。addr是发送方的(ip, port)元组。在单聊场景中我们可以通过addr判断消息是谁发来的方便后续做多端扩展。3.5 close() 与资源释放通信结束后需要调用close()关闭套接字释放系统资源。udp_socket.close()关闭后套接字不能再用于收发数据。实际项目中建议使用try-finally或with语法确保资源释放。3.6 可选 connectUDP 也有“连接”有些读者可能听说过 UDP 也可以调用connect()。这里特别说明一下UDP 的connect()并不是建立真正的连接它只是把对端地址保存到内核中之后就可以用send()和recv()代替sendto()和recvfrom()写法更简洁。udp_socket.connect((127.0.0.1, 8002)) udp_socket.send(message.encode(utf-8)) data udp_socket.recv(1024)调用connect()之后UDP 套接字只能和这个固定对端通信而且可以通过recv()收到对端返回的 ICMP 端口不可达错误从而更快感知对端异常。但本文的基础示例使用sendto()和recvfrom()因为这两个方法更直观地体现了 UDP 无连接、每次指定目标地址的特点。4. 单聊程序的设计与实现4.1 程序职责划分一个最简单的 UDP 单聊程序需要完成四件事创建 UDP 套接字并绑定本地端口。持续接收来自对端的消息并打印。持续读取用户输入并发送给对端。支持退出机制关闭套接字。由于接收消息和发送消息是两个相互独立的动作如果只有一个主循环就会出现“正在等待用户输入时无法接收消息”的问题。因此程序需要拆成两个线程主线程负责读取用户输入并发送消息。接收线程负责循环调用recvfrom()收到消息后打印。4.2 收发线程模型线程模型用文字描述是这样程序启动 | -- 创建 UDP Socket | -- bind 本地端口 | -- 启动接收线程循环 recvfrom | -- 主循环循环 input sendto | -- 用户输入 exit 或收到对方退出通知 | -- 退出程序接收线程设置为守护线程daemonTrue。守护线程的特点是当主线程结束时守护线程会自动终止。这样即使接收线程还阻塞在recvfrom()主线程退出后程序也能正常结束。4.3 数据格式与编码Socket 收发的是字节串而聊天内容是字符串所以需要统一编码。本文示例统一使用 UTF-8 编码发送时message.encode(utf-8)接收时data.decode(utf-8)关于消息格式这里使用最简单的纯文本消息。但在实际项目中建议设计为包含消息类型、消息序号、时间戳等字段的结构化格式例如 JSON 字符串或自定义二进制协议这样后期增加 ACK、心跳、文件传输等功能时更容易扩展。4.4 退出机制退出机制是单聊程序最容易忽略的地方。基础版本可以约定用户输入exit时退出程序同时向对端发送一条特殊消息__EXIT__通知对方自己已经离开。由于 UDP 不可靠这条__EXIT__消息不能保证一定送达。所以更严谨的设计是即使对方没有收到退出通知发送方也可以直接退出接收方之后检测到超时或心跳超时才判定对端离线。本文示例采用尽力通知的方式让读者理解“应用层协议约定”的概念。5. 完整可运行代码5.1 项目结构本文示例不需要复杂项目结构一个 Python 文件即可。udp_chat/ └── udp_chat.py在终端中两个聊天方分别运行这个脚本并传入不同的本地端口和对方地址。5.2 完整代码 udp_chat.py下面是完整代码可以直接复制保存为udp_chat.py运行。# 文件路径udp_chat/udp_chat.py import socket import threading import sys # 全局退出事件任何一方退出时用于通知另一个线程停止 exit_event threading.Event() def receive_message(udp_socket: socket.socket): 接收线程循环接收 UDP 数据报并打印 while not exit_event.is_set(): try: data, addr udp_socket.recvfrom(1024) message data.decode(utf-8) if message __EXIT__: exit_event.set() print(\n[系统] 对方已退出聊天按回车可退出。) break print(f\n[对方] {message}) print( , end, flushTrue) except OSError: # 套接字被关闭时退出接收线程 break except KeyboardInterrupt: exit_event.set() break def start_chat(local_port: int, remote_ip: str, remote_port: int): 启动 UDP 单聊主逻辑 # 1. 创建 UDP 套接字 udp_socket socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 2. 绑定本地端口0.0.0.0 表示监听本机所有网卡 udp_socket.bind((0.0.0.0, local_port)) # 3. 启动接收线程 receiver threading.Thread( targetreceive_message, args(udp_socket,), daemonTrue ) receiver.start() print(fUDP 单聊已启动本地端口 {local_port} - {remote_ip}:{remote_port}) print(输入消息回车发送输入 exit 退出。) try: # 4. 主线程循环读取用户输入并发送 while not exit_event.is_set(): text input( ) if not text.strip(): continue if text.strip().lower() exit: # 尽力通知对方自己已退出 udp_socket.sendto(__EXIT__.encode(utf-8), (remote_ip, remote_port)) exit_event.set() break # 5. 发送数据报 udp_socket.sendto(text.encode(utf-8), (remote_ip, remote_port)) except KeyboardInterrupt: print(\n[系统] 用户主动中断正在退出。) exit_event.set() finally: # 6. 关闭套接字释放系统资源 udp_socket.close() print([系统] 聊天已结束。) if __name__ __main__: if len(sys.argv) ! 4: print(用法: python udp_chat.py 本地端口 远端IP 远端端口) print(示例: ) print( A 端: python udp_chat.py 8001 127.0.0.1 8002) print( B 端: python udp_chat.py 8002 127.0.0.1 8001) sys.exit(1) start_chat( local_portint(sys.argv[1]), remote_ipsys.argv[2], remote_portint(sys.argv[3]), )5.3 运行方式打开两个终端窗口在第一个窗口运行 A 端python udp_chat.py 8001 127.0.0.1 8002在第二个窗口运行 B 端python udp_chat.py 8002 127.0.0.1 8001两个终端都在本机所以 IP 都填写回环地址127.0.0.1。A 端绑定本地端口8001发往127.0.0.1:8002B 端绑定本地端口8002发往127.0.0.1:8001。如果两台机器在同一个局域网内则 IP 填写对方机器的局域网 IP例如python udp_chat.py 8001 192.168.1.100 8002注意局域网通信时需要保证两台机器的防火墙允许对应 UDP 端口的入站流量。5.4 预期运行效果A 端窗口输出UDP 单聊已启动本地端口 8001 - 127.0.0.1:8002 输入消息回车发送输入 exit 退出。 你好我是 A [对方] 你好我是 B 收到B 你好 [对方] 我们退出吧 exit [系统] 聊天已结束。B 端窗口输出UDP 单聊已启动本地端口 8002 - 127.0.0.1:8001 输入消息回车发送输入 exit 退出。 [对方] 你好我是 A 你好我是 B [对方] 收到B 你好 我们退出吧 [系统] 对方已退出聊天按回车可退出。到这里一个基础的 UDP 单聊程序就跑通了。它的核心逻辑只有五步创建套接字、绑定端口、接收线程、主线程输入、关闭套接字。后面我们再来看看如何在这个基础上增加可靠性保障。6. 进阶为 UDP 单聊增加可靠性保障6.1 为什么需要应用层确认UDP 本身不保证消息可靠到达所以要想在 UDP 上实现更可靠的聊天必须在应用层自己实现确认和重传机制。最简单的模型是发送方给每条消息编号。接收方收到消息后回复一条 ACK表示“某号消息已收到”。发送方如果一段时间内没有收到 ACK就重发该消息。这本质上是在应用层模仿 TCP 的确认重传机制但实现比 TCP 简单得多也足够满足很多轻量级通信需求。6.2 消息编号与 ACK 的简化设计这里给出一套简化的消息协议设计思路不展开完整代码重点说明流程。发送方 接收方 |-------- msg:1:你好 -------- | |------- ack:1 -------------- | |-------- msg:2:在吗 -------- | |------- ack:2 -------------- |消息格式可以采用类型:序号:内容的文本格式msg:1:你好ack:1发送方在发送消息时记录消息序号并启动一个定时器。如果超时没有收到对应的 ACK则重发。接收方收到msg消息后立即回一条ack。6.3 核心代码思路下面是消息格式的解析和构造核心代码可以作为扩展参考。# 文件路径udp_chat/ack_protocol.py def build_message(seq: int, content: str) - bytes: 构造一条带序号的聊天消息 return fmsg:{seq}:{content}.encode(utf-8) def build_ack(seq: int) - bytes: 构造一条 ACK 确认消息 return fack:{seq}.encode(utf-8) def parse_message(data: bytes): 解析收到的数据返回 (类型, 序号, 内容) text data.decode(utf-8) parts text.split(:, 2) if len(parts) 2: return None msg_type parts[0] seq int(parts[1]) content parts[2] if len(parts) 2 else return msg_type, seq, content在实际项目中还可以给消息增加时间戳、消息 ID、签名等字段并引入滑动窗口机制来提升吞吐量。对于单聊这种低频场景每发一条消息就等待 ACK 的简单方式通常已经够用。7. 常见问题与排查思路7.1 高频问题排查表问题现象常见原因解决思路两端都运行了但收不到消息地址或端口填错核对本地绑定端口和对端发送端口收不到消息且程序绑定报错端口被占用端口已被其他程序占用更换端口或用netstat查看端口占用中文乱码编码/解码不一致统一使用 UTF-8 编码消息被截断消息超过 recvfrom 缓冲区大小增大缓冲区或限制单条消息大小一方退出后另一方卡在输入状态输入阻塞导致线程无法及时退出用退出事件 提示用户按回车退出局域网内收不到消息防火墙拦截 UDP 入站流量在防火墙中放行对应 UDP 端口7.2 几个典型问题的详细分析先说“收不到消息”。这是 UDP 调试中最高频的问题。首先是检查地址和端口A 发送给 B必须确保 A 填写的目标地址是 B 绑定的本地端口而不是 A 自己的端口。很多初学者会把local_port和remote_port搞混导致数据报发送给了自己。其次是防火墙问题。UDP 没有连接状态防火墙很难判断一个 UDP 数据报是否是“应答包”所以很多系统的默认策略会拦截来自外部的 UDP 入站请求。在局域网内调试时可以先在防火墙中放行指定端口或者先用127.0.0.1回环地址测试排除网络层因素。再说“消息截断”。recvfrom(1024)表示最多读取 1024 字节。如果发送的数据报超过 1024 字节超出的部分会被丢弃而且接收方不会收到任何“数据被截断”的提示。建议设计协议时把单条消息控制在 512 字节以内或者在接收时使用更大的缓冲区比如recvfrom(65535)。最后是“程序无法退出”。由于input()是阻塞的当接收线程收到对方退出通知并设置exit_event时主线程可能还卡在input()上。此时需要用户按一次回车让input()返回主线程才能检查退出事件并结束。这是一个比较常见的交互问题本文代码里已经通过提示信息说明。8. 工程实践建议8.1 消息大小与缓冲区UDP 数据报的长度受限于网络 MTU最大传输单元。在以太网环境中MTU 通常为 1500 字节扣除 IP 头和 UDP 头后建议单条 UDP 数据报不要超过 1472 字节。如果消息体积较大建议在应用层拆分成多个数据报并给每个数据报编号接收方按编号重组。在实际代码中recvfrom()的缓冲区大小可以设为 65535这是 UDP 数据报的最大长度能避免大部分截断问题。8.2 安全与权限边界UDP 无连接的特性也带来安全风险任何人都可以向你的绑定端口发送数据报因此程序必须校验消息来源。如果服务暴露在公网容易被恶意扫描和 UDP Flood 攻击。不要在公网环境中直接运行裸 UDP 单聊程序建议增加鉴权、白名单、消息签名等机制。本文代码中接收线程打印了addr实际项目中应加入来源 IP 白名单校验。需要特别说明的是本文所有示例仅用于合法授权的开发测试环境请不要对非授权目标发送任何 UDP 流量。8.3 日志与可观测性UDP 调试比 TCP 困难因为它没有连接状态发生问题时不方便直接观察“连接在哪一步断了”。因此在工程实现中要增加日志收到消息时打印发送方地址、消息序号、消息长度。发送消息时打印目标地址、消息序号。记录丢包、重传、异常等事件。有了日志就能快速定位是发送失败、接收失败还是网络丢包。8.4 多环境验证建议按以下顺序验证 UDP 单聊程序本机回环测试两个终端都使用127.0.0.1排除网络因素。局域网测试两台机器用局域网 IP 通信验证防火墙和路由是否正常。跨网段或 Wi-Fi 测试观察弱网环境下的丢包和延迟表现评估是否需要增加应用层可靠性机制。只有在多环境下验证过才能判断当前程序是否满足需求。9. 总结与下一步学习方向本文围绕 UDP 单聊程序从协议差异、Socket API、线程模型、完整代码到可靠性扩展做了一个相对完整的梳理。你现在可以动手把第 5 章的代码复制下来先在本机两个终端中跑通回环通信再尝试改成局域网内的两台机器通信。跑通之后建议按以下方向继续深入给程序加入 ACK 确认和超时重传理解应用层可靠性的实现思路。将消息格式升级为 JSON 或自定义二进制协议增加消息类型、时间戳、序列号等字段。把单聊扩展为多人群聊需要在数据报中携带“房间号”或“目标用户 ID”等信息。学习如何用select、poll或异步 IO 代替多线程模型优化高并发下的资源占用。UDP 最大的特点是无连接、效率高但不可靠。真正的难点往往不在“怎么发消息”而在“消息丢了怎么办”“消息乱序了怎么办”“如何区分不同来源的数据报”。这些问题值得在实际项目中一点点验证和打磨。