Python Socket编程实战:从TCP原理到高并发与粘包处理 我最早正经用Python写socket是为了从车间一台老设备上拉数据。那设备只认TCP协议文档三页纸都不到接口就是一个端口、一段十六进制指令。我刚开始还用requests那套思路去连结果当然连不上后来老老实实回到socket编程才明白什么叫“传输层的真实样貌”。如果你写过HTTP请求、爬过网页但一看到socket()、bind()、listen()、accept()就发怵这篇内容就是给你准备的。我会把Python Socket网络通信从原理到代码、从单机调试到多客户端并发再到粘包、端口占用这些高频坑完整过一遍。学完之后你至少能独立写出一个可用的TCP服务端和客户端也能看懂网上大部分socket示例代码为什么那样写。1. 为什么现在还要翻Socket它在TCP/IP网络里到底管哪一段1.1 网络分层Socket不是协议是一道“柜台”先解决一个最容易被忽略的问题Socket到底是个什么东西它不是协议也不是TCP本身而是操作系统提供给应用程序的一套网络编程接口。我们可以把整个网络通信想象成寄快递你写好一封信这是应用层的内容快递公司帮你把信装进信封、填上收件人地址这是传输层和网络层的工作而Socket就是快递公司的柜台。你不需要自己开车去送信只需要把信件交给柜台剩下的封装、路由、重传都交给操作系统内核处理。如果你打开一台Linux服务器的/proc/net/tcp或者在Windows上执行netstat看到的每一个TCP连接本质上都对应一个Socket。Python里的socket模块就是对这套操作系统接口的封装。所以当你写socket.socket()的时候其实是在向操作系统申请一个“可以收发网络数据的文件句柄”。这也是为什么后面所有的读和写看起来都像在读文件——网络通信在Unix哲学里就是“一切皆文件”的延伸。理解了这层关系你就不会把Socket和HTTP、TCP混为一谈。HTTP是基于TCP的一种应用层协议而Socket是你和TCP之间打交道的接口你可以用Socket收发任意字节流不一定要遵守HTTP格式。1.2 一次HTTP请求背后Socket做了什么用你最熟悉的场景来对照浏览器访问一个网站背后到底发生了什么第一步浏览器调用操作系统的Socket接口创建一个套接字第二步通过connect()向目标服务器的80或443端口发起TCP连接这一步会完成三次握手第三步连接建立后浏览器把HTTP请求报文通过send()发送出去第四步服务器通过accept()接收到新连接再通过recv()读取请求第五步服务器处理完把HTTP响应通过sendall()返回第六步浏览器recv()拿到响应渲染页面。也就是说requests、urllib这些库归根到底都在Socket之上工作。它们之所以好用是因为帮你把HTTP报文拼装、解析、状态码判断这些重复劳动都包掉了。但一旦你遇到非HTTP协议比如自定义TCP协议、Modbus、MQTT的底层实现、内网设备心跳上报你就必须回到Socket这一层。1.3 先选对通行方式TCP还是UDPPython的socket.socket()第一个参数是地址族第二个参数是套接字类型。地址族我们99%的场景都用AF_INET也就是IPv4而套接字类型最常见的是SOCK_STREAM和SOCK_DGRAM分别对应TCP和UDP。维度TCPSOCK_STREAMUDPSOCK_DGRAM连接需要建立连接一对一无连接直接发数据报可靠性可靠传输有重传和确认不可靠丢了就丢了数据边界字节流没有消息边界保留消息边界一次send对应一次recv应用场景文件传输、HTTP、数据库连接实时音视频、DNS查询、游戏状态同步我自己的经验是凡是需要可靠、有序传输的业务无脑选TCP凡是允许丢包、但要求低延迟的场景才考虑UDP。比如你要传一个控制指令给设备漏掉一条可能导致设备状态错误那就必须TCP。反过来如果是视频画面偶尔丢一帧人眼看不出来UDP更合适。2. 让两台程序第一次说上话服务端与客户端的完整骨架2.1 服务端五件套socket、bind、listen、accept、close写一个最朴素的TCP服务端只需要五步。我先把代码贴出来再逐个解释为什么每一步都不能少。import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((127.0.0.1, 8899)) server.listen(5) print(服务端已启动监听 127.0.0.1:8899) while True: conn, addr server.accept() print(f收到来自 {addr} 的连接) data conn.recv(1024) print(f收到数据: {data.decode()}) conn.sendall(bhello from server) conn.close()socket.socket(socket.AF_INET, socket.SOCK_STREAM)创建了一个IPv4的TCP套接字。bind((127.0.0.1, 8899))把套接字绑定到本机的IP和端口。这里有个常见疑问为什么127.0.0.1和0.0.0.0不一样127.0.0.1只允许本机访问适合本地调试0.0.0.0表示监听所有网卡局域网里的其他机器也能连。如果写成本机的具体局域网IP那就只监听那块网卡。listen(5)是让套接字进入监听状态参数5是backlog表示内核里等待accept()处理的连接队列最大长度。注意它不是你最多能同时服务的客户端数量而是“还没被accept的排队连接数”。accept()是个阻塞调用它会一直等直到有客户端来连接然后返回一个conn——这个conn才是真正和该客户端通信的套接字。原来那个server套接字继续负责接新客人。close()关闭连接。很多人会忘记关在长连接服务里这就是文件描述符泄漏的源头。2.2 客户端三连socket、connect、send/recv对应的客户端代码更简单import socket client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((127.0.0.1, 8899)) client.sendall(bhello from client) resp client.recv(1024) print(f服务端回应: {resp.decode()}) client.close()connect()会发起TCP三次握手。如果服务端没启动你会立刻看到ConnectionRefusedError。sendall()是send()的加强版。send()一次不一定能发完所有字节而sendall()内部会循环调用send()直到所有数据都写进内核缓冲区才返回。我建议所有需要“完整发送”的场景都用sendall()不要用send()。recv(1024)表示最多读取1024字节。这里有个大坑它返回的字节数不一定是1024也可能更少取决于内核缓冲区里有多少数据。这个点后面讲粘包的时候会重点展开。2.3 一个最简单的回声服务先跑通再说把上面两段代码组合起来做个echo服务客户端发什么服务端就原样返回什么。服务端import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((127.0.0.1, 8899)) server.listen(5) while True: conn, addr server.accept() with conn: print(f连接: {addr}) while True: data conn.recv(1024) if not data: break conn.sendall(data)客户端import socket with socket.create_connection((127.0.0.1, 8899), timeout5) as client: client.sendall(bping) back client.recv(1024) print(back)注意服务端里用了两层循环外层不断accept()新的客户端内层循环负责把一个客户端发来的所有数据echo回去。如果没有内层循环收一次数据就关连接后面的数据全丢。这段代码能跑通但缺点很明显内层recv()在等待数据时整个服务端是阻塞的一个新客户端连过来也没人搭理。这个问题我们到第4部分再解决。2.4 为什么必须理解“字节流”这件事TCP是流式协议。什么叫流式想象一根水管你把10字节和20字节两段数据先后塞进去对端在水管另一头捞出来的可能是一整个30字节也可能先捞出5字节、再捞出25字节。数据之间没有天然的分界线。Python里sendall()只是把数据交给内核缓冲区不保证对端recv()一次就拿到全部recv()只是从内核缓冲区里尽力拿n字节不保证拿到的是完整一条消息。所以任何基于TCP的通信协议都必须自己在应用层定义“消息边界”否则就会出现粘包、半包。这个坑值得单独用一整章来讲。3. 粘包、半包与缓冲区TCP流式传输的第一课3.1 粘包到底是怎么来的“粘包”这个词听起来很吓人其实描述的现象很朴素客户端连续发送两条消息A和B服务端recv()一次把两条都读走了看起来就像A和B粘在了一起。产生原因主要有两个。一是Nagle算法TCP为了减少小包数量会把连续发送的小数据合并成一个包再发相当于把几封信塞进一个快递袋。二是接收方调度时机内核缓冲区的数据积累到了一定量或者服务端恰好在这时才执行recv()自然一次拿走了更多数据。举个例子客户端连续执行两次sendall(bAAA)和sendall(bBBB)服务端一个recv(1024)可能直接拿到bAAABBB。代码上你完全看不出问题但业务逻辑上必须知道AAA和BBB是两条独立消息不能混在一起处理。3.2 为什么UDP没有粘包UDP和TCP最大的区别之一就是UDP保留消息边界。sendto()一次发送的数据报对端recvfrom()一次就能完整拿到多一分不多、少一分不少。就像寄包裹每个包裹是独立包装签收时不会把两个包裹混成一个。但UDP换来的是不可靠数据报可能丢失、乱序、重复。所以很多游戏协议宁可自己在上层加序号和确认也不愿意处理TCP的粘包和队头阻塞。我个人的看法是如果你的应用层协议需要高可靠性不要因为粘包麻烦就换UDPTCP的粘包是有成熟解法的可靠性的代价在UDP上反而更大。3.3 应用层帧协议长度前缀是最实用的解法解决粘包的通用思路就是让消息带上“长度信息”。最简单的方案是一条消息 4字节长度头 实际数据。接收方先读4字节解析出长度再按照这个长度读完整条数据。import socket import struct def send_msg(sock: socket.socket, data: bytes): # !I 表示网络字节序大端的4字节无符号整数 header struct.pack(!I, len(data)) sock.sendall(header data) def recv_exact(sock: socket.socket, n: int) - bytes: chunks [] remaining n while remaining 0: chunk sock.recv(remaining) if not chunk: raise ConnectionError(对端连接已关闭) chunks.append(chunk) remaining - len(chunk) return b.join(chunks) def recv_msg(sock: socket.socket) - bytes: header recv_exact(sock, 4) length struct.unpack(!I, header)[0] return recv_exact(sock, length)这里最关键的是recv_exact()。它解决的是“半包”recv(remaining)可能一次读不到那么多字节所以要用循环凑够。比如协议约定数据体有1024字节可能第一次recv()只返回了300字节那就要继续读722字节直到凑满。struct模块的!I采用网络字节序也就是大端序。为什么用大端因为TCP/IP协议族规定多字节整数的传输统一用大端序避免不同CPU架构解析出相反结果。3.4 实战里的补充要不要关掉Nagle有些教程会让你设置TCP_NODELAY为1也就是关掉Nagle算法让每个sendall()都立刻发送。这确实能缓解部分粘包让数据看起来“更及时”但它治标不治本。因为粘包不仅由Nagle引起还和接收方的调度、缓冲区大小有关。我试过在生产环境里关掉Nagle代价是网络里小包数量剧增对带宽和CPU都是浪费。更可靠的做法是坚持在应用层用长度前缀定义消息边界让数据无论怎么粘、怎么拆都能被精确还原。Nagle算法本身是在“延迟”和“带宽”之间做权衡如果你的业务不是实时性极高不必动它。4. 多客户端同时接入怎么办从阻塞死循环到并发模型4.1 单线程accept的问题到底出在哪第2部分的echo服务一次只能服务一个客户端。如果你用两个终端分别去连接它第二个连接会一直卡在connect()上直到第一个客户端断开并腾出连接。原因很简单服务端主线程阻塞在第一个conn.recv()里根本没机会回到accept()接新客。这不是代码写错了而是阻塞式编程模型的固有局限。但现实场景里多客户端并发是常态一个设备管理服务可能要同时维护几十条TCP长连接一条连接一个线程是最直观的解法。4.2 多线程方案用socketserver省掉一半代码Python标准库里的socketserver模块提供了封装好的并发服务。ThreadingTCPServer每来一个连接就开一个新线程处理代码清晰很多from socketserver import ThreadingTCPServer, StreamRequestHandler class EchoHandler(StreamRequestHandler): def handle(self): while True: data self.rfile.readline() if not data: break self.wfile.write(becho: data) with ThreadingTCPServer((127.0.0.1, 8899), EchoHandler) as server: server.serve_forever()StreamRequestHandler把连接封装成了文件流self.rfile读客户端发来的数据self.wfile写响应。如果你的消息是带换行的文本用readline()很顺手如果是二进制协议最好还是回到self.connection.recv()自己按长度解析。多线程方案的代价是线程资源。如果客户端数量上千每个连接一个线程会让系统线程数爆炸。这时候要么用线程池限制并发数要么换事件驱动模型。4.3 事件驱动用selectors写一个单线程高并发服务Python标准库还提供了selectors它在不同操作系统上会自动选择epoll、kqueue或select是写网络服务的利器。核心思路把每个socket注册到选择器里内核帮忙盯着哪些socket“可读”或“可写”程序只需要在事件到来时处理对应回调。import selectors import socket sel selectors.DefaultSelector() def accept(sock): conn, addr sock.accept() print(f新连接: {addr}) conn.setblocking(False) sel.register(conn, selectors.EVENT_READ, read) def read(conn): data conn.recv(1024) if data: conn.sendall(data) else: sel.unregister(conn) conn.close() server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((127.0.0.1, 8899)) server.listen(100) server.setblocking(False) sel.register(server, selectors.EVENT_READ, accept) while True: events sel.select() for key, _ in events: key.data(key.fileobj)这段代码只用一个线程就能同时管理大量连接。server.setblocking(False)让accept()不阻塞每次有新连接或新数据到来sel.select()会返回对应的socket然后调用注册好的回调函数。写selectors模式要小心read()回调里一个recv(1024)仍然可能只读到半条消息所以真实项目里要在回调里维护每个连接的接收缓冲区把长度前缀解析逻辑搬进去。这个改造会比echo复杂一些但换来的是单线程支撑几千连接的能力。4.4 并发模型怎么选一张表看清适用边界方案代码复杂度适合场景注意点多线程手写中几十到几百连接线程资源有限记得控制数量ThreadingTCPServer低中小型服务每个连接一个线程高并发受限selectors事件驱动高几千连接的长连接服务半包处理要自己做asyncio高同一进程内高并发IO需要熟悉async/await语法我自己的习惯是个人工具和内部小服务用socketserver因为快、不容易写错正式的高并发网关用selectors或asyncio重写。别一上来就追求最高性能先保证逻辑正确。5. 网络通信高频坑实录端口占用、连接重置与心跳缺失5.1 Address already in use端口明明关了为什么还占用写服务端最常遇到的报错就是OSError: [Errno 98] Address already in use很多人的第一反应是“上一个程序还没关”。确实有可能但更常见的原因是TIME_WAIT状态。TCP四次挥手之后主动关闭连接的一方会进入TIME_WAIT状态默认要等2MSL大约1到4分钟才能彻底释放端口。如果你频繁重启服务端端口可能还处于这个状态直接bind()就会报错。解决办法有两个。一是在bind()之前设置SO_REUSEADDRserver.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)二是用命令确认端口状态netstat -an | grep 8899如果状态显示TIME_WAIT等一会儿或者设置SO_REUSEADDR基本就能解决。这个选项允许内核把处于TIME_WAIT状态的端口重新分配给新套接字对服务端来说是必须的。客户端一般不需要设置因为客户端用的临时端口是随机的。5.2 ConnectionResetError对端已经跑了你还在写另一个高频报错是ConnectionResetError: [Errno 104] Connection reset by peer或者你在写数据时遇到BrokenPipeError。这两个错误本质都是对端进程已经异常退出或主动关闭连接但本机还在往这条连接上写数据。TCP发现对端不可达后会发一个RST报文本机内核收到RST后续的write和read就直接失败。经验之谈TCP没有可靠的“告诉我对端还活着”的机制指望发一次数据看报错再处理太被动了。长连接场景最好自己做心跳每隔一段时间发送一个心跳包连续几次没收到回应就主动断开重连。这跟Socket无关但和Socket服务的稳定性强相关。5.3 recv返回空串千万别忽略这个信号服务端循环里最常见的bug是没判断recv()返回值为空while True: data conn.recv(1024) # 忘了 if not data: break conn.sendall(data)当对端正常关闭连接时recv()不会抛异常而是返回b。如果你不处理这个循环会疯狂空转CPU直接飙到100%而且什么数据都没处理。正确的写法是while True: data conn.recv(1024) if not data: break # 处理业务把b当成“连接结束”的哨兵是网络编程的基本素养。5.4 端口选择与防火墙很多“连不上”不是代码问题如果你的服务端监听在0.0.0.0:8899然后另一台机器一直连不上先别急着改代码。先检查防火墙Linux下可能是firewalld或iptables拦截了端口Windows下可能是系统防火墙弹窗被忽略了。另一个建议开发环境统一用127.0.0.1调试部署到服务器再改监听地址。我见过有人本地调试完忘记改监听地址结果服务一直只监听127.0.0.1线上怎么调都连不上。把监听地址和端口写进配置文件而不是硬编码在代码里能省掉很多低级事故。6. 把Socket程序从“能跑”写到“好维护”超时、日志、抓包与安全底线6.1 设置超时不要让程序永久卡死默认情况下recv()在没有数据时会一直阻塞。如果对端宕机你的线程可能卡在那里一整天。给套接字设置超时是最基本的保险client.settimeout(5.0) try: data client.recv(1024) except socket.timeout: print(等待响应超时)settimeout()的参数单位是秒。超时一旦触发会抛出socket.timeout异常。注意Python 3.10之后这个异常是TimeoutError的子类用except socket.timeout仍然兼容。超时时间要根据业务设定局域网内的设备响应快给3到5秒就够跨公网的接口可以放宽到10到15秒。6.2 把日志和心跳当成基础设施网络程序特别难调试因为它不像单机代码可以随便断点错误往往发生在不同机器之间。所以日志一定要从第一天就加上。每接受一个连接记录客户端的IP和端口每条消息记录方向和字节数每次异常记录完整的堆栈。这么做看起来啰嗦但线上排查时价值巨大。我通常会在每个连接里记录连接时长和收发的总字节数用来发现哪些客户端虽然连上了但几乎不通信哪些客户端在频繁重连。心跳机制前面提过服务端可以定时清理长时间没有消息的连接。比如记录每个连接最后活跃时间超过30秒没有收发过数据就主动关闭防止一堆死连接占着文件描述符。这在高并发的长连接服务里是必须的。6.3 抓包看TCP比打日志更直观有时候代码怎么查都看不出问题但网络就是不通。我的建议是直接抓包看TCP报文。Wireshark是最直观的工具。在本地调试时选择lo回环接口可以看到完整的TCP三次握手、PSH、ACK标记还能直观看到粘包是怎么发生的。如果你怀疑消息边界有问题抓包看一眼每个数据包的长度基本就水落石出了。命令行环境可以用tcpdumpsudo tcpdump -i lo port 8899 -w socket_demo.pcap抓完用Wireshark打开文件慢慢分析。我见过好几次“代码明明对对端就是收不到”的问题最后都是抓包发现中间某台机器把包丢了或者改了内容。网络是黑盒的时候抓包是唯一的眼睛。6.4 安全底线别把裸Socket直接暴露公网最后说一个容易被忽略的问题安全。不要把没有任何鉴权和校验的裸Socket服务直接暴露到公网。至少要做三件事第一消息长度校验。用长度前缀的时候要对长度字段做上限判断防止有人发一个“长度2GB”的假头让你的recv_exact()傻等一整天。length struct.unpack(!I, header)[0] if length 10 * 1024 * 1024: raise ValueError(消息长度异常)第二连接数限制。哪怕用selectors也要设置最大连接数防止资源被耗尽。第三敏感数据走TLS。Python的ssl模块可以直接包裹一个Socket让通信内容加密。注意TLS只是加密传输不解决业务鉴权该做的登录校验还是要做。我自己写过一段时间的TCP服务最大的感受是网络编程真正难的不是API而是你永远在和一个“看不见、随时会断、并且不保证顺序”的通道打交道。把超时、心跳、日志、长度校验这些基础设施前置后面的麻烦会少一大半。最后再分享一个小习惯我会在本地始终放一个socket_client.py的小脚本支持命令行传入IP、端口、十六进制报文一秒钟就能测试任意TCP服务。排查问题的时候特别顺手你也可以照着写一个比每次临时打开浏览器端口工具快得多。