Python socket编程详解:TCP与UDP选型、实现与实战

Python socket编程详解:TCP与UDP选型、实现与实战 我最近在赶一个项目要在同一台设备上既做状态上报又做指令下发。状态上报要求实时能容忍偶尔丢包指令下发却必须可靠到达不能出错。于是又回到了Python socket编程里最经典的两个选择TCP传输和UDP传输。这个问题看起来很简单但真到落地的时候很多人搞不清楚为什么TCP写起来比UDP复杂那么多也不知道怎么根据实际场景去定协议。这篇文章我就把两种实现方式从头到尾拆开讲清楚适合刚接触网络编程的Python开发者也适合做了几年业务但没系统梳理过socket细节的朋友。我尽量用实际项目里的思路来讲不只贴代码还会说清楚为什么这么写因为socket编程的坑往往不在语法而在对协议和操作系统行为的理解。1. 从需求场景聊起TCP和UDP到底怎么选1.1 两种协议的底层差异先看协议层。TCP是面向连接的、可靠的、基于字节流的传输协议。它要经过三次握手建立连接传输过程中通过序列号、确认应答、超时重传、拥塞控制这些机制保证数据不丢失、不重复、按序到达。UDP则完全相反它是无连接的、不可靠的、基于数据报的传输协议。发送方只需要把数据包丢到网络上接收方能不能收到、按什么顺序收到、会不会重复发送方一概不管。这两种差异直接决定了代码结构。TCP要先“打电话”连接建立成功后才能收发包UDP像“发快递”直接把包裹扔出去就行。所以在Python里TCP程序一般需要connect、accept、listen这几个流程UDP则只需要sendto、recvfrom代码量会少很多。1.2 如何根据应用场景做选择我一般会根据这几点来判断数据是否允许丢失。文件传输、数据库同步、远程指令这种丢一个字节都可能出大事选TCP。实时性要求多高。语音通话、视频会议、游戏状态同步允许偶尔掉帧或丢包选UDP能明显降低延迟。应用层是否愿意做重传、排序、去重。如果愿意可以在UDP之上自己控制传输逻辑比如游戏同步协议经常这么做。网络环境是否可控。内网通信可以相对放心用UDP跨公网则要仔细评估丢包率。下面这个表格我经常用来给团队做选型参考维度TCPUDP连接状态面向连接需三次握手无连接直接发包可靠性可靠传输有重传机制尽力而为可能丢包数据边界字节流无消息边界数据报保留消息边界传输顺序保证到达顺序可能乱序传输速度有拥塞控制和重传相对慢无重传机制速度快典型场景HTTP、FTP、数据库、远程指令音视频、DNS、局域网广播、游戏同步我最近在做的机器人项目里部分话题通信确实用了类似UDP的机制因为它追求实时状态刷新偶尔掉一帧状态不会影响整体控制。但真正下发底盘急停指令的时候绝对不能用UDP裸传必须走可靠通道否则代价太大。1.3 一个形象的类比打电话 vs 发快递给新同事讲TCP和UDP的时候我常用“打电话”和“发快递”来类比。TCP像打电话。拨号后对方接起双方都确认“我现在能听到你”这时候才开始说话。通话过程中你说一句没听清会“再说一遍”对方没听清也会要求你重复。挂断之前双方都知道会话结束。这就是为什么TCP需要listen、accept、connect这些流程因为它要维护一个连接状态。UDP像发快递。你把包裹放进快递柜或交给驿站单号一填就完事后面的运输、丢失、破损、延迟发件人都不追踪。收件人收到后拿出来如果发现东西坏了只能重新发。但是快递的优势是快不需要建立复杂连接适合“这件事发出去就行”的场景。2. 搭建环境与socket基础准备2.1 Python环境的准备Python的socket模块是标准库不需要pip安装任何第三方包。只要机器上有Python 3就能直接跑socket程序。我建议用Python 3.6以上版本因为新版对字符串和字节串的区分更明确写socket代码时不容易出现类型错误。如果你还在用Python 2那尽早迁移Python 2里的许多字符串处理习惯放到3里会踩很多坑。安装Python这一步Windows去官网下载安装包勾选“Add Python to PATH”Linux可以用系统包管理器装python3。装完在命令行敲python --version确认版本。我见过不少新手卡在环境变量没配好装完还是提示命令找不到问题多半就出在这一步。2.2 socket模块的核心类和常用方法socket模块最核心的入口是socket.socket()它返回一个套接字对象。常用的方法其实就那么几个socket(family, type)创建套接字family决定地址族type决定协议类型。bind(address)绑定IP和端口服务端必须绑定才能被访问。listen(backlog)TCP服务端监听连接backlog是等待队列长度。accept()TCP服务端阻塞等待客户端连接返回新的套接字和客户端地址。connect(address)TCP客户端发起连接触发三次握手。send(data)/sendall(data)发送字节数据TCP上用。recv(bufsize)接收字节数据TCP上用。sendto(data, address)/recvfrom(bufsize)UDP上发送和接收数据报。setsockopt(level, optname, value)设置套接字选项常用SO_REUSEADDR。close()关闭套接字释放资源。这些方法不需要死记但一定要理解每个方法的阻塞行为和服务端/客户端侧的差异。后面写代码的时候大部分bug都出现在“方法用错场景”上。2.3 地址族与套接字类型socket.socket()的第一个参数family常见的有AF_INETIPv4、AF_INET6IPv6、AF_UNIX本机Unix域套接字。第二个参数type常见的有SOCK_STREAM流式套接字对应TCP、SOCK_DGRAM数据报套接字对应UDP、SOCK_RAW原始套接字一般需要管理员权限用于自定义协议。也就是说TCP和UDP的区别在创建套接字那一刻就已经决定了。你想写TCP就用SOCK_STREAM写UDP就用SOCK_DGRAM。这之后调用bind、send、recv的语义都会跟着变。有人会问TCP和UDP能不能绑定同一个端口号在Linux和Windows上是可以的。TCP和UDP在协议栈里属于不同的传输层协议五元组也不一样所以可以同时监听同一个数字端口。我在实战案例里就用过这个特性一台设备同时提供TCP可靠通道和UDP实时通道端口号保持一致对端只需要记住一个端口就能分别连两个协议。3. TCP编程完整实操3.1 TCP服务端代码拆解一个最简TCP服务端如下import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 8888)) server.listen(5) print(TCP服务端已启动等待连接...) while True: client_sock, client_addr server.accept() print(客户端接入:, client_addr) try: data client_sock.recv(1024) if data: print(收到消息:, data.decode(utf-8)) client_sock.sendall(bhello from server) finally: client_sock.close()这里每一行都有讲究。bind((0.0.0.0, 8888))里的0.0.0.0表示监听本机所有网卡这样无论是localhost还是局域网IP都能连上。如果只写127.0.0.1那就只有本机自己可以访问部署到服务器上局域网设备根本连不进来这是我很早就踩过的坑。SO_REUSEADDR这个选项经常被忽略。服务端程序一旦停止刚用过的端口可能进入TIME_WAIT状态立刻重启时bind会报“Address already in use”。加上这行能让我们快速重启服务写开发脚本时特别重要。accept()是个阻塞方法代码执行到这就会一直等直到有客户端连接进来。每次accept返回的是一个新的套接字对象专门用来和这个客户端通信原始的server套接字继续等待后续连接。所以TCP服务端天然是多套接字模型一个监听套接字对应多个连接套接字。3.2 TCP客户端代码拆解TCP客户端更简单import socket client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((127.0.0.1, 8888)) client.sendall(bhello) response client.recv(1024) print(收到响应:, response.decode(utf-8)) client.close()connect调用会触发三次握手握手完成之后客户端和服务端的连接才真正可用。如果服务端没有启动connect会抛ConnectionRefusedError这时候要检查服务端进程、端口、防火墙。发送数据时我习惯用sendall而不是send。send不一定能把所有数据都发出去它返回实际发送的字节数你需要自己判断是否发送完成。sendall内部会循环发送直到全部发完除非中途出错否则更省心。接收用的是recv(1024)参数是缓冲区大小表示最多读1024字节。字节流不像消息流一次recv返回多少数据是不可预期的后面讲粘包时会重点说。3.3 TCP三次握手在代码中的体现TCP三次握手在代码里其实看不到明显的函数调用但它决定了connect和accept的时序。第一次握手客户端发送SYN报文请求建立连接。第二次握手服务端内核收到SYN回复SYNACK表示“我收到了你的连接请求我也准备好连接了”。第三次握手客户端收到SYNACK再发一个ACK表示“我知道了连接建立”。当客户端connect返回时第三次握手已经发出去了当服务端accept返回时说明内核已经完成了三次握手这个连接可以被业务代码使用了。有个很关键的细节accept只是把内核已完成握手的连接取出来真正处理握手的是内核协议栈。所以服务端listen之后即使还没调用accept客户端也可以connect成功只是连接会在内核队列里排队。backlog参数就是控制这个已完成队列长度的排满了新的连接请求会被拒绝或丢弃。3.4 并发处理多线程/多进程/select处理多客户端前面那个服务端是单线程模型一次只能处理一个客户端。如果客户端连上来后不发送数据accept虽然返回了但业务代码卡在recv后续客户端连进来也只能在内核队列里等待表现就是“连上了但不响应”。实际项目里我常用threading来并发处理import socket import threading def handle_client(client_sock, client_addr): try: while True: data client_sock.recv(1024) if not data: break print(client_addr, :, data.decode(utf-8)) client_sock.sendall(back) finally: client_sock.close() server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 8888)) server.listen(5) while True: client_sock, client_addr server.accept() t threading.Thread(targethandle_client, args(client_sock, client_addr)) t.start()每来一个客户端就开一个线程简单粗暴对于连接数不高的内部工具完全够用。如果连接数很多开线程太多会浪费资源这时可以改用selectors或asyncio做IO多路复用。Python标准库里还有socketserver模块可以封装并发模型但我个人更习惯直接用threading逻辑更透明出了问题也好排查。用多线程时要注意所有线程共享同一个进程如果多个客户端同时操作共享数据要加锁。socket的recv是阻塞的一个线程卡在recv不影响其他线程所以IO密集场景下Python的GIL影响不大。4. UDP编程完整实操4.1 UDP服务端代码拆解UDP服务端比TCP短不少import socket udp_server socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_server.bind((0.0.0.0, 9999)) print(UDP服务端已启动等待数据...) while True: data, addr udp_server.recvfrom(1024) print(收到来自, addr, 的消息:, data.decode(utf-8)) udp_server.sendto(back, addr)这里没有listen没有accept因为UDP不需要连接。服务端从一个固定端口收数据用recvfrom接收返回值有两个第一个是数据第二个是发送方地址。需要回包时用sendto把数据发给指定地址。UDP在并发处理上比TCP更省心。因为所有客户端都往同一个端口发数据服务端只有一个套接字就能服务所有客户端不需要为每个客户端创建新的套接字和线程。代价是服务端无法知道哪些客户端还“活着”只能被动等数据。4.2 UDP客户端代码拆解UDP客户端非常灵活可以和多个服务端通信不需要先连接。最简单的方式import socket udp_client socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_client.sendto(bhello, (127.0.0.1, 9999)) data, server_addr udp_client.recvfrom(1024) print(data.decode(utf-8)) udp_client.close()如果不关心回包客户端甚至可以直接sendto连recvfrom都不调用。当然如果想用send和recv也不是不行UDP套接字可以调用connect但这里的connect和TCP完全不同它并不会发起握手只是给套接字设置一个默认的对端地址之后就能用send和recv了。这个特性会让误以为“connect之后就是TCP”的人产生困惑实际上UDP始终是无连接的。还有一个点UDP客户端如果只sendto不recvfrom数据发送是否成功无从得知。发送端只能确定数据交给了本地协议栈无法确认对端是否收到。这也是很多新手说“UDP不可靠”的直观来源。4.3 UDP的边界保持特性与丢包问题TCP是字节流协议没有消息边界UDP是数据报协议每条sendto就是一个完整的数据报recvfrom一次就能读出那个数据报。比如客户端连续sendto三个数据包服务端三次recvfrom通常能分别收到三个包不会读到“半个包”或“两个半包”。但UDP的不可靠性会在实际网络环境里显露出来。数据发出后可能在网络中途被丢弃也可能因为路由器缓存溢出而丢包还可能后发的先到。对于实时音视频偶尔丢一帧问题不大所以UDP很合适对于需要完整数据的场景就必须在应用层自己加序号、确认和重传机制。现在很多现代协议也选择在UDP上做可靠传输比如QUIC和WebRTC。原因是UDP没有TCP的队头阻塞问题控制起来更灵活。Python里如果要用UDP实现可靠通信可以自己设计一个简易协议数据结构里包含包序号、消息类型、校验值收方收到后回ACK发方超时未确认就重发。4.4 实现一个简易UDP广播发现UDP还有一个TCP做不到的特性广播。同一局域网内的设备可以通过广播地址255.255.255.255或者子网广播地址把数据发给网段内所有设备常用于设备发现。发送端import socket import time bcast socket.socket(socket.AF_INET, socket.SOCK_DGRAM) bcast.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) bcast.settimeout(3) bcast.sendto(bdiscover_request, (255.255.255.255, 9999)) try: while True: data, addr bcast.recvfrom(1024) print(发现设备:, addr, data.decode(utf-8)) except socket.timeout: print(广播探测结束)服务端只需要bind到指定端口然后recvfrom接收广播回包时用sendto。注意广播包无法穿越路由器只能在同一个局域网广播域里传播。如果跨网段设备发现就要用组播或专门的服务发现协议了。这个场景在工业设备调试里很常见一台设备不知道服务器IP又不想手动配置就可以通过广播告诉服务器“我在线IP是多少”服务器回包完成注册。5. 实战案例一个同时支持TCP/UDP的通信小工具5.1 需求描述与设计我在实际项目里经常遇到“既要可靠又要实时”的需求。比如一套室内环境监测系统传感器节点通过UDP上报温度和湿度因为数据每秒钟都在变偶尔丢一帧无所谓但上位机下发控制指令时必须保证节点能收到所以用TCP。我把这两个服务合并到同一个工具里TCP端口和UDP端口都设为8888用两个线程分别启动。对客户端来说连接这个IP的8888端口走TCP就是可靠通道走UDP就是实时数据通道。5.2 核心代码实现import socket import threading HOST 0.0.0.0 PORT 8888 def tcp_server(): tcp_sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) tcp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) tcp_sock.bind((HOST, PORT)) tcp_sock.listen(5) print(TCP服务运行在端口, PORT) while True: conn, addr tcp_sock.accept() def handle(c, a): try: while True: data c.recv(1024) if not data: break print([TCP], a, data.decode(utf-8)) c.sendall(btcp ack) finally: c.close() threading.Thread(targethandle, args(conn, addr)).start() def udp_server(): udp_sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_sock.bind((HOST, PORT)) print(UDP服务运行在端口, PORT) while True: data, addr udp_sock.recvfrom(1024) print([UDP], addr, data.decode(utf-8)) udp_sock.sendto(budp ack, addr) if __name__ __main__: threading.Thread(targettcp_server, daemonTrue).start() threading.Thread(targetudp_server, daemonTrue).start() while True: time.sleep(1)把TCP和UDP各放到一个线程里两个协议互不干扰。因为TCP和UDP协议不同虽然端口一样系统却能区分开。实际运行中传感器节点用UDP快速上报状态控制台用TCP稳定下发指令两种通信互不影响。这个结构对很多物联网项目都有参考价值。如果你的设备资源比较紧张也可以用select或epoll同时监听多个套接字减少线程数量。5.3 运行测试在终端先启动这个工具然后开两个终端分别测试。TCP测试用Python一行命令python -c import socket; ssocket.socket(); s.connect((127.0.0.1,8888)); s.sendall(btcp test); print(s.recv(1024))UDP测试python -c import socket; ssocket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.sendto(budp test, (127.0.0.1,8888)); print(s.recvfrom(1024))如果顺利服务端会分别打印两条消息两个客户端各自回包成功。这就说明同一端口上TCP和UDP同时工作了。6. 常见问题与排查技巧6.1 端口占用与Address already in use我见过最多的问题就是bind时报错。Linux下通常是OSError: [Errno 98] Address already in useWindows下是WinError 10048。原因一般是端口被其他进程占用或者前一个服务端套接字处于TIME_WAIT状态。解决方式加setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)。换一个端口测试确认是程序问题还是端口冲突。用lsof -i :8888或netstat -ano | findstr 8888查看谁占用了端口。如果确实是自己的旧进程杀掉或等它自然释放。还有一类端口冲突是服务端想监听一个已经被客户端占用的本地端口报错信息类似error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket address。这种情况就是端口没释放干净重启服务前检查一遍进程就清楚了。6.2 recv与recvfrom的区别以及TCP粘包很多新手在TCP服务端用recv在UDP服务端也用recv结果发现UDP服务端收不到数据。原因就是UDP必须用recvfrom它需要同时取出地址和内容。TCP用recv就可以了因为连接已经确定了对端地址。TCP的粘包问题也很常见。发送方连续两次sendall(babc)和sendall(bdef)接收方一次recv(1024)可能拿到babcdef也可能只拿到bab。这是字节流协议导致的必然现象和“粘包”这个名词暗示的不一样它本质上是没有消息边界。解决办法是在应用层定义帧格式。我常用“长度前缀”方案每条消息的前4个字节表示后续消息长度接收方先收满4字节再按长度收内容。import struct def send_frame(sock, data: bytes): sock.sendall(struct.pack(I, len(data)) data) def recv_frame(sock): head b while len(head) 4: part sock.recv(4 - len(head)) if not part: return None head part length struct.unpack(I, head)[0] body b while len(body) length: part sock.recv(length - len(body)) if not part: return None body part return body这个封装能让TCP通信具备类似UDP的消息边界也是很多应用层协议的基础。6.3 网络调试中的工具与抓包技巧排查socket问题光靠print不够还得学会抓包。Linux下我常用tcpdumpsudo tcpdump -i lo port 8888 -nn -A本地测试时网卡是lo也就是loopback。抓包可以看到SYN、SYNACK、ACK的过程也能看到数据包内容能直观理解三次握手。如果想看更详细的内容把-A去掉只看报文头。Windows下可以用WireShark图形界面更容易上手。抓包时过滤条件填tcp.port 8888或udp.port 9999。如果发现只有发出的SYN没有回复基本都是服务端或防火墙的问题如果收到RST说明对端主动拒绝连接常见于端口未监听或防火墙丢包。Python本身也可以用来造包测试比如写一个简单的客户端定时发送数据观察服务端是否收得到。这种方式比抓包更快定位是代码问题还是网络问题。6.4 WSL2与Windows宿主UDP通信的注意点很多人在WSL2里跑Python服务然后想让Windows宿主机上的客户端通过localhost去访问结果发现通信不上。WSL2内部是一个轻量虚拟机有自己的虚拟网卡Windows宿主和WSL2的localhost共享在一些情况下并不像纯本机那么简单。如果UDP服务端跑在WSL2里Windows宿主机访问时不能简单认为用localhost就能通可能要使用WSL2的IP。反过来让WSL2访问Windows宿主机上的UDP服务需要通过ip route show | grep default查到宿主机IP。同时Windows防火墙会拦截入站UDP需要在防火墙设置里放行对应端口否则数据发过去石沉大海。我的建议是遇到跨环境通信时先把服务端bind到0.0.0.0然后两边都用具体IP测试不要依赖localhost。确认网络通再回到代码层面的问题。6.5 连接被关闭或中断的常见报错开发中经常会遇到这类报错cannot connect to api: the socket connection was closed unexpectedly。这个报错通常出现在HTTP或API客户端里表示TCP连接在数据交换过程中被对端关闭。可能的原因有服务端程序崩溃或主动close了连接。请求数据格式不对导致服务端直接断开。中间网络设备如防火墙、网关拦截了连接发送了RST。服务端有超时设置长时间没有数据交互自动断开。排查思路是先在服务端加日志确认连接是否建立、recv到多少数据、在什么时刻发送了close再用抓包工具看是FIN还是RST。如果是RST多半是对端主动拒绝问题在服务端配置或防火墙如果是FIN说明对端正常关闭了连接检查业务逻辑更靠谱。7. 个人心得与建议写socket程序这几年我最大的体会是协议选型决定代码的复杂度代码结构决定后期排障的难度。能用TCP满足需求的场景不要贪图UDP的“快”去自己造轮子只有当延迟敏感而且能容忍少量丢包时才值得在UDP上做自定义控制。我在项目里就吃过UDP的亏。一开始图省事所有数据都用UDP上报结果上位机偶尔出现状态回退因为有的包丢了下位机用的还是旧状态。后来把控制指令切到TCP状态上报继续用UDP问题才彻底解决。另一个教训是TCP服务端没处理好客户端断连。客户端直接拔网线服务端recv不会立刻返回要等很久才会超时。这种情况下要给套接字设置超时或者用心跳包检测连接是否真的还活着。最后再分享一个小技巧写socket程序时先用最简单的单客户端单线程跑通再扩展并发先用本地127.0.0.1测通再改到局域网实际IP。这样每一步出错都能快速定位。遇到不确定的协议行为就抓包看一眼别再对着代码猜了。