☰
Python Socket编程实战:从bind到并发,彻底搞懂粘包与半包
2026/10/10 2:53:35 网站建设 项目流程

我第一次把 Python Socket 跑通,是在一个没有任何现成网络库可用的环境里。当时最让我难受的一件事:服务端明明收到了数据,打印出来却对不上;或者客户端连续 send 了三条消息,服务端一次 recv 就全收走了。这些现象后来都指向同一个根因——Socket 只是一条字节通道,它根本不关心你的消息边界在哪里。

这篇内容不打算讲太深的协议理论,我想从一条最简单的 TCP 连接开始,把 Server 和 Client 的代码从头写起来,再逐步拆开 bind、listen、accept、connect、send、recv 这些系统调用背后的真实行为,包括缓冲区、阻塞、粘包和并发处理。适合刚接触网络编程的 Python 开发者,也适合写过一点 Socket 但总是遇到莫名其妙问题的人。看完你可以得到一套直接照着敲的代码,以及排查问题时的完整思路。

1. 为什么从 Socket 起步:网络通信的最小模型与 Python 的优势

1.1 把 Socket 想成一条水管,而不是一段代码

很多人第一次接触 Socket 时喜欢把它类比成“打电话”,但这个类比容易误导。电话是实时语音,而 Socket 更像一根水管:你这边把水倒进去,对面拧开水龙头接水。水在水管里是一段一段的,中间可能有空气,可能水流忽大忽小,甚至可能几杯水混成一大团流过去。

从程序员视角看,Socket 本质上就是内核提供的一个文件描述符。你在服务端拿到一个 socket 对象,在客户端拿到另一个 socket 对象,两边各自对这个文件描述符做读和写。写进去的字节会通过网络协议栈送到对面,对面的 read 操作会从自己的内核缓冲区里把这些字节取出来。整个过程里没有“消息”这个天然概念,只有字节流。

这一点理解透之后,后面所有问题都好解释:为什么会出现粘包?因为两次 send 的字节可能在内核缓冲区里连成了连续的一段,对面一次性 recv 就全读走了。为什么会出现半包?因为一次 send 的数据可能比较大,在传输过程中被拆成了多个 TCP 分片,对面第一次 recv 只拿到了开头一部分,剩下的还在路上。这不是 Python 的 bug,也不是你代码的 bug,而是字节流模型的固有特性。

1.2 Python 的 socket 库为什么适合做这件事

Python 标准库里的 socket 模块是对操作系统 socket API 的一层薄封装,基本上每个函数都对应一个系统调用。这种“薄”反而适合学习,因为你在 Python 里写的东西,换到其他语言思路是一致的,只是语法不同。而且 Python 有几个优势:

  • 不需要处理指针和内存释放,不容易因为野指针把进程搞崩。
  • 交互式验证方便,我可以同时开两个终端,一个跑服务端,一个跑客户端,很快能看到效果。
  • 标准库自带 struct、selectors、threading 等模块,从最原始的 socket 一直做到并发事件循环,不用装任何第三方包。

当然,Python 的 socket 性能不如 C 或 Go,做高吞吐服务时会有瓶颈。但它是理解网络编程最好的入门工具。等你对模型熟悉了,换语言只是换一套 API 而已。

2. 动手前先想清楚:TCP 还是 UDP,阻塞还是非阻塞

2.1 TCP 和 UDP 的选择逻辑,不能只看“哪个快”

创建 socket 时第一个参数是协议族,第二个参数是套接字类型。最常见的是这两行:

import socket # TCP sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # UDP sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

SOCK_STREAM对应 TCP,SOCK_DGRAM对应 UDP。很多教程会说“TCP 可靠,UDP 不可靠但快”,然后直接推荐 TCP。但在实际项目里,这个选择应该取决于你的业务容忍度。

我一般用这套判断标准:

判断维度适合 TCP适合 UDP
数据完整性转账、订单、文件传输,丢一个字节都不行视频帧、游戏位置同步,丢几帧无所谓
消息顺序后发的消息必须后到顺序乱了可以由上层逻辑纠正
连接状态需要长时间保持连接、服务端主动推送一问一答,无状态查询
应用场景HTTP、数据库协议、消息队列DNS、NTP、音视频流、实时位置上报

需要注意,UDP 也并不是一定比 TCP 快。TCP 慢很多时候是因为重传和拥塞控制,但局域网内 TCP 的吞吐量同样很高。选择的关键是“数据丢了你能不能接受”,而不是“谁更快”。

