4. TCP循环收发:客户端循环发送、服务端循环接收、解决粘包问题

好,咱们继续往下走。前面我们写了一个“一发一收”的简单例子,客户端发一句,服务端收一句,然后连接就断了。这在实际项目中根本不够用。你想想看,真正的网络通信,比如聊天软件、游戏服务器,哪个不是持续收发数据的?

所以这一章,我们要解决两个核心问题:如何让客户端和服务端循环收发数据,以及如何解决TCP粘包这个老生常谈的坑。

本章核心目标:

  • 实现客户端循环发送多条消息
  • 实现服务端循环接收并回显
  • 理解粘包问题的本质
  • 掌握两种主流解决方案
TCP循环收发与粘包处理流程 客户端 服务端 1. 创建socket 2. 连接服务器 3. 循环发送数据 4. 循环接收响应 1. 创建socket 2. bind + listen 3. accept等待连接 4. 循环接收+处理 发送数据 返回响应 ⚠ 粘包问题 多条数据粘在一起 解决方案: 固定长度 / 特殊分隔符 / 头部长度

4.1 从“一次”到“循环”

先看一个最简单的循环收发模型。客户端用 while True 不断发送用户输入,服务端也用 while True 不断接收。听起来很简单对吧?但这里有个细节——什么时候退出循环?

我个人习惯的做法是:客户端输入 exit 或 quit 时主动关闭连接,服务端收到空数据或特定指令时退出。这样双方都能优雅地结束。

客户端代码:循环发送

import socket

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('127.0.0.1', 8888))

while True:
    msg = input("请输入要发送的消息(输入exit退出):")
    if msg == 'exit':
        break
    client.send(msg.encode('utf-8'))
    # 接收服务端回显
    data = client.recv(1024)
    print(f"收到服务端回复:{data.decode('utf-8')}")

client.close()

服务端代码:循环接收

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8888))
server.listen(5)
print("服务端已启动,等待连接...")

conn, addr = server.accept()
print(f"客户端 {addr} 已连接")

while True:
    data = conn.recv(1024)
    if not data:
        print("客户端已断开连接")
        break
    print(f"收到:{data.decode('utf-8')}")
    # 原样回显
    conn.send(data)

conn.close()
server.close()

💡 小提示:服务端 recv() 返回空字节 b'' 时,说明客户端已经调用了 close()。这是TCP协议规定的标准行为,用来检测连接断开。

4.2 粘包问题:你一定会遇到的坑

好,上面的代码看起来没问题,但实际跑起来你会发现一个诡异的现象。我曾经在做一个即时通讯项目时就踩过这个坑——客户端连续发送两条消息,服务端却只收到一条,两条消息粘在一起了。

为什么会这样?

TCP是流式协议,它不关心你发送了几次数据,只关心字节流的顺序。如果发送端连续发送多个小数据包,TCP可能会把它们合并成一个大的数据包发送出去。接收端一次 recv() 可能读到多条消息的内容。

说白了,TCP不保证消息边界。这就是粘包问题的本质。

⚠️ 注意:粘包不是TCP的bug,而是它的特性。UDP就没有这个问题,因为UDP是面向消息的协议,每个sendto对应一个recvfrom。

4.3 三种主流解决方案

解决粘包问题,说白了就是让接收方知道每条消息从哪里开始、到哪里结束。我总结下来,业界常用的方案有三种:

方案 原理 优点 缺点
固定长度 每条消息固定N字节,不足补空格 实现简单 浪费带宽,不适合变长数据
特殊分隔符 消息末尾加特殊标记(如\n) 灵活,适合文本协议 消息内容不能包含分隔符
头部长度 先发4字节长度,再发消息体 通用性强,适合二进制协议 实现稍复杂

4.4 实战:头部长度方案(推荐)

我个人最推荐的是头部长度方案。为什么呢?因为它在效率和通用性之间取得了最好的平衡。我在写游戏服务器时用的就是这种方案。

思路很简单:

  1. 发送方:先发送4字节的消息长度(网络字节序),再发送消息体
  2. 接收方:先读取4字节得到长度,再读取指定长度的数据

客户端代码(带粘包处理)

import socket
import struct

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('127.0.0.1', 8888))

while True:
    msg = input("请输入要发送的消息(输入exit退出):")
    if msg == 'exit':
        break
    
    # 将消息编码为字节
    msg_bytes = msg.encode('utf-8')
    # 打包长度:使用struct将长度转为4字节大端序
    header = struct.pack('!I', len(msg_bytes))
    # 先发头部,再发消息体
    client.send(header + msg_bytes)
    
    # 接收响应:先读4字节头部
    resp_header = client.recv(4)
    if not resp_header:
        break
    resp_len = struct.unpack('!I', resp_header)[0]
    # 根据长度读取完整消息体
    resp_data = b''
    while len(resp_data) < resp_len:
        chunk = client.recv(resp_len - len(resp_data))
        if not chunk:
            break
        resp_data += chunk
    print(f"服务端回复:{resp_data.decode('utf-8')}")

client.close()

服务端代码(带粘包处理)

import socket
import struct

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8888))
server.listen(5)
print("服务端已启动...")

conn, addr = server.accept()
print(f"客户端 {addr} 已连接")

while True:
    # 先读4字节头部
    header = conn.recv(4)
    if not header:
        break
    msg_len = struct.unpack('!I', header)[0]
    
    # 根据长度读取完整消息体
    data = b''
    while len(data) < msg_len:
        chunk = conn.recv(msg_len - len(data))
        if not chunk:
            break
        data += chunk
    
    print(f"收到完整消息:{data.decode('utf-8')}")
    
    # 回显:同样使用头部长度方案
    response = f"已收到:{data.decode('utf-8')}".encode('utf-8')
    resp_header = struct.pack('!I', len(response))
    conn.send(resp_header + response)

conn.close()
server.close()

🔑 关键点解析:

  • struct.pack('!I', length):将整数打包成4字节网络字节序(大端序)
  • struct.unpack('!I', data):从4字节中解包出整数
  • 接收消息体时用 while 循环:因为 recv() 不一定一次返回所有数据
  • 网络字节序保证了不同平台之间的兼容性

4.5 避坑指南

我曾经在项目中遇到过几个典型问题,分享出来给大家提个醒:

  • recv() 不保证一次收完:即使你知道消息长度是100字节,一次 recv(100) 也可能只返回50字节。所以必须用循环收完。
  • struct 的格式字符串要一致:发送方用 '!I',接收方也必须用 '!I'。大小端不匹配会导致长度解析错误。
  • 空数据判断:recv() 返回 b'' 表示连接断开,一定要及时处理,否则会陷入死循环。
  • 消息长度上限:4字节无符号整数最大是4GB,但实际中建议限制单条消息大小,防止恶意攻击。

💡 我的经验:在实际项目中,我通常会在头部长度后面再加一个1字节的消息类型字段。这样服务端收到后可以先判断类型,再决定怎么处理。比如0x01表示文本消息,0x02表示心跳包,0x03表示文件数据。这样协议扩展性会好很多。

好了,这一章的内容就到这里。循环收发加上粘包处理,你已经掌握了TCP编程中最核心的实战技能。代码虽然不长,但每个细节都值得反复推敲。建议你亲手敲一遍代码,把粘包问题复现出来,再用头部长度方案解决它。只有亲手踩过坑,印象才深刻。

可可精品资料库