ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零实现Python Socket:Server/Client通信与粘包处理

从零实现Python Socket:Server/Client通信与粘包处理 1. 项目概述与整体设计思路1.1 核心需求解析这个项目做的是最基础的网络通信骨架用一个 Python 进程充当 Server监听端口等待连接另一个进程充当 Client主动发起连接并交换数据。很多人觉得 Socket 编程是老古董现在都有 requests、httpx 这么成熟的库谁还手动撸 Socket但我自己的体会是越往底层走越能理解网络通信的本质。HTTP 协议本身就是在 TCP Socket 之上封装了一层格式约定你连最原始的 Socket 收发都没摸过遇到连接池耗尽、半包粘包、超时重试这类问题就会非常被动。标题里写的 从零实现我理解并不是让你去重新发明 TCP/IP 协议栈而是把 Server 和 Client 之间最朴素的那条数据通道亲手搭一遍服务端创建套接字、绑定地址、监听端口、接受连接客户端创建套接字、发起连接、发送数据双方按照约定好的格式收发消息。这个过程走完你对网络编程的整个心智模型就立起来了。这套东西适合谁刚学完 Python 基础语法、想跨入网络编程门槛的人工作中经常跟接口联调、被 Connection refused 和 Timeout 折磨的后端开发还有想搞清楚 TCP 三次握手、四次挥手在实际代码里长什么样的学习者。我建议你一定要自己动手敲一遍不要直接复制粘贴哪怕代码跟我写的一模一样重新敲的过程里你才会注意到很多细节。1.2 为什么选择 Python 来做这个项目用 Python 写 Socket 几乎是最低门槛的入门方式原因有三个。第一标准库 socket 模块直接封装了 BSD Socket API你不需要像 C 语言那样手动处理结构体指针、字节序转换和内存分配能用最少的代码把核心逻辑跑通。第二Python 的交互式环境让调试变得很舒服我可以一边开着一个服务端一边在另一个终端里反复测试客户端的各种异常场景改一行代码立刻能看到效果。第三生态里还有 socketserver、asyncio 这些更上层的封装学完基础之后可以平滑过渡到生产级的并发模型。不过这里要提醒一句Python 的 GIL 和性能特性决定了它不太适合做超高并发的网关类服务但作为教学项目、内部工具、原型验证它完全够用。我自己做过一个内部的数据上报服务就是用 Python 的 Socket 加 Threading 处理的日均几百万条消息一点问题没有。真正的瓶颈往往不在语言本身而在你的架构设计和资源管理方式。1.3 项目目标与成果预期通过这个项目你最终要能独立实现这样的场景启动 server.py 之后终端显示服务端已在某个端口监听打开另一个终端运行 client.py输入一句话服务端立刻收到并在自己的控制台打印出来同时给客户端回一条确认消息客户端再把这个响应打印出来。这就是一个完整的请求-响应循环。进阶目标包括支持多个客户端同时连接、服务端主动推送消息、粘包与半包的处理、优雅关闭连接。我在后面几节会把这些坑一个个展开讲因为这些都是实际生产环境里几乎必然遇到的问题也是面试官最爱问的点。2. 核心细节解析与实操要点2.1 Socket 通信的基本原理解读先把最核心的东西讲透。Socket 本质上就是一个文件描述符操作系统把网络连接抽象成了可以读写的文件你在应用层对它执行 read/write内核负责把数据打包成 TCP 报文段经 IP 层封装通过网卡发出去。接收方反过来内核把报文解包数据放进 Socket 的接收缓冲区应用层调用 recv 的时候从缓冲区拷贝出来。这个缓冲区的概念特别重要很多 Bug 的根源都在这里。发送方调用 send 只是把数据拷贝到了内核的发送缓冲区并不代表对方已经收到更不代表对方应用层已经读出来。接收方调用 recv 是从自己的内核接收缓冲区拿数据如果对方的数据还没到recv 就会阻塞在那里等待。生活化的类比是这样的你往邮筒里投了一封信send() 就是把信丢进邮筒的动作这只能说明邮局内核接管了信件不代表收件人已经看到。recv() 就是你站在自家门口等邮递员送信信没到之前你就一直等着。理解了这层关系你就能明白什么叫做异步什么叫做阻塞以及为什么网络编程里动不动就讨论超时设置。再说一下 TCP 的三次握手和四次挥手在代码层面的体现。三次握手是 connect() 成功时已经完成的事它发生在内核里应用层感知不到你只知道 connect 没有报错。四次挥手对应 close() 的调用过程主动关闭方进入 FIN_WAIT 状态被动关闭方进入 CLOSE_WAIT 状态如果代码里忘记关闭连接或者关闭顺序不对你会在系统里看到大量 CLOSE_WAIT 的僵尸连接占着资源。这些状态用 netstat -anp 就能看到排查网络问题的时候特别有用。2.2 Server 端各个函数的职责拆解服务端的核心逻辑可以拆成五个函数调用按顺序执行。socket.socket(family, type) 创建套接字对象family 用 AF_INET 表示 IPv4 地址族type 用 SOCK_STREAM 表示流式套接字对应 TCP 协议。这一步相当于买了一部电话机。bind((host, port)) 把套接字绑定到具体的 IP 地址和端口上。这里有一个常见的坑host 参数填 127.0.0.1 表示只允许本机回环地址访问外部机器连不上填 0.0.0.0 表示绑定所有网卡接口外部才能通过机器的实际 IP 访问。我自己调试的时候习惯先用 127.0.0.1因为安全也省事但如果你要部署到服务器上提供服务必须改成 0.0.0.0。listen(backlog) 让套接字进入监听状态backlog 参数表示等待连接队列的最大长度。假如你的服务端正在处理一个连接请求此时又来了新的连接请求这些请求就会排队backlog 就是这个队伍能排多长。超过了之后再来的连接会被内核直接拒绝。这个参数不是越大越好它跟系统配置、你处理请求的速度都有关系。accept() 是阻塞调用它会一直等在那里直到有客户端发起连接。一旦有连接进来accept 返回一个新的套接字对象 conn 和客户端的地址信息 addr。注意这个新套接字才是用来跟客户端通信的原来那个监听套接字还要继续干活等着接受下一个连接。你可以理解为前台接待员监听套接字只负责把客人带到会议室新套接字然后立刻回到前台等下一个客人。recv(buffer_size) 从连接套接字接收数据buffer_size 是单次接收的最大字节数。比如你填 1024如果对方一次发了 2048 字节第一次 recv 只会拿到前 1024 字节剩下的还在内核缓冲区里需要再次调用 recv 才能拿到。这就是粘包和半包问题的最基本原理。close() 关闭连接释放资源。别小看这一步在 Python 里如果不显式关闭虽然垃圾回收机制最终会回收掉 socket 对象但时机不可控大量连接堆积起来就会报 Too many open files 的错误。2.3 Client 端核心步骤与注意事项客户端相对简单核心就是三步。socket.socket() 创建套接字然后 connect((host, port)) 发起连接。connect 的 host 参数必须填服务端实际监听的地址如果服务端 bind 的是 127.0.0.1客户端也填 127.0.0.1 就能连上如果服务端 bind 的是 0.0.0.0客户端可以填 127.0.0.1也可以填服务端机器的局域网 IP。端口号必须精确一致填错了直接 ConnectionRefusedError。连接成功之后send() 或者 sendall() 发送数据。send() 可能没有把全部数据发出去返回的是实际发送的字节数你需要自己循环把剩余的数据继续发出去。sendall() 则是对 send 的封装内部自动循环发送直到全部发完如果中途出错会抛异常。所以日常写代码优先用 sendall()省心很多。收发完毕之后调用 close() 关闭连接。客户端的关闭顺序一般没有服务端那么讲究但要记住一个原则如果服务端先 close客户端再调用 recv 会收到一个 b 空字节串这是正常的关闭信号如果客户端直接 close服务端对应连接的 recv 也会返回空串此时服务端应该主动关闭这个连接套接字。有个细节比较容易忽略send 的数据必须是 bytes 类型不能直接传 str。所以你要么在字符串前加 b 前缀变成字面量字节串要么调用 str.encode(utf-8) 转换。对应的recv 收回来的是 bytes你要显示成字符串就得 decode。新手在这一步卡住很常见报错信息 TypeError: a bytes-like object is required, not str 就说明类型没转对。3. 实操过程与核心环节实现3.1 环境准备与基础代码骨架开始写代码之前先把环境确认一下。Python 版本至少 3.8 以上我这台机器用的是 3.10标准库的 socket 模块不需要额外安装直接 import 就能用。操作系统不限Windows、macOS、Linux 都能跑但要注意 Windows 下 CtrlC 中断服务端的方式略有差异我在后文会专门讲。先搭一个最小的服务端骨架把这个跑通你就有信心继续往下加了。import socket # 创建 TCP Socket server_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 设置 SO_REUSEADDR允许端口复用 server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 绑定地址和端口 server_socket.bind((127.0.0.1, 8888)) # 开始监听backlog 设为 5 server_socket.listen(5) print(Server listening on 127.0.0.1:8888) # 接受连接进入循环 while True: conn, addr server_socket.accept() print(fClient connected from {addr}) # 接收数据 data conn.recv(1024) if data: print(fReceived: {data.decode(utf-8)}) # 回显确认消息 conn.sendall(fServer received: {data.decode(utf-8)}.encode(utf-8)) # 关闭连接 conn.close()对应最小的客户端代码import socket # 创建 TCP Socket client_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 连接服务器 client_socket.connect((127.0.0.1, 8888)) # 发送数据 client_socket.sendall(Hello, Server!.encode(utf-8)) # 接收服务端响应 response client_socket.recv(1024) print(fServer response: {response.decode(utf-8)}) # 关闭连接 client_socket.close()保存成两个文件先启动 server.py再运行 client.py你会看到服务端打印出发送来的消息客户端打印出服务端的确认消息。这种一收一发的模式是网络通信最基本的闭环。3.2 支持多客户端连接引入线程处理上面那个服务端一次只能处理一个客户端处理完之后才回到 accept() 等待下一个连接。这在真实场景里根本不够用因为 recv 是阻塞的如果客户端连接上之后迟迟不发数据服务端就会卡在这个连接的 recv 上其他客户端全都在后面排队等着。解决办法是为每个连接创建一个新的线程来单独处理主线程继续 accept。这是最经典也最容易理解的多路并发模型。import socket import threading def handle_client(conn, addr): print(fClient connected from {addr}) try: while True: data conn.recv(1024) if not data: print(fClient {addr} disconnected) break print(fReceived from {addr}: {data.decode(utf-8)}) conn.sendall(fServer received: {data.decode(utf-8)}.encode(utf-8)) except ConnectionResetError: print(fClient {addr} forcibly closed the connection) finally: conn.close() server_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind((127.0.0.1, 8888)) server_socket.listen(5) print(Server listening on 127.0.0.1:8888) while True: conn, addr server_socket.accept() # 每个客户端连接单独开一个线程处理 thread threading.Thread(targethandle_client, args(conn, addr)) thread.daemon True thread.start()解释两个细节。recv 返回 b 表示对方已经优雅关闭此时应该跳出循环不用再等了。ConnectionResetError 表示客户端异常断开比如进程被强杀、网络闪断这种情况在真实环境里非常常见必须捕获处理否则线程会带着异常退出控制台刷一堆报错。线程模式还有个隐患如果并发量很大每来一个连接就开一个线程线程本身是有开销的几千个线程同时存在会让系统资源非常紧张。初学者阶段不用过度优化但你要有这个意识后面可以升级到 ThreadPoolExecutor 或者 asyncio。3.3 实现双向持续通信的完整案例把上面的客户端也升级一下变成可以连续发送多条消息的版本。很多教程只演示一次收发就结束了但实际使用中客户端往往需要多次交互。import socket client_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) client_socket.connect((127.0.0.1, 8888)) try: while True: message input(You: ) if message.strip().lower() quit: break client_socket.sendall(message.encode(utf-8)) response client_socket.recv(1024) print(fServer: {response.decode(utf-8)}) finally: client_socket.close()这个客户端会一直等待你输入每输入一行就发给服务端并同步等待响应直到输入 quit 才退出。服务端因为加了循环处理也能持续接收同一条连接上的多轮请求。跑起来之后就有点像一个最简单的命令行聊天室了。需要注意一个细节input() 是阻塞的如果你是在非交互环境下运行比如管道输入要确保输入流有数据。我在实际测试中发现有些读者在 IDE 里运行这种程序会出问题因为 IDE 的控制台输入行为跟系统终端不一样。我的建议是直接用系统自带终端跑遇到问题容易定位。3.4 粘包与半包问题的处理方案这是 Socket 编程最经典的坑必须单独说。粘包的意思是客户端连续两次 send 了 Hello 和 World服务端可能一次 recv 就收到了 HelloWorld因为两次发送的数据在接收缓冲区里粘在一起了。半包正好相反客户端一次 send 了 2000 字节服务端 recv(1024) 只收到前 1024 字节剩下的一半要等下一次 recv 才能拿到。产生粘包的底层原因是 TCP 是面向流的协议数据在传输过程中没有边界内核可能把多次 send 的数据合并成一个段发送也可能把一次 send 的数据拆成多个段发送。接收方 recv 的时候拿到多少完全是基于缓冲区当前有多少数据跟发送方 send 了几次没有对应关系。破局的思路是应用层自己定义消息边界。最简单通用的方案有两种。第一种是固定长度每条消息都用固定字节数填充不够就补空格接收方每次 recv 固定长度。这个方案浪费空间但实现最简单。第二种是长度前缀每个消息的前 4 个字节用 struct 模块打包成消息长度接收方先 recv 4 个字节解析出长度再继续 recv 直到收满这个长度的数据。这是我在实际项目里最常用的方案。import struct def send_message(sock, message: str): data message.encode(utf-8) # 前 4 个字节用网络字节序打包消息长度 header struct.pack(I, len(data)) sock.sendall(header data) def recv_exact(sock, n: int): data b while len(data) n: chunk sock.recv(n - len(data)) if not chunk: break data chunk return data def recv_message(sock): # 先接收 4 个字节的消息头 header recv_exact(sock, 4) if len(header) 4: return None msg_len struct.unpack(I, header)[0] # 再接收完整的消息体 body recv_exact(sock, msg_len) return body.decode(utf-8)recv_exact 这个函数很关键它保证了一定要收满 n 个字节才返回这样读取完整消息就不会被半包问题干扰了。我在生产代码里几乎都是这种模式稳定可靠。还有一种方案是使用特殊分隔符比如每条消息以 \n 结尾接收方读到 \n 就算一条完整消息但这种方式对消息内容有限制消息里面不能包含分隔符所以还是长度前缀最通用。4. 常见问题与排查技巧实录4.1 端口被占用与 Address already in use 的排查这是新手最容易踩的坑。服务端运行中你按 CtrlC 中断了进程再重新启动时直接报错 Address already in use。原因是操作系统里这个端口还处于 TIME_WAIT 状态。TCP 四次挥手中主动关闭方会保持 TIME_WAIT 状态一段时间通常是 2MSL约 1 到 4 分钟这是为了让迟到的报文段在网络中自然消失避免污染新的连接。解决方式有两种。一是代码层面在 bind 之前设置 SO_REUSEADDR让端口可以立即复用。我在上面的示例代码里已经加了这个选项这也是几乎所有网络服务的标准做法。二是手动处理Windows 下用 netstat -ano | findstr 8888 查端口占用然后 taskkill /PID 进程号 /F 强杀进程Linux 下用 lsof -i:8888 查占用kill -9 进程号。还有一个类似的坑是 通常每个套接字地址(协议/网络地址/端口)只允许使用一次这个在 Windows 上特别常见。通常是多个服务同时 bind 了同一个端口或者服务没有正常退出导致端口未释放。我一般先检查是不是有 Python 进程没杀干净再检查是不是代码里明明设置了端口却被系统分配了随机端口。4.2 ConnectionRefusedError 连接被拒绝的原因分析客户端 connect 时报 ConnectionRefusedError核心原因是服务端根本没有在目标端口上监听。可能的情况有这么几种。第一服务端没启动或者启动后因为异常退出了。你检查一下服务端控制台看看有没有报错。第二端口不一致。客户端连接的端口跟服务端 bind 的端口不是同一个数字我见过不少读者把服务端端口写成 8888客户端却连 8889。第三IP 地址不对。服务端 bind 的是 127.0.0.1客户端却填了机器的局域网 IP自然连不上。第四防火墙拦截。Windows 防火墙或者 Linux 的 iptables 规则把入站连接挡掉了尤其是跨机器测试的时候。我的排查顺序是先在服务端所在机器上直接 telnet 127.0.0.1 8888 看能否连通能通说明服务端没问题再去查防火墙和其他机器的问题不能通就回去查服务端代码和进程状态。这样一步步缩小范围比到处猜有效率得多。4.3 recv 阻塞与超时设置的正确姿势默认情况下recv 是阻塞模式如果对方一直没有发数据程序就会一直卡在那里。有些场景你不想无限等下去这时候要给套接字设置超时。server_socket.settimeout(5.0) # 单位是秒设置了超时之后如果 5 秒内没有收到数据recv 会抛出 socket.timeout 异常。你可以捕获这个异常决定是重试还是放弃。非阻塞模式也可以把 settimeout(0) 或者 setblocking(False)recv 会立即返回并抛出 BlockingIOError 如果缓冲区没有数据。但非阻塞模式对处理逻辑的要求高很多稍不注意就会出现忙等待浪费 CPU初学者我建议先从设置超时开始。还有一个细节单次 recv(1024) 如果缓冲区里的数据超过 1024 字节你只读走了部分数据剩下的数据下次再 recv 会继续读。所以无论如何你都要设计好读取循环不要假设一次 recv 就能拿到完整的一条消息。4.4 Windows 环境下的 CtrlC 与退出清理Windows 上跑阻塞的 accept() 循环时按 CtrlC 有时候不会立即退出而是卡在那里。这是因为 accept 的阻塞发生在内核层信号处理不一定能立刻中断它。我在命令行窗口测试时碰到过这种情况按一次没反应多按几次才退出或者要等连接请求进来才退出。一个相对靠谱的解决方式是在服务端代码里加一个 SignalHandler收到 SIGINT 信号后设置一个全局标志位同时强制关闭监听套接字让 accept 抛异常退出。import signal import socket running True def handle_signal(signum, frame): global running print(Shutting down...) running False if server_socket in globals(): server_socket.close() signal.signal(signal.SIGINT, handle_signal) server_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind((127.0.0.1, 8888)) server_socket.listen(5) while running: try: conn, addr server_socket.accept() # 处理连接 except OSError: break这个写法不复杂但非常实用。在真实部署环境里你还需要考虑进程被 kill 命令杀掉之后端口怎么释放的问题SO_REUSEADDR 这时候就是你的保命符。5. 经验总结与进阶方向5.1 我在实际开发中的关键体会做了这么多年的网络通信相关项目有一个体会特别深写 Socket 代码最难的从来不是调通能通的那条路径而是把所有异常情况都处理干净。正常流程谁都能写但一个 recv 返回空串怎么处理、ConnectionResetError 怎么收尾、发送超时怎么重试这些脏路径才是真正拉开代码质量差距的地方。我见过不少项目正常跑一整天都没事一到网络抖动就崩就是因为异常分支没有写好。所以我的建议是跑通基础代码之后不要急着做业务功能先花时间把各种异常场景都测一遍拔网线、强杀进程、超时、粘包、大量并发连接把这些情况下程序的行为摸清楚。你在这个阶段踩的坑以后在生产环境都会少踩一遍。5.2 从基础 Socket 到并发模型的演进路径如果你把上面的例子都跑熟了下一步有几个方向可以继续深入。第一个方向是异步编程。Python 的 asyncio 提供了一个更轻量级的并发模型配合 asyncio.open_connection 和 asyncio.start_server单线程内就能处理成千上万的连接适合 I/O 密集型的网络服务。我强烈建议学完基础 Socket 之后去了解 asyncio因为它在企业级项目里的出镜率实在太高了从 FastAPI 到各种消息推送服务底层都有它的影子。第二个方向是高级封装的网络框架。比如用 socketserver 库的 ThreadingTCPServer 快速搭建多线程服务或者用 Twisted、Tornado 这种成熟框架处理更复杂的网络协议。框架的好处是帮你把连接管理、协议解析、线程模型这些繁琐的事情都封装好了让你专注业务逻辑。第三个方向是 RPC 和消息中间件。当你理解了 Socket 之后再去看 gRPC、Kafka 这种服务之间的通信方式会有一种豁然开朗的感觉——它们本质上都是在解决两个进程之间可靠地交换数据这个问题的不同阶段方案。我自己当年就是从 Socket 一路学到 gRPC很多概念都是这么一层一层串起来的。如果你有兴趣还可以试着用 Socket 实现一个简单的 HTTP Server手动解析 HTTP 请求行和头部返回一个 HTML 页面。这个练习会把你对网络协议的理解推上一个新台阶。等你哪天发现所谓的高性能框架、微服务通信底层不过就是 Socket 加一堆约定协议在转来转去那你就真的入门了。
返回列表