2.2 阻塞与非阻塞:一开始直接用阻塞模式就对了

Python 的 socket 默认是阻塞模式。所谓阻塞,就是当你调用recv时,如果内核缓冲区里没有数据,这个函数会卡住,直到有数据到达或者连接关闭。同理,accept会卡住,直到有新的客户端来连接。

对初学者来说,阻塞模式是最友好的,因为代码逻辑是线性的,容易理解。刚开始写服务端,就用阻塞模式,把功能跑通再说。只有当你需要同时服务多个客户端,而且连接数可能超过几十上百时,才需要考虑把 socket 设置成非阻塞,配合事件循环使用。

我自己踩过的一个教训是:千万别在一开始就追求“高性能”,用非阻塞 + 复杂状态机,结果半天没跑通,连基本功能都没有。先阻塞,后优化;先单连接,后并发。

3. 服务端三步定生死:bind、listen、accept 的边界与细节

3.1 bind 到底绑定的是什么,端口和地址都别写错

服务端的第一段代码骨架如下:

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', 8080)) server.listen(5) print('server is listening on 127.0.0.1:8080')

bind 接收一个元组,第一个元素是 IP 地址,第二个是端口。这里有两个容易踩的坑。

第一个坑:把 IP 写成'127.0.0.1'之后,只有本机能连,局域网其他机器连不上。原因很简单,127.0.0.1是回环地址,数据包只在本机内部转一圈,不会经过网卡。如果你需要让其他设备访问,应该 bind 到'0.0.0.0',意思是“监听本机所有网卡地址”。在开发调试时用127.0.0.1没问题,但部署到服务器上就要改成0.0.0.0或者具体的公网/内网地址。

第二个坑:端口占用。如果上次运行的程序没有正常关闭,或者端口被其他进程占用,bind 的时候会抛OSError: [Errno 98] Address already in use。这时候你可能会想改一个端口,但其实更合理的办法是设置SO_REUSEADDR。这个选项让内核允许在 TIME_WAIT 状态下重新绑定同一个端口,对开发调试特别有用。

3.2 listen 的 backlog 参数是积压队列,不是最大连接数

很多人看到server.listen(5)以为这是“最多允许 5 个客户端连接”,这是一个很常见的误解。实际上,listen 的参数叫 backlog,它表示内核中已完成三次握手、但还没被调用 accept 获取的连接队列长度。

也就是说,如果客户端连接来得太快,你的应用忙不过来,多出来的连接会排在内核的队列里。队列满了之后,新的连接请求可能被拒绝或延迟。在 Python 里,这个数字的值因操作系统而异,一般写 5 到 128 之间的数都行。关键理解是:它不代表服务的总连接数,只是告诉内核“请帮我缓冲这么多等待 accept 的连接”。

还有一个细节:listen 之后 server 这个 socket 本身不负责收发数据,它只负责接收连接。真正收发数据的是 accept 返回的新 socket 对象。这里我把 server 和 conn 分开理解,后面才不会被混淆。

3.3 accept、收数据、关连接,一个完整的会话循环

看下面这个最简单的服务端主循环:

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', 8080)) server.listen(5) print('等待客户端连接...') while True: conn, addr = server.accept() print(f'新连接来自 {addr}') while True: data = conn.recv(1024) if not data: print(f'{addr} 已断开') break print(f'收到: {data.decode()}') conn.sendall(b'ack: ' + data) conn.close()

外层 while 负责不断接受新连接,内层 while 负责和当前连接持续收发。conn.recv(1024)里的 1024 是“本次最多读取多少字节”,它是一个缓冲区上限,不代表一次 recv 只能收到 1024 字节,更不能保证一次 recv 就能收到对方 send 的全部数据。

当客户端正常关闭连接时,服务端 recv 会返回空字节串b'',这是判断连接是否结束的关键信号。千万不能用if data is None或者if len(data) == 0去判断,虽然空字节串的长度也是 0,但更标准的写法是直接if not data。因为如果对方真的什么都没发送就直接关闭连接,recv 返回的就是空字节串,而不是 None。

3.4 send 和 sendall 的区别,很多人在这里栽过

服务端代码里我用了sendall而不是send。这两者的区别是新手最容易忽略的。

