前面已经把一条 NetPacket 从对象、序列化一直讲到了网络发送,但真正到了 TCP 这一层,问题才刚刚开始。
TCP 没有"消息"的概念,它只是一条连续的字节流。你发送了 3 条协议,对方一次 Receive() 可能收到半条,也可能一次把 3 条全收回来。
MyFramework 的 NetConnectTCPBit 就是在这一层处理:半包、粘包、包长、CRC、序列号、加密、多线程以及主线程分发。
项目地址:
一、TCP 最大的问题:它根本不知道哪里是一条消息
假设连续发送:
Packet A
Packet B
Packet C
TCP 接收端完全可能变成:
第一次 Receive:
[A 的一半]
第二次 Receive:
[A 剩余部分][B][C 的一部分]
第三次 Receive:
[C 剩余部分]
所以这种代码肯定不行:
int size = mSocket.Receive(mRecvBuff);
// 直接把 mRecvBuff 当成一条协议解析
MyFramework 收到数据后第一步不是解析协议,而是先追加到:
protected StreamBuffer mInputBuffer;
核心流程:
int nRecv = mSocket.Receive(mRecvBuff);
using (new ThreadLockScope(mInputBufferLock))
{
mInputBuffer.addData(mRecvBuff, nRecv);
}
于是 Socket 每次吐出来多少数据已经不重要了。
所有数据都会先变成一条连续的:
mInputBuffer
[已经收到的数据....................]
然后再从这个缓冲区中一条一条拆协议。
二、半包怎么处理:数据不够就什么都不做
真正拆包的是:
preParsePacket(...)
它首先尝试读取:
PacketSize
PacketSizeCRC
PacketType
Sequence
HasSign
FieldFlag
PacketBody
CRC
任何一步发现数据不够,都返回:
PARSE_RESULT.NOT_ENOUGH
例如:
if (!reader.read(out uint tempPacketSize))
{
return PARSE_RESULT.NOT_ENOUGH;
}
或者包体还没收完整:
if (!reader.readBuffer(outPacket, packetSize))
{
return PARSE_RESULT.NOT_ENOUGH;
}
此时不会删除 mInputBuffer 中任何东西。
直接等待下一次:
Receive()
↓
继续追加数据
↓
再次尝试解析
所以所谓"半包处理"本质上非常简单:
数据不完整,就把它留在缓冲区里。
三、粘包怎么处理:解析成功以后继续往后拆
如果一条消息解析成功:
mReceiveBuffer.add(new(
packetData,
fieldFlag,
packetSize,
sequence,
packetType,
hasSign));
随后会从输入缓冲区删除已经消费的数据:
mInputBuffer.removeData(
0,
bitCountToByteCount(bitIndex));
但是代码不会结束,而是继续:
while (true)
{
preParsePacket(...);
...
}
所以如果一次 Receive() 收到了:
[A][B][C]
最终流程就是:
解析 A
↓
删除 A
↓
解析 B
↓
删除 B
↓
解析 C
↓
删除 C
TCP 的粘包问题到这里就解决了。
四、包长不是直接相信,而是先做一次 CRC
想拆包,必须知道:
这条消息到底有多长?
所以协议最前面首先写:
writer.write((uint)realPacketSize);
但这里有一个危险。
如果 packetSize 自己因为数据错误被解析成:
20 MB
后面的所有拆包逻辑都会出问题。
所以紧跟着又写了:
writer.write(
generateCRC16(realPacketSize));
接收时先验证:
if (generateCRC16(packetSize) != packetSizeCRC)
{
return PARSE_RESULT.ERROR;
}
也就是:
PacketSize
↓
PacketSize CRC
↓
确认包长可信
↓
才继续读取后面的包
这是第一层校验。
五、完整消息还有第二层 CRC
除了包长 CRC,消息最后还有一次完整 CRC:
writer.write(
generateCRC16(
writer.getBuffer(),
writer.getByteCount()));
接收端先计算当前收到的数据:
ushort curCrc = generateCRC16(
reader.getBuffer(),
reader.getReadByteCount());
然后再读取发送端附带的 CRC:
reader.read(out ushort readCrc);
最终比较:
if (curCrc != readCrc)
{
return PARSE_RESULT.ERROR;
}
所以这里其实是两级检查:
第一层
PacketSize CRC
防止错误包长破坏拆包
第二层
Packet CRC
验证整个消息数据
六、包体先加密,再参与完整 CRC
发送时顺序也很有意思。
业务消息首先完成 Bit 序列化:
netPacket.write(
mBitWriter,
netPacket.hasSign(),
out ulong fieldFlag);
然后先加密包体:
mEncryptPacket?.Invoke(
packetBodyData,
0,
realPacketSize,
key);
之后才组装完整消息并计算 CRC。
所以实际顺序是:
业务数据
↓
Bit 序列化
↓
加密 PacketBody
↓
写入协议头
↓
写入加密后的 PacketBody
↓
计算 CRC
接收端则反过来:
接收完整数据
↓
验证 CRC
↓
提取 PacketBody
↓
解密
↓
SerializerBitRead
↓
恢复 NetPacket
这样完整 CRC 校验的是网络上真正传输的数据。
七、Sequence 不负责 TCP 可靠性,而是额外检测消息连续性
每发送一个协议:
++mSendSequenceNumber;
并写入:
writer.write(mSendSequenceNumber);
接收时:
if (sequence != mLastReceiveSequenceNumber + 1 &&
mLastReceiveSequenceNumber != 0xFFFFFFFF)
{
// 当前不直接判定消息非法
}
mLastReceiveSequenceNumber = sequence;
TCP 本身已经提供可靠、有序传输,所以这里的 Sequence 不是重新实现 TCP。
它更像是一层应用协议上的检测信息:
上一条:100
当前:101 √
上一条:100
当前:105 ?
值得注意的是,目前源码即使发现不连续,也不会直接丢弃消息,相关错误返回被注释掉了。
这避免了一些特殊情况下因为额外检测导致误判。
八、发送不是来一条就 Socket.Send 一次
如果游戏一帧产生:
30 条协议
最简单的方式当然是:
Socket.Send()
Socket.Send()
Socket.Send()
...
但 MyFramework 不这么做。
协议序列化完成后先进入:
DoubleBuffer<PacketSendInfo> mOutputBuffer;
发送线程再一次性取出这一批消息:
using var a =
new DoubleBufferReader<PacketSendInfo>(
mOutputBuffer);
随后全部拼入:
mTotalBuffer
最后统一:
sendTotalData();
也就是:
Packet A ┐
Packet B ├→ mTotalBuffer → Socket.Send
Packet C ┘
如果总缓冲区满了,就先发送当前数据,再继续拼。
这样可以减少大量零散的 Socket.Send() 调用。
九、接收线程绝对不直接执行游戏逻辑
收到完整协议以后,网络线程也不会马上:
packet.execute();
而是先进入:
DoubleBuffer<PacketReceiveInfo> mReceiveBuffer;
网络线程负责:
Receive
拆包
CRC
提取包体
↓
mReceiveBuffer
主线程 update() 再获取:
using var a =
new DoubleBufferReader<PacketReceiveInfo>(
mReceiveBuffer);
然后:
NetPacket packet = parsePacket(...);
packet.execute();
mNetPacketFactory.destroyPacket(packet);
完整结构变成:
接收线程
Socket.Receive
↓
StreamBuffer
↓
拆包 / CRC
↓
DoubleBuffer
================
主线程
↓
解密 / 反序列化
↓
NetPacket.execute()
↓
回收
这样 UI、角色、场景、背包等游戏对象仍然只会在主线程修改。
十、为什么这里特别适合 DoubleBuffer
如果只用普通线程安全队列:
网络线程写
主线程读
每条消息都可能需要同步。
而双缓冲是:
Buffer A:网络线程持续写
Buffer B:主线程稳定遍历
下一次读取:
A/B 交换
主线程拿到一个列表以后,这批数据就基本不会再被网络线程碰。
所以它非常适合这种:
一个或多个线程持续生产,一个固定线程批量消费的场景。
十一、整条 TCP 收发链路
把所有东西串起来:
发送
NetPacket
↓
Bit序列化
↓
加密
↓
PacketSize / CRC / Type / Sequence / FieldFlag
↓
发送DoubleBuffer
↓
批量合并
↓
Socket.Send
====================
接收
Socket.Receive
↓
StreamBuffer累积
↓
处理半包 / 粘包
↓
PacketSize CRC
↓
完整CRC
↓
接收DoubleBuffer
↓
主线程
↓
解密
↓
Bit反序列化
↓
NetPacket.execute()
这里真正值得看的不是某一个 API,而是这些技术点如何组合:
StreamBuffer 解决 TCP 无消息边界,PacketSize 负责拆包,两级 CRC 检测数据异常,Sequence 提供额外连续性信息,DoubleBuffer 隔离网络线程与主线程,批量发送则降低零散 Socket 调用。
最终才组成了 NetConnectTCPBit 这条完整的 TCP 通信链路。
下一篇就可以继续拆另一个很有意思的问题:TCP 已经可靠了,游戏为什么还要 UDP?MyFramework 的 UDP 网络层又是怎么处理协议、序列号和收发线程的?