3. TCP粘包与拆包问题剖析
这一章我们真正开始动手写代码了。TCP Client 端,说白了就是主动发起连接的那一方。你想想看,生活中你打电话,拨号的那个人就是 Client,接电话的就是 Server。今天我们就来把这个「拨号、通话、挂断」的流程,用 Python 代码完整走一遍。
我个人习惯把 TCP Client 的生命周期分成四个阶段:创建 → 连接 → 通信 → 关闭。每个阶段都有坑,我当年刚入行时几乎全踩过一遍。嗯,咱们一个一个来拆解。
3.1 创建 Socket:你的网络通信句柄
创建 Socket 其实就一行代码,但这一行决定了你用什么协议族、什么传输类型。我见过不少新手直接用默认参数,结果在 IPv6 环境下跑不通,排查了半天。
import socket
# 创建一个 IPv4 的 TCP Socket
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
这里两个参数的含义:
| 参数 | 可选值 | 说明 |
|---|---|---|
| AF_INET | AF_INET6, AF_UNIX | IPv4 地址族,最常用 |
| SOCK_STREAM | SOCK_DGRAM, SOCK_RAW | 面向连接的 TCP 流式套接字 |
SO_REUSEADDR 选项。虽然 Client 端不像 Server 那样必须设,但我在项目中遇到过 Client 快速重连时端口被占用的诡异问题,加上这个选项能省很多麻烦。
client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
3.2 连接服务器:三次握手的发起者
创建完 Socket,下一步就是连接服务器。这一步背后是 TCP 的三次握手,但对应用层来说,你只需要提供一个 IP 和端口号。
server_address = ('192.168.1.100', 8888)
client_socket.connect(server_address)
connect() 是一个阻塞调用。什么意思?就是你的程序会停在这里,直到三次握手完成或者超时失败。我曾经在生产环境遇到过一个问题:服务器负载高,握手耗时超过默认超时时间,Client 直接抛异常退出了。
client_socket.settimeout(5.0) # 5 秒超时
try:
client_socket.connect(server_address)
except socket.timeout:
print("连接超时,请检查网络或服务器状态")
为什么是 5 秒?我个人经验是:局域网内 1-2 秒足够,公网环境 3-5 秒比较合理。太短容易误判,太长影响用户体验。
3.3 收发数据:核心通信逻辑
连接建立后,就到了真正的数据交换阶段。TCP 是流式协议,没有消息边界。这一点非常重要,我当年在这个坑里摔得鼻青脸肿。
3.3.1 发送数据
message = "Hello, Server!"
client_socket.send(message.encode('utf-8'))
send() 方法返回实际发送的字节数。你可能会想:「我发了 100 字节,它应该全发出去吧?」不一定。TCP 协议栈可能只发送了部分数据,剩下的需要你手动重发。这就是所谓的「部分发送」问题。
def send_all(sock, data):
total_sent = 0
while total_sent < len(data):
sent = sock.send(data[total_sent:])
if sent == 0:
raise RuntimeError("Socket 连接断开")
total_sent += sent
sendall() 方法,它在内部帮我们处理了循环发送的逻辑。但要注意,sendall() 在出错时也会抛异常,需要捕获处理。
3.3.2 接收数据
接收比发送更复杂。因为 TCP 是流,你调用 recv() 时,可能收到半个消息、一个完整消息、或者多个消息粘在一起。
response = client_socket.recv(1024)
print(response.decode('utf-8'))
这里的 1024 是缓冲区大小。选多大合适?
- 太小:频繁的系统调用,性能差
- 太大:浪费内存,而且不一定能填满
- 我的建议:一般消息用 4096,大文件传输用 65536(64KB)
但问题来了:你怎么知道消息什么时候收完了?这就是「粘包」问题的根源。常用的解决方案有两种:
| 方案 | 实现方式 | 优缺点 |
|---|---|---|
| 固定长度 | 每条消息固定 N 字节,不足补位 | 简单但浪费带宽 |
| 长度前缀 | 先发 4 字节表示消息长度,再发内容 | 高效,最常用 |
| 特殊分隔符 | 用 \n 或 \r\n 标记消息结束 | 适合文本协议,但内容中不能出现分隔符 |
我个人最推荐「长度前缀」方案。后面章节我们会专门实现一个完整的协议层,这里先给你看个简单示例:
def recv_exact(sock, n):
"""精确接收 n 个字节"""
data = b''
while len(data) < n:
chunk = sock.recv(n - len(data))
if not chunk:
raise ConnectionError("连接已关闭")
data += chunk
return data
# 接收消息:先读 4 字节长度,再读内容
header = recv_exact(client_socket, 4)
msg_len = int.from_bytes(header, 'big')
message = recv_exact(client_socket, msg_len)
3.4 优雅关闭:别让连接断得不明不白
很多人觉得关闭连接就是调一下 close(),其实没那么简单。TCP 关闭有四次挥手,如果处理不当,会导致数据丢失或者端口长时间处于 TIME_WAIT 状态。
什么叫「优雅关闭」?就是告诉对方「我要断开了,你还有数据要发吗?」而不是直接拔网线。
# 优雅关闭的推荐步骤
client_socket.shutdown(socket.SHUT_WR) # 告诉服务器:我不再发了
# 此时还可以接收服务器剩余的数据
remaining = client_socket.recv(4096)
client_socket.close() # 彻底关闭
shutdown() 的参数有三种:
- SHUT_RD:关闭读通道,不能再接收数据
- SHUT_WR:关闭写通道,不能再发送数据
- SHUT_RDWR:同时关闭读写,相当于 close()
还有一个容易被忽略的点:上下文管理器。Python 的 socket 对象支持 with 语句,可以自动关闭:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect(('localhost', 8888))
sock.sendall(b'Hello')
data = sock.recv(1024)
# 退出 with 块时自动 close()
但注意,with 语句退出时调的是 close(),不是 shutdown()。如果你的场景需要先 shutdown 再收尾,还是得手动控制。
3.5 完整示例:一个可用的 TCP Client
把上面的知识点串起来,就是一个完整的 TCP Client 模板:
import socket
import sys
def tcp_client(host, port, message):
try:
# 1. 创建 Socket
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
client.settimeout(5.0)
# 2. 连接服务器
client.connect((host, port))
print(f"已连接到 {host}:{port}")
# 3. 发送数据
client.sendall(message.encode('utf-8'))
print(f"发送: {message}")
# 4. 接收响应
response = client.recv(4096)
print(f"收到: {response.decode('utf-8')}")
# 5. 优雅关闭
client.shutdown(socket.SHUT_WR)
# 可选:接收服务器最后的确认
tail = client.recv(1024)
if tail:
print(f"尾数据: {tail.decode('utf-8')}")
except socket.timeout:
print("错误: 连接超时")
except ConnectionRefusedError:
print("错误: 连接被拒绝,服务器可能未启动")
except Exception as e:
print(f"错误: {e}")
finally:
client.close()
print("连接已关闭")
if __name__ == '__main__':
tcp_client('127.0.0.1', 8888, 'Hello TCP Server!')
3.6 核心流程总览
下面这张图把 TCP Client 的完整生命周期画出来了,你可以对照着代码看,会更清晰:
你看,整个流程其实不复杂。但每个环节都有细节,尤其是关闭阶段,很多人觉得「不就是关个连接吗」,结果线上出问题才后悔。嗯,记住一句话:创建要谨慎,通信要健壮,关闭要优雅。