socket.send()并不保证把整个字节串一次发送出去。它会返回实际发送的字节数,这个数字可能小于你传入的字节长度,比如你 send 了 4096 字节,它可能只发了 1500 字节就返回了。原因在于 TCP 的发送缓冲区可能暂时不够,或者底层网络一次最大传输单元的限制。

socket.sendall()会循环调用 send,直到所有数据都发送完毕,或者抛异常。所以在需要把一块完整数据全部发出去时,应该优先用sendall,而不是自己写循环。

有一种情况需要注意:sendall 也不代表对端立刻收到完整数据。它只是保证数据成功写入了本机的内核协议栈缓冲区。数据在网络里的传输、重组,对你来说是不可控的。这也是我们后面要处理粘包问题的前提。

4. 客户端篇:connect 之后的收发节奏怎么控制

4.1 客户端最小骨架:connect、sendall、recv

客户端的代码比服务端短得多:

import socket client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect(('127.0.0.1', 8080)) client.sendall(b'hello server') resp = client.recv(1024) print(f'收到响应: {resp.decode()}') client.close()

connect 是阻塞的,它要完成 TCP 的三次握手。如果服务端没在监听,或者 IP/端口不对,connect 会抛ConnectionRefusedError,错误信息是[Errno 111] Connection refused。这时候不要急着改代码,先确认服务端进程是否在运行、端口是否监听正确。

客户端在调用 recv 时同样会阻塞,直到服务端发了数据或者连接断开。如果你事先不知道服务端会发多少数据,常见做法是先 recv 一下试试,如果业务需要,再配合消息格式循环读取。

4.2 recv 的缓冲区大小该设多少,为什么不是越大越好

recv 的参数表示这次调用最多能读多少字节。我见过有人把它设成 1048576,觉得缓冲区越大越不容易丢数据。但缓冲区大小不会影响数据完整性,它只会影响“一次能读多少”。

如果你设成 8192,而对方一次发了 20000 字节,你第一次 recv 只能拿到先到达的 8192 字节,剩下的还在内核缓冲区里,需要再调用 recv 继续读。如果你设成 65536,可能一次就把 20000 字节全拿走了,但这不能保证永远不会出现半包情况,因为网络分片的大小不由你控制。

我一般习惯用 1024 或者 4096,原因不是性能,而是方便调试。打印日志时,一次最多看到几 KB 数据,不容易刷屏。真正在高性能场景里,这个值确实需要根据实际平均消息大小来调整,但那是服务开发中后期才需要考虑的事。

4.3 客户端收数据的两种模式:定长等待和持续监听

客户端和服务端的交互模式通常有两种。

第一种是“请求-响应”模式。客户端发一条请求,然后等一条响应。这种最简单,直接 sendall 之后 recv 一次或者几次把响应收齐就行。但这里要注意一个问题:你怎么知道响应收齐了?如果服务端只回一句话,然后不关闭连接,那么客户端 recv 一次可能就够了。如果响应比较大,可能就要循环 recv,直到某些约定条件满足。

第二种是“持续接收”模式。服务端会不定时推送数据,比如聊天室消息、行情推送、任务进度通知。这时客户端需要有一个循环一直在 recv,收到数据就处理,处理完继续等。如果有业务逻辑需要超时控制,可以在创建 socket 后设置client.settimeout(5),这样 recv 最多阻塞 5 秒,超时后会抛socket.timeout异常。

我早期写客户端时犯过一个错:请求响应模式里,直接用一次 recv 就认为收完了。如果响应超过几 KB,就会读到一半,后面数据丢失。后来我养成了一个习惯:所有客户端和服务端之间都定义好消息格式,要么固定长度,要么带长度前缀,这样读取就能确定边界。

5. 粘包与半包:日志里最不该忽略的异常现象

5.1 一个小小的 demo 复现粘包现象

我们可以做一个简单的实验。服务端代码如下:

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', 8081)) server.listen(5) conn, addr = server.accept() data1 = conn.recv(1024) data2 = conn.recv(1024) print(f'第一次收到: {data1!r}') print(f'第二次收到: {data2!r}') conn.close()

客户端代码:

import socket client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect(('127.0.0.1', 8081)) client.sendall(b'msg1') client.sendall(b'msg2') client.close()

在很多情况下,服务端第一次 recv 会同时收到b'msg1msg2',第二次 recv 会因为连接关闭而返回b''。这就是粘包。原因我前面说了,两次 send 的数据被合并到了一个 TCP 段里,服务端一次 recv 全取走了。

