Unity MyFramework 用法说明(十二):使用 DoubleBuffer 在多线程之间传递数据

上一篇介绍了SafeList,它解决的是同一线程中遍历集合时增删元素的问题。

SafeList并不是线程安全集合。当子线程不断产生数据,而主线程需要统一处理这些数据时,MyFramework 提供了另一种工具:

DoubleBuffer<T>

项目地址:

https://github.com/ZHOURUIH/MyFramework

一、DoubleBuffer 解决什么问题

网络模块是最典型的使用场景。

TCP 接收线程负责从 Socket 中读取并解析数据,真正的协议逻辑则应该回到 Unity 主线程执行:

TCP 接收线程 ↓ 读取并解析网络数据 ↓ 将消息放入缓冲区 ↓ Unity 主线程 ↓ 取出消息并执行协议

最直接的做法是使用一个加锁的List

protected List<PacketReceiveInfo> mReceiveList = new(); protected ThreadLock mReceiveLock = new(); public void addReceivePacket(PacketReceiveInfo packet) { using (new ThreadLockScope(mReceiveLock)) { mReceiveList.Add(packet); } }

主线程读取时也必须加锁:

public void updateReceivePacket() { using (new ThreadLockScope(mReceiveLock)) { foreach (PacketReceiveInfo packet in mReceiveList) { executePacket(packet); } mReceiveList.Clear(); } }

问题在于,主线程遍历和执行消息期间,接收线程无法继续写入。

如果协议数量较多,或者消息处理逻辑较复杂,锁会被长时间占用。

DoubleBuffer的目的就是:

只在交换缓冲区时短暂加锁,读取和遍历过程中允许其他线程继续写入。


二、DoubleBuffer 的工作方式

DoubleBuffer内部维护两个列表:

列表 A 列表 B

任意时刻,一个负责写入,另一个负责读取:

写线程 → 列表 A 主线程 → 列表 B

主线程准备读取时,框架会交换两个列表:

交换前: 写列表:A 读列表:B 交换后: 写列表:B 读列表:A

交换操作在锁中快速完成。

交换完成以后:

主线程遍历列表 A 子线程继续向列表 B 写入

两个线程不再操作同一个列表,所以主线程遍历期间不需要一直持有锁。

下一次读取时再交换回来:

写列表:A 读列表:B

这就是双缓冲的核心。


三、创建 DoubleBuffer

以网络接收消息为例:

protected DoubleBuffer<PacketReceiveInfo> mReceiveBuffer = new();

PacketReceiveInfo用来保存一次网络消息的基本信息:

public struct PacketReceiveInfo { public byte[] mPacketData; public ulong mFieldFlag; public int mPacketSize; public uint mSequence; public ushort mType; public bool mHasSign; public PacketReceiveInfo( byte[] data, ulong fieldFlag, int packetSize, uint sequence, ushort type, bool hasSign) { mPacketData = data; mFieldFlag = fieldFlag; mPacketSize = packetSize; mSequence = sequence; mType = type; mHasSign = hasSign; } }

这里使用结构体保存消息的中转信息,避免再创建一个额外的普通对象。


四、在子线程中写入数据

接收线程解析出一个完整的数据包后,直接调用:

mReceiveBuffer.add(new PacketReceiveInfo( packetData, fieldFlag, packetSize, sequence, packetType, hasSign));

add()可以从多个线程调用。

它会短暂锁住当前写列表,把数据加入以后立即释放锁:

线程获得锁 ↓ 向当前写列表添加数据 ↓ 释放锁

写线程不需要关心当前哪个列表是读列表,也不需要判断主线程是否正在遍历。


五、在主线程中读取数据

主线程使用DoubleBufferReader

public void update(float elapsedTime) { using var reader = new DoubleBufferReader<PacketReceiveInfo>( mReceiveBuffer); foreach (PacketReceiveInfo info in reader.mReadList) { executePacket(info); } }

创建DoubleBufferReader时,会调用:

mReceiveBuffer.get();

get()会交换读写列表,并返回刚刚停止写入的列表。

离开作用域后,DoubleBufferReader.Dispose()会自动:

清空本次读取的列表 结束读取状态

因此不需要手动调用:

reader.mReadList.Clear(); mReceiveBuffer.endGet();

六、完整的简化示例

下面是一个简化的网络消息中转类:

public class NetworkReceiveQueue : FrameSystem { protected DoubleBuffer<PacketReceiveInfo> mReceiveBuffer = new(); // 可以从网络接收线程调用 public void pushPacket(PacketReceiveInfo packet) { mReceiveBuffer.add(packet); } // 由主线程统一执行 public override void update(float elapsedTime) { base.update(elapsedTime); using var reader = new DoubleBufferReader<PacketReceiveInfo>( mReceiveBuffer); foreach (PacketReceiveInfo packet in reader.mReadList) { executePacket(packet); } } public override void destroy() { mReceiveBuffer.destroy(); base.destroy(); } protected void executePacket(PacketReceiveInfo packet) { // 在主线程解析并执行协议 } }

这样网络线程只负责生产数据:

mNetworkReceiveQueue.pushPacket(packet);

Unity 主线程只负责消费数据:

读取这一批消息 执行这一批消息 清空读取列表 等待下一帧

两边的职责非常明确。


七、为什么不直接交换一个普通 List

也可以自己维护两个列表:

protected List<PacketReceiveInfo> mListA = new(); protected List<PacketReceiveInfo> mListB = new();

但还需要处理:

当前写入的是哪一个列表 当前读取的是哪一个列表 什么时候允许交换 交换过程如何加锁 读取期间能否再次交换 是否在同一个线程读取 读取完成后如何清空 销毁时如何处理

DoubleBuffer把这些规则统一封装起来,业务代码只需要:

mReceiveBuffer.add(packet);

以及:

using var reader = new DoubleBufferReader<PacketReceiveInfo>( mReceiveBuffer);

八、手动读取方式

不使用DoubleBufferReader时,也可以手动操作:

List<PacketReceiveInfo> readList = mReceiveBuffer.get(); foreach (PacketReceiveInfo packet in readList) { executePacket(packet); } readList.Clear(); mReceiveBuffer.endGet();

但这种写法存在一个问题:

List<PacketReceiveInfo> readList = mReceiveBuffer.get(); if (readList.Count == 0) { return; }

提前返回后,endGet()没有执行,缓冲区会一直处于读取状态。

所以下一次调用get()时,框架会提示:

读列表正在使用中,不能再次获取读列表

因此更推荐使用:

using var reader = new DoubleBufferReader<PacketReceiveInfo>( mReceiveBuffer);

即使中途return或出现异常,也能正常结束读取。


九、同一个 DoubleBuffer 只能有一个读取线程

DoubleBuffer支持:

多个线程写入 一个固定线程读取

不支持:

多个线程同时读取 同一个线程重复获取读列表 读取过程中再次调用 get()

第一次调用get()时,框架会记录读取线程的 ID。

以后必须继续由这个线程读取:

网络线程1 ─┐ 网络线程2 ─┼→ add() 网络线程3 ─┘ Unity主线程 → get()

不能这一次在主线程读取,下一次又切换到其他工作线程读取。

同一个DoubleBuffer也不能嵌套创建两个 Reader:

using var readerA = new DoubleBufferReader<PacketReceiveInfo>( mReceiveBuffer); // 错误:第一个 Reader 还没有结束 using var readerB = new DoubleBufferReader<PacketReceiveInfo>( mReceiveBuffer);

读取列表只能在当前 Reader 的作用域中使用。


十、数据本身的生命周期仍然要管理

DoubleBuffer只保证列表读写安全,不会自动管理元素内部的数据。

例如网络消息中包含一个线程对象池分配的数组:

PacketReceiveInfo info = new( packetData, fieldFlag, packetSize, sequence, packetType, hasSign); mReceiveBuffer.add(info);

将数据加入缓冲区以后,写线程不能立即回收:

mReceiveBuffer.add(info); // 错误:主线程还没有处理这个数组 UN_ARRAY_BYTE_THREAD(info.mPacketData);

应该等主线程处理完成后再回收:

foreach (PacketReceiveInfo info in reader.mReadList) { NetPacket packet = parsePacket( info.mType, info.mPacketData, info.mPacketSize, info.mSequence, info.mFieldFlag, info.mHasSign); UN_ARRAY_BYTE_THREAD(info.mPacketData); packet?.execute(); }

向缓冲区写入数据,实际上也代表把这份数据的处理权交给了读取线程。


十一、限制写缓冲区大小

可以设置写列表的最大数量:

mReceiveBuffer.setWriteListLimit(1024);

当当前写列表已经达到上限时,后续数据不会继续写入。

这可以避免生产速度远高于消费速度时,缓冲区无限增长。

但需要注意:

add() 没有返回是否写入成功 超过限制的数据会被忽略

所以不能丢失的数据,不应该随意设置一个过小的上限。

如果业务必须限制数量,还需要结合自己的逻辑记录溢出次数或处理异常状态。

默认值为0

0 表示没有数量上限

十二、什么时候调用 clear 和 destroy

清空缓冲区:

mReceiveBuffer.clear();

它会同时清空读列表和写列表。

但不能在 Reader 仍然存在时调用:

using var reader = new DoubleBufferReader<PacketReceiveInfo>( mReceiveBuffer); // 错误:当前仍在使用读列表 mReceiveBuffer.clear();

销毁时调用:

mReceiveBuffer.destroy();

销毁前应该先停止所有可能继续调用add()的工作线程:

停止生产线程 ↓ 确认不再写入 ↓ 处理或释放缓冲区中的数据 ↓ 销毁 DoubleBuffer

不能在子线程仍然运行时直接销毁缓冲区。


十三、DoubleBuffer 和 SafeList 的区别

两者解决的问题不同。

SafeList

用于同一个线程内:

正在遍历集合 ↓ 调用链中可能增加或删除元素 ↓ 当前遍历保持稳定

典型场景:

子弹更新 怪物更新 状态更新 可见格子更新

DoubleBuffer

用于多个线程之间:

一个或多个线程不断生产数据 ↓ 一个固定线程批量读取数据

典型场景:

网络线程 → 主线程 文件读取线程 → 主线程 日志线程 → 写入线程 后台计算线程 → 结果处理线程

可以简单理解为:

SafeList 解决遍历过程中修改集合 DoubleBuffer 解决生产线程与消费线程之间的数据交换

十四、总结

MyFramework 中DoubleBuffer的基本用法是:

protected DoubleBuffer<MyData> mBuffer = new();

生产线程写入:

mBuffer.add(data);

消费线程读取:

using var reader = new DoubleBufferReader<MyData>(mBuffer); foreach (MyData data in reader.mReadList) { process(data); }

它通过两个列表交替承担读写任务:

写线程持续写入一个列表 ↓ 消费线程交换两个列表 ↓ 消费线程无锁遍历旧写列表 ↓ 生产线程继续写入新列表

DoubleBuffer并不是完全没有锁,而是把锁限制在数据写入和缓冲区交换的短暂过程里,避免消费线程在处理整批数据时长期占用同一把锁。

对于“多个线程生产,一个线程批量消费”的场景,这种结构既能保证数据交换安全,也能减少读写双方互相等待的时间。