ConcurrentBag:从线程本地队列到工作窃取
系列 :C# 与常用数据结构源码剖析 · 并发集合篇
固定源码边界 :
dotnet/runtime的v8.0.0tag,System.Collections.Concurrent/ConcurrentBag.cs阅读提醒:公开语义比私有字段稳定;Unity、Mono、后续 .NET 版本必须重新核验
一、Bag 的语义不是 Queue,也不是 Stack
ConcurrentBag<T> 是线程安全的无序多重集合 :可以加入重复元素,可以并发 Add/TryTake,但不承诺全局 FIFO、全局 LIFO、公平性或某个元素何时被取出。它针对"某线程放入的对象,往往又由同一线程取回"的模式优化,例如并行工作线程反复租借和归还临时缓冲。
.NET 8.0.0 实现为每个参与线程关联一个 WorkStealingQueue。所有者线程在本地端加入和取出;其他线程在本地无物可取时,遍历全局注册链,从别人的另一端窃取。由此得到一种局部 LIFO、远端近似 FIFO的实际行为:所有者倾向先拿回刚放入的项,窃取者倾向拿走更老的项。但这只是固定实现中的方向,不是 Bag 的顺序契约。并发竞争、多个本地队列的遍历和线程迁移都使全局顺序不可定义。
也不能把整个实现称为"无锁"。本地不竞争且容量充足时有无 Monitor 的快速路径;扩容、最后一项竞争、跨线程窃取、统计快照和清空等路径会使用锁、原子操作或冻结协议。准确表述应针对具体路径,而不是给类型贴一个口号。
本文的字段和流程来自 v8.0.0 源码的结构化节选与等价伪代码,为讲解省略了溢出归一化、内存序细节和异常处理。需要逐字事实时应打开固定 tag,不应把本文反推到 .NET Framework、其他 tag 或 Unity 自带类库。
二、两层结构:线程本地入口与全局注册链
顶层对象维护线程本地映射和所有工作队列的链表:
// 结构化节选:字段名对应 .NET 8.0.0,省略修饰与初始化细节。
ThreadLocal<WorkStealingQueue> _locals;
WorkStealingQueue? _workStealingQueues;
long _emptyToNonEmptyListTransitionCount;
_locals.Value 让当前线程快速找到自己的队列。第一次在该 Bag 上 Add 时,代码在同步保护下创建或复用适合当前线程的队列,并把它链接到 _workStealingQueues。链表使一个没有本地工作项的线程可以发现其他队列,也是 Count、ToArray 和 Clear 遍历全部存储的入口。
每个 WorkStealingQueue 的核心状态可抽象为:
// 结构化节选:不是可复制的完整运行时源码。
T[] _array; // 2 的幂长度环形数组
int _mask; // 以 index & mask 映射槽位
int _headIndex; // 窃取端
int _tailIndex; // 所有者端的下一写入位置
int _addTakeCount; // 本地净增减统计
int _stealCount; // 被窃取统计
Operation _currentOp; // 本地快速操作状态
bool _frozen; // 全局快照冻结标志
WorkStealingQueue? _nextQueue;
int _ownerThreadId;
逻辑有效区间从 head 延伸到 tail,物理槽位通过掩码环绕:
窃取者取旧项 所有者操作新项
head tail
| |
v v
[ old ][ ... ][ ... ][ newest ][ free ][ free ]
---- 逻辑有效区间 ---->
数组末端后回到 0,图示不代表物理上永远连续。
索引接近整数边界时,真实实现还会在受保护路径归一化索引;不能用上图推导"tail 等于数组长度就扩容"。扩容取决于逻辑占用和环形容量,并需要与窃取者协调。
注册链与 Bag 生命周期绑定。线程结束不会自动从该链中摘除其队列,因为其他线程仍可能窃取其中项目,全局操作也必须看见它。源码还会按 owner thread id 寻找可复用队列,但这不等同于线程退出便释放数组。大量短命专用线程轮流接触一个长寿命 Bag,可能留下许多已注册队列和容量;是否成为实际问题要用内存快照验证。
三、Add:本地快路为什么仍需要慢路
Add(item) 先取得当前线程的 WorkStealingQueue,再调用本地压入。常见无竞争快路大致如下:
// 等价伪代码:表达方向和分路,不是逐字源码。
void LocalPush(T item)
{
markCurrentOperationAsAdd();
if (!frozen && hasSafeSpareCapacityAndDistance())
{
array[tail & mask] = item;
publishTail(tail + 1);
updateLocalCount();
clearCurrentOperation();
return;
}
clearCurrentOperation();
lock (this)
{
normalizeIndicesIfNeeded();
growAndRebaseIfFull();
array[tail & mask] = item;
tail++;
updateCountsAndTransitions();
}
}
为什么容量明明还有空位,也可能进锁?因为 head 端可能正由窃取者推进;队列接近只剩一项时,所有者和窃取者会争夺同一个槽位。快速路径必须留出安全距离,并与冻结协议协调。_currentOp 让执行全局冻结的线程知道某个所有者是否正在一个未持锁的本地操作中。
数组满时,所有者在锁内申请更大数组,并按逻辑顺序把环形区间复制过去,再更新 head、tail 和 mask。具体初始长度、增长规则及索引归一化属于 v8.0.0 私有实现,不应成为业务假设。Add 的常见路径可很短,扩容当次仍有分配和 O(n) 复制,也可能等待锁。
源码记录"所有工作队列从空集合变为非空"的转换计数。窃取逻辑用它判断一次扫描期间是否发生了可能遗漏的新工作,从而决定是否重试。这个计数解决的是并发扫描观察窗口,不是公开 Count,也不是严格的全局版本号。
四、TryTake:所有者从 tail 端弹出
当前线程若已经拥有本地队列,TryTake 先尝试 LocalPop。所有者递减 tail,读取最近加入的项,因此局部表现像 Stack。容量富余且 head 明显落后时可走快速路径;接近最后一项时必须与从 head 窃取的线程同步,确保同一项只被一个调用者取得。
// 等价伪代码:省略内存屏障和计数修正。
bool LocalPop(out T result)
{
reservePreviousTailSlot();
if (!frozen && moreThanContendedBoundary())
return takeTailSlotAndClearIt(out result);
lock (this)
{
if (head <= reservedTail)
return takeTailSlotAndClearIt(out result);
restoreTail();
result = default!;
return false;
}
}
成功取出后,真实实现会清理相应数组槽位,避免被移除的引用继续由队列后备数组持有。调用者的 out 变量仍然引用该对象;清槽位只移除 Bag 内部的一条引用链,不代表立即回收。
本地失败后,TryTake 遍历已注册队列并尝试窃取。它不是"只在自己的队列工作",也不保证优先访问哪一个远端队列成为公共行为。TryPeek 具有相似的本地优先和远端探测结构,但不移除元素;在并发环境里,刚 Peek 到的项下一刻就可能被其他线程取走,Peek 与后续 Take 不是原子事务。
五、窃取:从 head 取旧项,而且要加锁
对某个目标 WorkStealingQueue 的窃取在 v8.0.0 中进入该队列的锁,检查 head 与 tail,然后从 head 端取走一项并推进 head。Take 型窃取会清空槽位并更新窃取计数;Peek 型探测不移除。
// 等价伪代码:v8.0.0 的窃取方向与同步思想。
bool TryStealFrom(WorkStealingQueue victim, out T item)
{
lock (victim)
{
if (victim.head >= victim.tail)
{
item = default!;
return false;
}
int index = victim.head & victim.mask;
item = victim.array[index];
victim.array[index] = default!;
victim.head++;
victim.stealCount++;
return true;
}
}
真实源码包含更精细的 volatile/Interlocked、计数与 Peek 分支,上例不能用于替换 BCL。关键事实是:不能把窃取描述为单靠 Interlocked.CompareExchange 的全无锁路径;固定版本使用 Monitor 与所有者边界竞争、扩容和冻结协调。
为什么两端方向有价值?所有者刚生成的工作往往仍在缓存中,或更可能与当前递归任务相关,LIFO 有利于局部性;窃取者从旧端拿任务,降低与所有者争夺最新槽位的概率,也可能获得较大、较早产生的工作。但 ConcurrentBag<T> 是通用容器,不负责调度任务依赖、避免饥饿或保证负载均衡。
一次扫描没有找到项,不代表世界在扫描全程都为空。其他线程可能在扫描已经越过某队列后才把空队列变成非空。实现借助转换计数检测这种变化并视情况重试,但公开 TryTake(false) 仍只表示该调用没有取得项,不能用它证明以后不会再有生产者 Add。
六、冻结协议:Count、ToArray 和 Clear 为什么昂贵
要获得跨多个本地队列的一致观察,运行时不能简单逐个读取易变 head/tail。v8.0.0 采用 FreezeBag 协议:先取得协调注册的全局锁,再按稳定顺序取得所有 WorkStealingQueue 的锁,设置 _frozen,等待正在进行的本地快速操作离开,随后执行全局计算或复制。最后清除冻结并释放各锁。
取得全局协调锁
|
锁住所有本地队列 -> 标记 frozen -> 等待 currentOp 完成
|
执行 Count / ToArray / Clear 所需的一致遍历
|
解除 frozen -> 释放全部队列锁 -> 释放全局锁
这说明以下操作不是便宜的监控属性:
Count需要冻结后汇总每个队列的危险计数;ToArray冻结后分配数组并复制当前所有项;GetEnumerator在该版本通过快照数组枚举,因此创建枚举器会付出快照成本;CopyTo同样需要一致快照/复制语义;Clear必须协调并清除跨线程队列,而不只是清当前线程 TLS。
枚举快照意味着枚举开始后新的 Add/Take 不会反映为实时游标,但枚举顺序仍未承诺。快照中引用的元素会至少存活到数组和枚举器都不可达。不要在高频状态栏每帧读取 Count,也不要通过 bag.Any() 或重复枚举实现实时背压;若业务需要精确容量、等待和完成信号,应选相应生产者---消费者抽象。
IsEmpty 有专门的探测逻辑,不应假定等价于 Count == 0 的成本或原子事务。即使读到空,下一刻也可能 Add;即使读到非空,下一刻也可能被别人取走。并发属性通常是瞬时观察,不是后续操作预约。
七、async 与线程迁移:ThreadLocal 不是异步上下文
WorkStealingQueue 绑定的是当前托管线程,不是 Task、请求或 async 方法。await 之前在 A 线程 Add,续体可能在 B 线程调用 TryTake;此时 B 先查自己的队列,再通过窃取获得 A 中的项。功能仍可能成功,却失去"同线程归还---同线程复用"的快路假设。
static async Task<byte[]> RoundTripAsync(ConcurrentBag<byte[]> pool)
{
byte[] buffer = pool.TryTake(out byte[]? rented)
? rented
: new byte[4096];
await DoIoAsync(buffer); // 续体不保证回到原线程
pool.Add(buffer); // 可能注册或使用另一个本地队列
return buffer;
}
示例还犯了所有权错误:返回 buffer 给调用方的同时又放回池,调用方和下一租用者可能并发修改同一数组。正确池接口必须规定 Rent/Return 所有权,Return 后原持有者不得继续使用。ConcurrentBag 只保证容器操作线程安全,不保证元素线程安全,也不保证租借协议正确。
Task.Run/线程池通常复用线程,可能契合本地队列;大量 new Thread 短命线程则可能扩大注册链。async 高并发中的请求数与实际线程数也不是同一个量,不能把"每请求一个本地队列"当实现模型。
八、对象池、引用留存与容量治理
ConcurrentBag 常被用于简易对象池,因为归还和再租可能发生在相同工作线程。但一个无限 Bag 也是无限池:如果 Return 长期多于 Rent,它会保留所有对象;若某些对象携带大数组、事件订阅或敏感数据,内存与状态风险都由上层承担。
可靠对象池需要额外规则:
-
Return 前恢复对象状态,清除外部引用和敏感内容;
-
设定全局或分片容量上限,超出时丢弃对象;
-
租出到归还之间只有一个所有者,禁止 double return;
-
归还后不再访问,防止 use-after-return;
-
对偶发膨胀的缓冲设置最大保留容量;
-
应用关闭时明确清理资源,Bag 不会替元素调用 Dispose。
// 应用层示意,不是 BCL 源码;近似上限在并发下需更严谨计数。
sealed class BufferPool
{
private readonly ConcurrentBag<byte[]> _bag = new();
private readonly int _bufferSize;public BufferPool(int bufferSize) => _bufferSize = bufferSize; public byte[] Rent() => _bag.TryTake(out byte[]? value) ? value : new byte[_bufferSize]; public void Return(byte[] value) { if (value.Length != _bufferSize) return; Array.Clear(value); _bag.Add(value); }}
示例未实现容量上限,不能直接用于不受信任流量。生产代码可优先评估 ArrayPool<T>、ObjectPool<T> 或领域专用池,因为它们表达了更多治理意图。无论选什么,都应以内存快照检查 WorkStealingQueue 数量、后备数组容量和元素引用链。
九、与 Queue、Stack 和 Channel 的选择
| 需求 | 更合适的起点 | 原因 |
|---|---|---|
| 同线程常归还、同线程常租用,顺序无要求 | ConcurrentBag | 利用线程本地快路和窃取 |
| 跨线程生产者---消费者,要求 FIFO 倾向 | ConcurrentQueue | 公共语义与队列一致 |
| 要求 LIFO 倾向 | ConcurrentStack | 公共语义表达栈 |
| 要等待数据、完成、背压或异步读写 | Channel / BlockingCollection | 提供协调协议而非轮询 Bag |
| 固定线程单独拥有池 | 普通 Stack/专用本地池 | 无需支付并发容器协调,但不能跨线程 |
ConcurrentBag 的本地 LIFO 不能替代 ConcurrentStack 的全局 LIFO 契约;窃取端 FIFO 也不能替代 ConcurrentQueue。若生产者和消费者固定分离,同线程复用优势消失,窃取扫描和锁路径可能成为常态。若必须公平地让旧项先处理,Bag 从语义上已经选错。
工作调度器也不一定应直接用 Bag。任务等待、取消、异常传播、优先级、完成检测和饥饿治理都超出它的职责。TPL 自身的调度结构不能从 ConcurrentBag 的实现简单类比。
十、Unity 的边界
Unity 项目能否使用 ConcurrentBag、内部实现是否与 CoreCLR v8.0.0 一致,取决于 Unity 版本、API Compatibility Level、Mono/IL2CPP 后端和平台。本文的字段与锁路径不能直接套到 Unity。即使 API 存在,IL2CPP 会把 IL 转为原生代码,线程、原子操作、GC 和内存布局也可能不同。
适合评估的场景是后台工作线程各自处理并复用临时对象,且对象不触碰只能在主线程调用的 Unity API。GameObject、Transform 和许多引擎对象不能因为装进线程安全 Bag 就安全地跨线程操作。主线程每帧从大量后台本地队列取结果还可能持续走窃取路径;若需求本质是后台产出、主线程按到达顺序消费,ConcurrentQueue 往往更清楚。
必须在目标设备 Player 中测:每帧 Add/Take 数、失败窃取、线程数、GC Alloc、暂停、峰值 retained memory 和长尾帧。编辑器结果只用于开发诊断,不能证明移动设备 IL2CPP 表现。
十一、故障反例与测试
常见失败包括:
- 用 Bag 实现消息 FIFO,偶尔出现乱序;这是违反抽象,不是实现 bug;
- Count 后循环取同样次数,并发下出现 TryTake false;Count 与后续操作不是事务;
- Peek 后修改"栈顶"对象,实际该对象已被另一线程取走;
- async Return 后仍使用对象,造成跨租用者数据竞争;
- 每个请求创建短命线程,长寿命 Bag 注册大量队列并保留容量;
- 把 Bag 当无限缓存,没有上限和淘汰策略;
- 枚举快照持有大量对象,误判为 Bag 清槽失败;
- 认为线程安全集合会使其元素自动线程安全。
测试应同时验证正确性和资源行为。给每个生产项分配唯一 ID,多线程 Add/Take 后核对没有丢失、重复领取或凭空产生;重复值测试则不能只靠值判断身份。用 Barrier 制造"所有者 Pop 与窃取者争最后一项",循环多次扩大竞态暴露概率。对 Clear/ToArray/Count 与并发 Add/Take 做压力测试,但断言只依据各 API 契约,不假定顺序。
内存测试创建可识别的大对象,用 WeakReference 配合强制 GC 只能作为辅助;更重要的是使用 dotnet-counters、dotnet-gcdump 或分析器查看引用链,并确保测试变量和 JIT 生命周期不意外持有对象。短命线程场景应记录注册队列数量需要诊断私有实现,不能让生产业务反射私有字段。
十二、可复现实验:区分本地与窃取负载
下面是实验结构伪代码,不是严谨基准成品。它要求将"同线程 Add/Take"和"生产线程 Add、消费线程 Take"拆成不同用例:
// 实验代码:用 BenchmarkDotNet 的线程基准或独立压力工具完善。
static long SameThreadRounds(int workers, int rounds)
{
var bag = new ConcurrentBag<int>();
long checksum = 0;
Parallel.For(0, workers, worker =>
{
long local = 0;
for (int i = 0; i < rounds; i++)
{
int value = (worker * rounds) + i;
bag.Add(value);
if (bag.TryTake(out int taken))
local += taken;
}
Interlocked.Add(ref checksum, local);
});
return checksum;
}
跨线程实验应用固定的专用生产者先填充,各消费者在屏障后开始 TryTake,并记录成功数、失败扫描和总校验和。另设 ConcurrentQueue/ConcurrentStack 对照,但只在语义可接受时比较。每个实验记录 .NET 8.0.0 runtime、SDK、OS、CPU、核心数、Server/Workstation GC、线程数、元素类型、预热、运行时长和原始分布;不要只报告平均值。
为了观察冻结成本,单独让监控线程以不同频率调用 Count 或 ToArray,比较吞吐和尾延迟。该实验展示"观察者效应",不能得出 Count 永远昂贵多少的固定数字。为了观察 async 迁移,记录 Add/Take 前后的 Environment.CurrentManagedThreadId,但线程 ID 仅作诊断,不能作为业务身份。
任何性能报告都要先证明结果正确,并保存源码与完整输出。固定 v8.0.0 的结论也只能解释该运行时、该硬件、该负载;换成新版 .NET 或 Unity 后端必须重跑。
十三、结论
ConcurrentBag 的核心不是"一个神奇的无锁数组",而是多个线程本地环形工作队列加一条全局注册链。v8.0.0 中,所有者从 tail 加入并优先从 tail 取回,窃取者从 head 取得旧项;本地宽松状态有快速路径,边界、扩容和远端访问会进入同步路径。Count、快照枚举和 Clear 依赖冻结全部队列,因而会干扰并发热路。
它最适合顺序无关且具有线程亲和复用的负载。async 续体迁移、生产消费线程分离和短命线程会削弱这一优势;无限对象池、错误所有权和元素自身数据竞争也不会被容器修复。需要 FIFO、LIFO、背压或等待时,应选择语义相符的 ConcurrentQueue、ConcurrentStack、Channel 或 BlockingCollection。
最后,始终区分三层事实:Bag 的公共无序线程安全契约、.NET 8.0.0 的 WorkStealingQueue 私有实现、目标平台实测结果。只有第一层可以跨实现依赖;后两层必须随版本与平台重新核验。