粘包的后果是:你应用层面的两条消息被合并成一条。如果消息是文本,结果会变成“msg1msg2”;如果消息是二进制结构,那解析出来可能直接报错。这就是为什么所有网络协议都要设计消息边界。

5.2 半包现象:一次 send 对面要多次 recv 才能收全

粘包的反面是半包。假设客户端 sendall 一段 20000 字节的数据,服务端的 recv 参数是 4096,那么服务端可能要循环 recv 五次才能收齐。在最开始的代码里,我只 recv 一次,然后直接拆数据,就会拿到一个不完整的消息。

这里的关键认知是:TCP 是面向字节流的,没有消息边界;底层网络栈会根据 MSS、缓冲区、Nagle 算法等因素,把数据拆分或合并。所以应用层必须自己定义“消息怎么算完整”。

5.3 我的解决方案:长度前缀法,简单又可靠

处理粘包和半包有很多方案,最常见的有三种:

方案做法优点缺点
固定长度每条消息固定 N 字节,不够补零实现最简单浪费带宽,长度不可变
分隔符消息之间加换行符或特殊标记适合文本协议内容里不能出现分隔符,否则需要转义
长度前缀每条消息前加一个固定字节的包头,标明后面数据长度通用、高效、适合二进制需要额外解析包头

我推荐长度前缀法。具体做法是:发送方先把数据的长度用固定字节数编码到消息头部,比如用 4 个字节(struct.pack('!I', len(data))),然后发送头部加正文。接收方先读 4 字节,解析出长度,再按这个长度循环读取正文。

对应的封装代码如下:

import socket import struct def send_msg(sock, data: bytes): length = len(data) sock.sendall(struct.pack('!I', length) + data) def recv_exactly(sock, n: int) -> bytes: parts = [] remaining = n while remaining > 0: chunk = sock.recv(remaining) if not chunk: raise ConnectionError('连接已断开') parts.append(chunk) remaining -= len(chunk) return b''.join(parts) def recv_msg(sock): raw_len = recv_exactly(sock, 4) length = struct.unpack('!I', raw_len)[0] return recv_exactly(sock, length)

这里的!I表示网络字节序(大端)的无符号 4 字节整数。用网络字节序的好处是,如果以后换语言实现客户端或服务端,还能互相解析。recv_exactly的循环逻辑很关键:它一直读,直到读满 n 字节才返回;万一连接中途断开,立即抛异常,而不是返回一个残缺的消息。

我之前在实际项目里用过纯文本分隔符方案,后来发现业务数据里一旦出现分隔符,就要做转义和反转义,特别繁琐。换成长度前缀后,解析逻辑变得干净多了。

6. 多客户端并发:threading 能跑,为什么还要用 selectors

6.1 单线程 accept 循环的最大问题:一个客户端拖死一片

回到第 3 节的服务端代码,它的问题很明显:外层 while 每次 accept 一个连接,然后进入内层 while 和这个客户端反复收发。如果这个客户端一直不发数据,服务端就阻塞在 recv 上,后面的客户端排队等着 accept,完全没法服务新连接。

这在单连接调试时没问题,一旦有几台设备同时想连上来,就抓瞎了。最直接的解决方法是给每个连接开一个线程:

import socket import threading def handle_conn(conn, addr): print(f'新连接来自 {addr}') try: while True: data = conn.recv(1024) if not data: break conn.sendall(b'ack: ' + data) except ConnectionError: pass finally: conn.close() print(f'{addr} 已断开') server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(('0.0.0.0', 8082)) server.listen(5) while True: conn, addr = server.accept() t = threading.Thread(target=handle_conn, args=(conn, addr)) t.start()

这种方式在连接数几十个的时候完全够用,代码也好理解。Python 的 GIL 虽然限制同一时刻只能跑一个线程的 Python 代码,但线程在 recv 阻塞时会释放 GIL,所以网络等待期间其他线程可以继续处理,实际并发效果并不差。

6.2 selectors 模块:单线程搞定所有连接的事件驱动写法

线程方案有个隐忧:每个连接一个线程,连接数一多,线程切换开销和个人内存占用都会上来,而且线程间如果共享数据,还要考虑加锁。所以当连接数可能达到上千,更常见的做法是事件驱动。

Python 标准库提供的 selectors 模块,可以帮我们监听一批 socket 上的可读、可写事件。事件来了,由它分发到对应回调函数,整个过程只有一个线程在跑。

