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

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

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

bash 复制代码
DoubleBuffer<T>

项目地址:

github.com/ZHOURUIH/My...

一、DoubleBuffer 解决什么问题

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

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

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

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

bash 复制代码
protected List<PacketReceiveInfo> mReceiveList = new();
protected ThreadLock mReceiveLock = new();

public void addReceivePacket(PacketReceiveInfo packet)
{
	using (new ThreadLockScope(mReceiveLock))
	{
		mReceiveList.Add(packet);
	}
}

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

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

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

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

DoubleBuffer 的目的就是:

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


二、DoubleBuffer 的工作方式

DoubleBuffer 内部维护两个列表:

bash 复制代码
列表 A
列表 B

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

bash 复制代码
写线程 → 列表 A

主线程 → 列表 B

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

bash 复制代码
交换前:

写列表:A
读列表:B

交换后:

写列表:B
读列表:A

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

交换完成以后:

bash 复制代码
主线程遍历列表 A
子线程继续向列表 B 写入

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

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

bash 复制代码
写列表:A
读列表:B

这就是双缓冲的核心。


三、创建 DoubleBuffer

以网络接收消息为例:

bash 复制代码
protected DoubleBuffer<PacketReceiveInfo> mReceiveBuffer = new();

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

bash 复制代码
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;
	}
}

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


四、在子线程中写入数据

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

bash 复制代码
mReceiveBuffer.add(new PacketReceiveInfo(
	packetData,
	fieldFlag,
	packetSize,
	sequence,
	packetType,
	hasSign));

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

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

bash 复制代码
线程获得锁
    ↓
向当前写列表添加数据
    ↓
释放锁

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


五、在主线程中读取数据

主线程使用 DoubleBufferReader

bash 复制代码
public void update(float elapsedTime)
{
	using var reader =
		new DoubleBufferReader<PacketReceiveInfo>(
			mReceiveBuffer);

	foreach (PacketReceiveInfo info in reader.mReadList)
	{
		executePacket(info);
	}
}

创建 DoubleBufferReader 时,会调用:

bash 复制代码
mReceiveBuffer.get();

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

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

bash 复制代码
清空本次读取的列表
结束读取状态

因此不需要手动调用:

bash 复制代码
reader.mReadList.Clear();
mReceiveBuffer.endGet();

六、完整的简化示例

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

bash 复制代码
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)
	{
		// 在主线程解析并执行协议
	}
}

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

bash 复制代码
mNetworkReceiveQueue.pushPacket(packet);

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

bash 复制代码
读取这一批消息
执行这一批消息
清空读取列表
等待下一帧

两边的职责非常明确。


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

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

bash 复制代码
protected List<PacketReceiveInfo> mListA = new();
protected List<PacketReceiveInfo> mListB = new();

但还需要处理:

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

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

bash 复制代码
mReceiveBuffer.add(packet);

以及:

bash 复制代码
using var reader =
	new DoubleBufferReader<PacketReceiveInfo>(
		mReceiveBuffer);

八、手动读取方式

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

bash 复制代码
List<PacketReceiveInfo> readList =
	mReceiveBuffer.get();

foreach (PacketReceiveInfo packet in readList)
{
	executePacket(packet);
}

readList.Clear();
mReceiveBuffer.endGet();

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

bash 复制代码
List<PacketReceiveInfo> readList =
	mReceiveBuffer.get();

if (readList.Count == 0)
{
	return;
}

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

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

bash 复制代码
读列表正在使用中,不能再次获取读列表

因此更推荐使用:

bash 复制代码
using var reader =
	new DoubleBufferReader<PacketReceiveInfo>(
		mReceiveBuffer);

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


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

DoubleBuffer 支持:

bash 复制代码
多个线程写入
一个固定线程读取

不支持:

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

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

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

bash 复制代码
网络线程1 ─┐
网络线程2 ─┼→ add()
网络线程3 ─┘

Unity主线程 → get()

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

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

bash 复制代码
using var readerA =
	new DoubleBufferReader<PacketReceiveInfo>(
		mReceiveBuffer);

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

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


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

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

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

bash 复制代码
PacketReceiveInfo info = new(
	packetData,
	fieldFlag,
	packetSize,
	sequence,
	packetType,
	hasSign);

mReceiveBuffer.add(info);

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

bash 复制代码
mReceiveBuffer.add(info);

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

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

bash 复制代码
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();
}

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


十一、限制写缓冲区大小

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

bash 复制代码
mReceiveBuffer.setWriteListLimit(1024);

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

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

但需要注意:

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

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

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

默认值为 0

bash 复制代码
0 表示没有数量上限

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

清空缓冲区:

bash 复制代码
mReceiveBuffer.clear();

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

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

bash 复制代码
using var reader =
	new DoubleBufferReader<PacketReceiveInfo>(
		mReceiveBuffer);

// 错误:当前仍在使用读列表
mReceiveBuffer.clear();

销毁时调用:

bash 复制代码
mReceiveBuffer.destroy();

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

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

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


十三、DoubleBuffer 和 SafeList 的区别

两者解决的问题不同。

SafeList

用于同一个线程内:

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

典型场景:

bash 复制代码
子弹更新
怪物更新
状态更新
可见格子更新

DoubleBuffer

用于多个线程之间:

bash 复制代码
一个或多个线程不断生产数据
    ↓
一个固定线程批量读取数据

典型场景:

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

可以简单理解为:

bash 复制代码
SafeList
解决遍历过程中修改集合

DoubleBuffer
解决生产线程与消费线程之间的数据交换

十四、总结

MyFramework 中 DoubleBuffer 的基本用法是:

bash 复制代码
protected DoubleBuffer<MyData> mBuffer = new();

生产线程写入:

bash 复制代码
mBuffer.add(data);

消费线程读取:

bash 复制代码
using var reader =
	new DoubleBufferReader<MyData>(mBuffer);

foreach (MyData data in reader.mReadList)
{
	process(data);
}

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

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

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

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

相关推荐
SmalBox1 天前
【节点】[PackWaterVertexData节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_1 天前
Unity MyFramework 用法说明(十一):使用 SafeList 在遍历过程中安全增删
unity3d
SmalBox2 天前
【节点】[EvaluateTipThickness节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_2 天前
Unity MyFramework 用法说明(十):使用 ClassObject 和 ClassScope 管理可复用对象
unity3d
SmalBox3 天前
【节点】[EvaluateSimulationDisplacement节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_4 天前
Unity MyFramework 用法说明(九):使用 EventSystem 处理不确定的业务响应
unity3d
SmalBox4 天前
【节点】[EvaluateSimulationCaustics节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox5 天前
【节点】[EvaluateSimulationAdditionalData节点]原理解析与实际应用
unity3d·游戏开发·图形学