首页 / 资讯中心 / 文章详情

Python Socket编程从零到实战:TCP通信、粘包与多客户端并发

Python Socket编程从零到实战:TCP通信、粘包与多客户端并发 ★ FEATURED ARTICLE
说实话聊到网络编程很多人的第一反应是“太难了”或者是“先学框架、用现成的库”。但我想说的是只要你把最底层的那块积木搭明白往上盖什么楼都不慌。这个“最底层的积木”在我看来就是 Socket。Python 的 Socket 编程没有 C 那么劝退也没有框架包装得那么玄乎它非常适合作为进入网络编程世界的第一站。这篇博文我就从零开始带你手写一个基于 TCP 的 Server 与 Client 通信程序并以此为基础把阻塞、粘包、多客户端并发这些绕不开的坎儿一并说透。适合刚学完 Python 基础语法、想往网络方向走的读者也适合那些“用过框架但不太清楚底层到底发生了什么”的同学对照自查。我不会直接从代码开始“糊”而是先讲清楚设计思路为什么用标准库的socket模块而不是直接上asyncio或者Twisted因为只有先把原生 API 的每一个阻塞点、每一个返回时机搞明白后面用任何高级封装你才能判断出它替你做了什么、没做什么。1. 项目整体设计与思路拆解1.1 先搞清楚这个项目到底在解决什么问题网络通信的本质就是让两个独立运行的程序之间能够交换数据。交换数据的场景太多了浏览器向服务器请求网页、手机 App 向后端上传图片、游戏客户端同步玩家的位置……这些背后都离不开一套基础的传输机制而 Socket 就是这套机制的编程接口。这个项目不追求高并发、不追求花哨的协议设计目标只有一个让两端跑起来能稳定地收发文本消息并且在这个过程中让你看明白数据是怎么“从 A 进程到 B 进程”的。我给这个项目定的边界很朴素一个 Server 端启动后监听端口一个 Client 端连接上之后给 Server 发送一条“你好Socket”Server 收到后原样返回一条确认消息。整个过程敲完代码、跑起来不超过 15 分钟。但麻雀虽小五脏俱全TCP 连接从建立到关闭的所有关键状态都会经历一遍。1.2 为什么选 Python 标准库而不是第三方框架这里有个取舍问题。市面上 Python 网络编程相关的库和框架很多比如Twisted、Tornado、aiohttp、gevent更别提各种 RPC 框架。如果要给一两年后的自己做储备直接学 Twisted 或者aiohttp也不是不行但我的建议是第一遍一定要用标准库socket裸写。原因有三个。第一标准库没有隐藏太多细节bind、listen、accept、connect这些 API 就是操作系统网络接口的直接映射学完它们你再看任何框架的源码会发现框架只是在这些 API 外面包了事件循环和回调层。第二标准库代码跑在任何装有 Python 的机器上都行不需要处理虚拟环境里一堆依赖的版本冲突。第三调试心智负担小就一个进程不需要理解框架内部的线程池和协程调度问题的根源相对容易定位。当然用原生 Socket 写出来的 Server 在性能和健壮性上确实没法跟成熟框架比但这个阶段追求的不是扛住百万连接而是把受限于同步阻塞模型的瓶颈亲手撞上一次撞完你才能真正理解为什么后来会有那么多异步方案。2. 核心概念铺垫Socket、TCP、收发模型2.1 Socket 是什么用生活类比理解它Socket 这个词原本是指“插座”在网络编程里可以理解成两个进程间通信的通道端点。假设你要寄一封实体信写信的地址需要写清楚国家、城市、街道、门牌号收信方还要有接收动作和回信动作。Socket 里对应的就是 IP 地址、端口号、监听队列、接收缓冲区这些概念。把视角拉低一点来看操作系统为每个进程维护了一张文件描述符表Socket 在 Linux/Windows 上本质上就是一个文件描述符。好处是你可以像读写文件一样read/write它在 Python 里对应recv/send坏处是——文件有尽头而 Socket 没有你必须时刻处理数据没读完、连接被另一端半关闭、缓冲区打满等等文件操作永远不会遇到的问题。搞懂了这个本质后面做代码容错的时候很多困惑会自然解开。2.2 TCP 连接建立与关闭的四个阶段映射到代码是哪儿几步这个项目用的是 TCP 协议。TCP 提供的是面向连接的、可靠的、字节流式的传输服务。“面向连接”意味着通信前必须先建立连接“可靠”意味着数据不丢、不乱序“字节流”意味着没有消息边界收发双方需要自己约定如何分块。这三个特性分别对应了代码里哪些位置我梳理了一个对应关系TCP 生命周期阶段底层机制代码侧对应 API连接建立三次握手Client 执行connect()时触发连接建立三次握手Server 执行accept()返回时完成数据传输确认、重传、滑动窗口send()/recv()循环连接关闭四次挥手任一端执行close()连接关闭四次挥手recv()返回b代表对端关闭很多人写服务端代码容易漏掉一个细节accept()返回的conn对象才是与客户端通信的 Socket而最初监听的那个listen_socket在accept()之后跟客户端再无数据交换。这两个 Socket 对象所承担的角色完全不同初学者如果混淆会在数据处理时踩大坑。2.3 同步阻塞模型到底“阻塞”在哪里我先解释一下什么叫阻塞。在默认情况下socket模块创建的 Socket 是阻塞式调用accept()时线程挂起直到有客户端连入调用recv()时线程挂起直到缓冲区有数据可读调用send()时线程挂起直到数据被写入系统发送缓冲区。如果用一句话概括就是每个 Socket 操作都会抢占当前线程因等待条件不满足而暂停执行。阻塞模型的好处是写代码直观、逻辑顺序跟事件顺序完全一致坏处是同一时刻一个线程只能处理一个 Socket 的事件流转。你可能会想如果第一个客户端连上来建立了连接但迟迟不发送数据那么 Server 端阻塞在recv()上第二个客户端连进来会发生什么答案是它会躺在一个叫 accept 队列的地方等到服务端从第一次recv()返回后再次调用accept()才能被处理。直观感受就是“卡住了”。这个问题会在我后面的多客户端章节里用代码正式引爆这里先留一个预期。3. 动手实现第一个能跑的 Server 与 Client3.1 服务端代码监听、接受、回应我先给出服务端代码的完整版本再逐段讲重点。不要照抄完就跑后面读到阻塞位置时请回来看这些注释。import socket SERVER_HOST 127.0.0.1 SERVER_PORT 8888 BUFFER_SIZE 1024 def main(): # socket.AF_INET 表示 IPv4, socket.SOCK_STREAM 表示 TCP listen_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 允许端口快速重用解决 TIME_WAIT 状态下重启服务报错的问题 listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # bind 的作用是把 Socket 绑定到指定地址和端口上 listen_socket.bind((SERVER_HOST, SERVER_PORT)) # listen(5) 表示最多允许 5 个客户端等待连接 listen_socket.listen(5) print(f[*] 服务端已启动监听 {SERVER_HOST}:{SERVER_PORT}) while True: # 阻塞在这里等待客户端连入 conn, client_addr listen_socket.accept() print(f[] 客户端已连入{client_addr[0]}:{client_addr[1]}) # 循环接收客户端发来的数据 while True: data conn.recv(BUFFER_SIZE) # recv 返回空字节串代表客户端关闭了连接 if not data: print(f[-] 客户端 {client_addr} 已断开) break print(f[] 收到消息{data.decode(utf-8)}) # 原样把数据回传给客户端 conn.sendall(data) conn.close() # 实际上 Python 进程退出时也会自动释放资源 # 这里写 close 是为了强调连接使用完毕后应显式释放 listen_socket.close() if __name__ __main__: main()这里有一个很多新手会犯的错误总在for循环里创建 Socket或者每次 accept 后忘了把conn放到内层循环里接收数据。写的时候多问自己一句“我是要持续接收还是只收一次就断开”这个项目要做一个简单的 Echo 对话所以要加内层循环。3.2 客户端代码连接、发送、接收客户端的逻辑比服务端简单不需要bind和listen只需要知道服务端的地址和端口即可。import socket SERVER_HOST 127.0.0.1 SERVER_PORT 8888 BUFFER_SIZE 1024 def main(): client_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 发起连接这里会触发 TCP 三次握手 client_socket.connect((SERVER_HOST, SERVER_PORT)) print(f[*] 已连接服务端 {SERVER_HOST}:{SERVER_PORT}) try: while True: msg input(请输入要发送的消息输入 exit 退出).strip() if not msg: continue if msg.lower() exit: break # sendall 会尝试一次性发送全部数据比 send 更可靠 client_socket.sendall(msg.encode(utf-8)) # 接收服务端的回显 reply client_socket.recv(BUFFER_SIZE) print(f[] 服务端回显{reply.decode(utf-8)}) finally: client_socket.close() print([*] 客户端已关闭连接) if __name__ __main__: main()这里为什么用sendall而不是send因为send并不能保证把参数里的全部字节都发出去它可能只发送了一部分你需要检查返回值然后把剩余部分继续发送。sendall是对这种发送循环的封装。用send发送大文件时这种差异尤其明显。对于入门阶段无脑用sendall是没什么问题的。3.3 运行效果与代码逐段验证运行步骤很简单先开一个终端执行python server.py再开第二个终端执行python client.py。我在机器上实测的结果是# 终端一服务端 [*] 服务端已启动监听 127.0.0.1:8888 [] 客户端已连入127.0.0.1:54321 [] 收到消息你好Socket [-] 客户端 127.0.0.1:54321 已断开# 终端二客户端 [*] 已连接服务端 127.0.0.1:8888 请输入要发送的消息输入 exit 退出你好Socket [] 服务端回显你好Socket注意一个细节我在客户端和服务端之间传输时统一用utf-8编码不要用系统默认编码否则在 Windows 上可能会因为 GBK 编码问题产生乱码。凡是跨程序传输文本永远显式指定编码格式。3.4 踩坑提醒Windows 防火墙与连接被拒绝如果你在 Windows 上运行客户端报WinError 10061目标计算机积极拒绝无法连接先分清几种可能第一服务端没有启动第二端口被别的进程占用第三Windows 防火墙拦截了来自外部的连接。如果是同一台机器用127.0.0.1回环地址通信通常不会触发防火墙但一旦你把服务端地址改成局域网 IP让手机或其他电脑去连就一定要放行 Python 进程的入站规则。在排查这个坑时有个小技巧执行netstat -ano | findstr 8888查看端口是否处于LISTENING状态这是判断服务端是否正常监听最快的方式。4. 从单客户端到多并发三个层级的升级路线4.1 第一次升级用多线程让主循环不卡死现在的服务端只能处理一个客户端因为accept()之后的处理逻辑全在主线程里串行执行。改进思路很直观用一个新线程去处理连接上的收发主线程继续回accept()等待新连接。代码调整为import socket import threading def handle_client(conn, addr): print(f[] 处理客户端 {addr[0]}:{addr[1]} 的业务逻辑) try: while True: data conn.recv(1024) if not data: break conn.sendall(data) except ConnectionResetError: print(f[!] 客户端 {addr} 异常断开) finally: conn.close() print(f[-] 客户端 {addr} 连接关闭) def main(): listen_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind((127.0.0.1, 8888)) listen_socket.listen(5) while True: conn, addr listen_socket.accept() t threading.Thread(targethandle_client, args(conn, addr)) t.start() if __name__ __main__: main()这段代码能支撑多大并发理论上线程数可以很多但线程切换有代价每个线程还会占用独立的资源像内存里的线程栈。如果你只是在学习和测试阶段开几十个客户端完全没有问题。但如果你想让这个服务端去应对上万连接这个模型就会因为频繁的上下文切换而吃力。这就是为什么后面需要事件驱动方案。还有一个惊悚的小坑我这里直接threading.Thread(targethandle_client, args(conn, addr))用了daemon默认值False而主线程里是一个无限循环所以进程不会因为某个线程退出而结束。如果你想在服务器关闭时强制退出全部线程可以把线程设为daemonTrue但注意守护线程里不要做需要优雅收尾的资源清理否则可能出怪问题。4.2 第二次升级基于 selectors 的 I/O 多路复用I/O 多路复用是一种让单个线程同时监控多个 Socket 事件的技术。在 Python 3.4 之后标准库提供了selectors模块它在不同操作系统上自动选择epoll、kqueue等最优实现。有概念基础的同学知道select本身存在限制比如它有 1024 个文件描述符上限但selectors内部默认用epoll时不受这个限制。我建议在本地直接使用selectors.DefaultSelector让库帮我们做这件事。import socket import selectors sel selectors.DefaultSelector() def accept_wrapper(sock): conn, addr sock.accept() conn.setblocking(False) # 非阻塞模式是事件驱动的前提 sel.register(conn, selectors.EVENT_READ, read_wrapper) print(f[] 新客户端 {addr}) def read_wrapper(conn): data conn.recv(1024) if data: conn.sendall(data) else: print(f[-] 客户端 {conn.getpeername()} 断开) sel.unregister(conn) conn.close() def main(): listen_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind((127.0.0.1, 8888)) listen_socket.listen(5) # 监听 Socket 也需要改成非阻塞 listen_socket.setblocking(False) sel.register(listen_socket, selectors.EVENT_READ, accept_wrapper) print([*] 服务端启动基于 selectors 管理事件) while True: events sel.select(timeoutNone) for key, _ in events: callback key.data callback(key.fileobj) if __name__ __main__: main()这里的关键操作是conn.setblocking(False)。如果一个 Socket 处于阻塞模式那么当某个 Socket 上没有数据可读时调用recv会直接卡住整个线程导致事件循环瘫痪。只有改成非阻塞recv才会立即返回或者抛异常取决于是否有数据事件循环才能继续处理别的 Socket。sel.register(conn, selectors.EVENT_READ, read_wrapper)这一行是核心把文件符、关注的事件、回调函数三者绑定在一起。select()返回后就依次取出就绪的事件执行对应的回调。整个过程只有一条线程但对多个 Socket 的状态同时保持敏感。4.3 两种多客户端方案的取舍为了帮你在实际项目中做选择我把两种方案的关键差异整理成表对比维度Threading 多线程Selectors 事件驱动代码复杂度低逻辑直观中回调风格需要适应并发连接上限受线程资源限制明显更高处理耗时任务的体验线程内处理不影响其他连接阻塞操作会卡住整个事件循环调试难度相对容易有堆栈可查回调之间调用关系隐晦适用场景中小规模、逻辑清晰的项目高连接数、长连接的网关/服务有个容易踩的误区selectors 模型里如果某个回调函数内部做了time.sleep或同步的requests.get整个事件循环都会被拖住其他所有连接都会没有响应。所以事件驱动模型要求回调函数必须短平快耗时操作要丢给线程池或异步化处理。如果你的业务逻辑里不可避免地有耗时操作我建议先用多线程简单可靠比优化到后期再纠结要好很多。5. 高频问题与排查技巧实录5.1 粘包问题为什么我发送两条消息对方只收到一条我刚写完第一个 Echo 程序时就遇到过“发了两条消息服务端只收到一条”的困惑。这就是 TCP 的字节流特性带来的“粘包”问题。客户端连续执行两次sendall这两条消息在底层很可能被合并成一个数据包发送接收方执行一次recv时可能一次性读取到了合并后的全部数据。问题的根源在于 TCP 本身不做消息边界切分它在意的只是字节顺序和完整性。解决粘包的通用思路是在应用层自己定义消息边界。常见方案有三种固定长度消息如果每条消息都固定为 10 字节接收方每读满 10 字节就算一条完整消息实现简单但浪费空间。特殊分隔符比如每条消息以\n结尾接收方按分隔符切分适合文本协议。长度前缀先发送一个固定字节数比如 4 字节的头部里面填写消息体长度接收方先读头部解析长度再按长度读消息体。推荐使用长度前缀方案它虽然要写一点解析代码但通用性和空间利用率都是最好的。5.2 端口被占用的完整排查指南热词里有一条很典型的报错通常每个套接字地址(协议/网络地址/端口)只允许使用一次。这基本就是端口被占用。引起端口占用的原因主要有两个一是确实有其他进程在用同一个端口二是程序刚退出但是 TCP 连接还处于TIME_WAIT状态没有完全释放。后者在 debug 场景极其常见服务端CtrlC退出后马上重启经常报这个错。针对TIME_WAIT导致的端口占用我用setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)解决这个选项允许新启动的进程在端口还处于 TIME_WAIT 状态时重新绑定。但要注意如果端口上有其他进程正在正常监听设置这个选项也无法抢占毕竟两个进程不能同时绑定同一个端口。排查端口占用最有效的方式是命令行工具。Windows 上用netstat -ano | findstr 8888拿到 PID再在任务管理器里查是哪个进程Linux/macOS 上用lsof -i :8888或ss -tulpn | grep 8888。拿到结果后再决定是换端口还是杀掉占用进程。5.3 recv 返回空字符串、ConnectionResetError 等典型报错服务端代码里最常见的条件判断是if not data: break这个data是recv返回的字节串。当对端正常关闭连接执行close()时本地recv会收到空字节串b这代表没有更多数据了处理完剩余数据后应当关闭连接。但如果对端崩溃、断网或者网络异常本地的recv可能直接抛出ConnectionResetError或ConnectionAbortedError。这就是为什么我的多线程版本中用了try...except ConnectionResetError包裹收发逻辑。这里想多提醒一句不要用data b判断后不做任何区分直接关闭因为对端正常关闭和异常断开对业务处理的含义是不同的。如果是正常关闭服务端可能还需要把最后一段处理结果发给对端当然发送时也可能会因为对端已经关闭而报错如果是异常断开直接释放资源即可。5.4 超时与心跳如何发现并清理“死连接”一个客户端连接建立后可能一直不发数据也可能中途网络掉线但操作系统还没有感知。默认情况下阻塞模式里recv会一直等下去这在服务端会造成“僵尸连接”堆积白白占据文件描述符。解决方案有两个思路设置超时或者做应用层心跳。设置超时可以直接调用conn.settimeout(30)这样recv在 30 秒内没有数据就抛出socket.timeout异常你可以在异常处理中关闭连接。心跳机制的思路是在业务协议里约定一个特殊的 PING/PONG 消息服务端定期检查各连接的最后活跃时间超过阈值就主动断开。心跳机制更适合真实的大规模长连接服务器因为光靠内核超时参数调优效果未必可控。6. 实战进阶把 Echo 程序扩展成一个可靠的小型协议6.1 用长度前缀解决粘包完整封包与解包代码现在把第 5 节提到的长度前缀方案实现出来。我定义一个send_message(sock, message: bytes)函数用于发送内部先把消息长度用 4 字节无符号整数struct.pack(!I, len(message))作为头部再把“头部 消息体”一起发送。接收方写一个recv_exact(sock, n)函数保证读满 n 个字节才返回。import struct def send_message(sock, message: bytes): header struct.pack(!I, len(message)) sock.sendall(header message) def recv_exact(sock, n): chunks [] remain n while remain 0: chunk sock.recv(remain) if not chunk: raise ConnectionError(连接已关闭无法继续读取) chunks.append(chunk) remain - len(chunk) return b.join(chunks) def recv_message(sock): header recv_exact(sock, 4) (msg_len,) struct.unpack(!I, header) return recv_exact(sock, msg_len)这段代码里的recv_exact是一个很关键的工具函数。因为在 TCP 流里一次recv(n)不保证恰好返回 n 个字节你需要用循环去读直到凑满预计长度。很多封装库的read()方法内部就是这样实现的。今后只要有人对你说“用recv接收大数据时报错”你基本可以怀疑是没有做到循环读满。6.2 消息结构的设计消息头里还能放什么一旦你引入了“长度前缀”的概念就可以顺理成章地把协议做得更严谨。比如我习惯用固定 8 字节的头部前 2 字节存协议版本号接下来 2 字节存消息类型最后 4 字节存消息体长度。这样接收方可以根据消息类型走不同的业务处理分支。对初学者来说这个设计也许显得繁琐但它在真实项目里是常态每个字节的含义都是双方约定好的。设计协议时的原则是字段要固定、顺序要明确、整数要统一字节序。上面的!I表示网络字节序大端的无符号整型使用网络字节序是为了保证不同 CPU 架构的机器之间解读一致。如果你在自己本地玩大端小端可能无所谓但一旦涉及跨机器这个问题就会暴露。6.3 给服务端加上心跳检测与优雅关闭一个更好的服务端不能只是无限while True地select()还需要响应退出信号。Python 的signal模块可以捕获SIGINT/SIGTERM在退出前做清理。结合心跳检测我可以给 selectors 版本加一个定时器逻辑用一个字典记录每个连接最后活跃时间在每次事件循环里检查如果超过 60 秒则认为超时。import time import socket import selectors sel selectors.DefaultSelector() last_active {} def check_alive(limit_seconds60): now time.time() expired [] for conn, active_time in list(last_active.items()): if now - active_time limit_seconds: expired.append(conn) for conn in expired: print(f[!] 连接 {conn.getpeername()} 心跳超时主动断开) sel.unregister(conn) conn.close() last_active.pop(conn, None) def main(): listen_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind((127.0.0.1, 8888)) listen_socket.listen(5) listen_socket.setblocking(False) sel.register(listen_socket, selectors.EVENT_READ, accept_wrapper) while True: events sel.select(timeout1) for key, _ in events: callback key.data callback(key.fileobj) check_alive()这段代码里sel.select(timeout1)很关键它最多等待 1 秒如果这 1 秒内没有任何事件就返回空列表然后执行check_alive()。如果timeoutNone代码会永远阻塞在select()心跳检测代码永远不会执行服务端也就无法主动清理坏连接。6.4 数据加密Socket 之上如何保证私密性如果你的通信内容涉及账号密码、密钥等敏感信息裸用 TCP 传输是把数据明文暴露在网络上。常规做法是引入 TLS/SSL 层Python 标准库有ssl模块可以直接在已有的 TCP Socket 外包装一层ssl.SSLContext加载证书后通过context.wrap_socket(conn, server_sideTrue)得到加密 Socket。之后所有sendall/recv调用的语义不变但数据已经在内部完成了加密与解密。这个做法属于锦上添花入门阶段不需要立刻掌握但要清楚在真实生产环境中这层加密是标配。7. 从这个小项目还能扩展到哪里个人体会是做完 Server 与 Client你可以试着给自己出几个小题目把能力边界撑开。比如把这个 Echo 程序改造成一个简单的多人聊天室协议里需要一个消息类型字段来区分“普通消息”和“系统通知”或者做成一个文件传输工具发送方要分段读取文件并附带文件名、总长度、校验值等信息再或者给 Client 端加一个断线重连机制模拟网络抖动场景下的重试逻辑。每一项改动都会把你推到一个新问题的边缘而正是这些问题的解决过程才能真正帮你建立对网络编程的直觉。如果后续想读一些开源代码验证自己的理解我建议先从 Python 标准库的http.server模块读起然后过渡到socketserver里的ThreadingTCPServer。你会发现它内部做的事情跟我这个多多线程版本的大体思路一致一个主循环负责 accept每来一个连接就交给新的处理器。当年我看到这段源码时最大的感受是“原来框架并没有魔法”。最后再分享一个经验调试网络程序时最常用的工具除了代码里的print之外还有两个一个是tcpdump图形化一点的可以用 Wireshark另一个是netstat/ss查看端口状态。用 Wireshark 抓一次本地回环的 TCP 三次握手过程你就能把教科书上的 SYN、SYN-ACK、ACK 跟实际的网络包对应起来。这种“眼见为实”带来的踏实感是任何文档都给不了的。我的建议是在你把bind、accept、connect、sendall、recv、close这套主流程跑通之后留一点时间抓包看看你会发现另一个维度的网络世界。
阅读完成 · 觉得有帮助?
咨询建站