来看一个简单的 selectors 版回显服务端:

import selectors import socket sel = selectors.DefaultSelector() def accept_conn(server): conn, addr = server.accept() print(f'新连接来自 {addr}') conn.setblocking(False) sel.register(conn, selectors.EVENT_READ, handle_conn) def handle_conn(conn): data = conn.recv(1024) if data: conn.sendall(b'echo: ' + data) else: print(f'{conn.getpeername()} 已断开') 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(('0.0.0.0', 8083)) server.listen(128) server.setblocking(False) sel.register(server, selectors.EVENT_READ, accept_conn) while True: events = sel.select(timeout=None) for key, _ in events: callback = key.data callback(key.fileobj)

这段代码的核心思想:sel.register告诉事件循环“你帮我看住这个 socket,什么情况下在什么事件”,callback 函数负责处理对应事件。server上出现可读事件,说明有新连接;某个conn上出现可读事件,说明客户端发了数据或关闭了连接。事件循环每轮把就绪事件分发给回调,然后继续等待。

需要特别强调的是,conn.setblocking(False)这行不能省。因为事件循环依赖于非阻塞 socket,只有非阻塞模式下,recv 才能在没有数据时立刻返回异常或空字节,让出主循环,而不是卡死整个进程。

6.3 两个方案怎么选,我的建议

从我自己写代码的经验来说:如果你只是做一个小工具、内部服务,总连接数在几十以内,用 threading 最省事,代码可读性也更好。如果你想写一个能扛住大量连接的网关或者代理,或者想学更通用的服务端模型,selectors 事件驱动是绕不开的。

还有一点不要忽略:threading和selectors并非互斥。你完全可以在 selectors 事件循环里收到新连接后,把连接丢给线程池去处理耗时业务,再用异步回调把结果写回。这种“事件循环管理网络、线程池处理业务”的模型,在真实的 Python 服务里非常常见。

7. 我的排错清单:端口占用、空字节与连接重置

7.1 端口被占用时,先看进程而不是急着换端口

开发时遇到Address already in use是很常见的事。我的处理习惯是先运行ss -lntp或者lsof -i :端口号查看是谁占用了端口。如果确实是自己上一个程序没退出,可以在代码里加上SO_REUSEADDR;但如果你不想改代码,也可以直接找到进程并结束掉它。

这里有个经验:在 Linux 上,一个程序如果在 TCP 连接关闭后立即重启,端口可能还处于 TIME_WAIT 状态,过一会儿才能重新 bind。开发环境设置SO_REUSEADDR就能规避这个问题。生产环境呢,这个选项要谨慎使用,因为多实例场景下它可能掩盖“多个进程抢占同一个端口”的错误。

7.2 recv 返回空字节串,第一反应不应该是“数据没收到”

第一次在服务端看到recv返回b''时,我以为是网络卡了,等了好久它还是空,查资料才明白这就是“连接已关闭”的信号。客户端调用close()之后,服务端能感知到这个事件,recv 返回空字节串,表示对方优雅地关闭了连接。

还有一种情况很特殊:如果对方进程直接崩溃,不关 socket,服务端 recv 可能会抛ConnectionResetError,错误信息是[Errno 104] Connection reset by peer。这叫连接重置,和正常关闭不太一样。它意味着曾经有一次连接,但对方的协议栈没有好好发 FIN 包就关了。这不算 bug,但对程序来说,必须把这个异常也考虑进去,在 try-except 里兜住。

7.3 客户端断网重连,socket 对象不能复用

另一个很容易踩的坑是:客户端断网之后,拿着原来的 socket 对象再次 sendall 或 recv,结果抛异常。TCP 的连接状态已经破坏了,原来的 socket 对象无法再回到“已连接”的状态。正确做法是重新创建一个新的 socket 对象,再 connect 一次。

如果是写一个需要自动重连的服务,我通常会把“创建 socket + connect”这个过程封装成一个函数,每次重连都调用它。重试时,先关闭旧 socket,再用新对象连接;重试间隔加上指数退避,避免服务端刚恢复就被一堆客户端挤爆。

最后再分享一个我自己一直保留的小习惯:在调试 Socket 程序时,所有消息都打repr格式的日志。遇到不可见字符、中文字节乱码,一眼就能看出来是编码问题还是协议问题。比如打印print(f'收到: {data!r}'),替代直接print(data.decode()),能省下很多排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询