1. 先说清楚ConcurrentBag到底适合什么场景我在写并行任务调度时最早用的线程安全集合是ConcurrentQueue。它的先进先出语义非常直观多线程往里塞任务、往外取任务行为可预测。后来有个需求是一批任务计算结果不关心收集顺序只要全部收回来就行我顺手也用了ConcurrentQueue。直到某天压测发现并发瓶颈不在计算本身而在队列的入队出队竞争上我才认真去研究ConcurrentBag——这个平时不太显眼、文档描述为无序集合的类型。ConcurrentBag的核心特征有三个线程安全、允许重复元素、遍历和取出时不保证顺序。它不是简单地把List包一层锁而是基于线程本地存储 工作窃取的思路设计出来的。理解它才能真正明白为什么在某些场景下它比ConcurrentQueue快而在另一些场景下它又慢得离谱。这个系列前面几篇讲Thread、Task和并发集合基础这篇专门拆ConcurrentBag的实现原理。我会把.NET Framework时代的经典实现ThreadLocalList 偷取机制和.NET Core 2.0之后的重写版本都讲一遍因为两代设计的侧重点完全不同。今天还在维护老项目的人见到的多半是旧实现新项目跑在.NET 6/8上看到的就是无锁版本。两个都懂才算真正掌握这个集合。适合读者正在做生产者消费者模型、需要临时收集并发结果、或者在想为什么我换了个集合性能反而下降的人。2. 旧版ConcurrentBag的设计骨架线程本地列表与工作窃取2.1 为什么会想到线程本地存储方案先看传统做法的问题。如果用一个全局List加一把锁所有线程的Add和Take都会抢同一把锁。假设有8个线程疯狂写入锁竞争会非常激烈CPU大部分时间浪费在等待和上下文切换上。而瓶颈不是数据结构本身是并发控制方式太粗暴。旧版ConcurrentBag的逻辑是几乎每个操作都先访问自己的那份数据不碰别人的这样锁竞争自然就小了。它内部使用了一个类似ThreadLocalThreadLocalList的结构。每一个线程第一次往ConcurrentBag里Add元素时会初始化一个ThreadLocalList这个列表只属于当前线程。后续这个线程所有的Add操作都只往自己的ThreadLocalList头部插入数据。这样在多线程各自频繁写入、很少互相读取对方数据的情况下绝大部分操作根本没有跨线程竞争。这个思路用生活类比就是一个公司有多个工位每个工位旁边有自己专属的收件箱。员工往自己收件箱里扔文件不需要排队只有收件箱满了或者需要去同事那边拿文件时才需要互相协调。这就是ThreadLocalList存在的意义。2.2 ThreadLocalList的内部成员ThreadLocalList不是普通的List它被设计成链表结构并且对头部操作做了优化。核心成员大致有成员作用Head指向链表头节点所有Add都是头插法Tail指向链表尾节点主要在Steal操作时用Count当前线程本地的元素数量Previous / Next把线程本地列表串成全局链表方便遍历所有列表注意这个Previous / Next它把每个线程独立的ThreadLocalList通过双向链表串起来。为什么要串起来因为当线程A想偷线程B的数据时需要能遍历到所有线程的本地列表。每次有新的ThreadLocalList创建就会把它挂到这个全局链表里。节点本身存储数据、计数器和下一个节点的指针。取数据时是从头部取也就是后进先出的顺序。这一点很关键——ConcurrentBag的无序其实是后进先出加随机偷取叠加出来的效果。2.3 Add操作头插法为什么快看Add的简化逻辑// 伪代码演示核心思想 public void Add(T item) { ThreadLocalList localList ThreadLocalList.Value; // 当前线程的本地列表 if (localList null) { localList new ThreadLocalList(); ThreadLocalList.Value localList; } localList.AddHead(item); // 头插不需要锁或者只在极端情况下锁 localList.Count; }头插法的效率来自两点不需要遍历。链表头部插入只需要更新头节点指针复杂度O(1)。当前线程操作自己的列表几乎没有竞争。只有在ThreadLocalList被其他线程偷取Steal的时候当前线程的Add才可能需要锁介入。所以Add操作在绝大多数情况下是无锁的这是ConcurrentBag对写操作极度友好的根本原因。对比一下ConcurrentQueue的入队它需要处理消费者竞争Tail指针ConcurrentBag的Add比它更轻量。2.4 Take操作两阶段查找逻辑Take操作比Add复杂得多它的核心逻辑分为两步第一步从当前线程的本地列表取。如果本地列表非空直接TryPop一个节点成功就返回。这保证了大部分情况下操作线程自己就能完成不需要碰其他线程的数据。第二步本地列表为空时偷取其他线程的列表。遍历全局的ThreadLocalList链表找到第一个非空的列表从头部弹出一个节点。注意这里偷的是头部而不是尾部也就是从对方最新加入的元素开始取。这一设计导致ConcurrentBag的取出顺序非常不可预测它不仅仅后进先出还取决于当前线程是否还有本地元素、哪个邻居的列表非空。两阶段设计的精妙在于当一个线程是典型的生产者只写入而另一个线程是消费者只取出时消费者发现自己的本地列表为空就会去生产者的本地列表里偷。这样不需要一个全局的共享队列也不需要通过锁同步每个Add写操作那边几乎不受影响。2.5 偷取操作的锁粒度与并发控制偷取并不是完全无锁的。当一个线程要偷另一个线程的ThreadLocalList时它需要获取目标列表的锁。旧版实现里每个ThreadLocalList有一个m_lock对象。偷取过程中会锁住目标列表执行弹出一个节点的操作然后释放锁。这个锁的粒度非常细只锁对方那一个列表而不是整个ConcurrentBag。因此多个线程同时偷取不同目标列表是并行的即使多个线程同时偷同一个列表也只需要竞争那一个列表的锁不会影响其他线程对自己本地列表的Add操作。真正麻烦的情况是当前线程的本地列表恰好被别的线程偷空了。此时当前线程要进行一次Add它必须和其他想偷这个列表的线程竞争同一把锁。这种场景在负载不均衡的生产者消费者模型里时有发生也是旧版实现吞吐量下降的关键原因。2.6 旧版的缺陷删除操作与同步开销旧版ConcurrentBag有一个比较隐蔽的痛点TryTake从别的线程列表里偷元素时会修改那个列表的链表结构但那个列表的头指针所指向的节点可能正在被属主线程快速Add。这就要保证头指针的更新是原子的。旧版通过synchronized锁来解决但这意味着所有涉及头指针的操作都要互相让步。另一个痛点是列表空时回收的问题。一个线程长时间不操作它的ThreadLocalList仍然挂在全局链表上遍历偷取时依然会扫过它。大量空闲线程会造成不必要的遍历开销。虽然旧版做了计数和管理但整体上随着线程数量增加偷取遍历的路径会变长。3. 新版本的无锁重写从锁竞争到原子操作3.1 为什么微软选择重写而不是修补.NET Core 2.0开始ConcurrentBag被彻底重写这一版一直沿用到.NET 8。重写的动机很明确旧版的ThreadLocalList 锁方案虽然并发友好但实际上锁的引入让很多纯操作场景尤其是高频Add 高频Take交替的场景还是会产生可观的锁竞争。新的实现思路是把全局的一个链表改成多个链表的复合结构用Interlocked原子操作替代锁从根本上减少阻塞。新版本的内部不再是每个ThreadLocalList一把锁而是采用一个无锁的单项链表数组 线程ID哈希映射的方式。每个线程通过线程ID定位到自己的槽位这个槽位维护一个无锁链表。Add操作直接通过原子操作把头节点替换为新节点TryTake则通过原子操作尝试把头节点往下移动。用Interlocked操作替代锁最大的收益是在竞争不高的情况下完全无等待。这与lock相比避免了进入内核态的开销和上下文切换。3.2 新版的Add与TryTake核心流程新版Add流程简洁清晰// 逻辑说明用不是完整源码 private void AddInternal(T item) { var localList GetCurrentThreadList(); // 依据Thread.CurrentThread.ManagedThreadId定位 localList.Add(item); // 无锁头插 Interlocked.Increment(ref m_count); // 全局计数增加 }每个链表节点中不再需要额外的锁对象新增节点时只需要var newNode new Node(item); newNode.Next localList.Head; // CAS操作如果期间头节点被其他线程改变则重试 while (Interlocked.CompareExchange(ref localList.Head, newNode, newNode.Next) ! newNode.Next) { newNode.Next localList.Head; }TryTake则反向操作先尝试从当前线程对应的列表中用CAS弹出头部节点。如果当前列表为空则遍历其他线程的列表找到非空的就尝试偷取头部节点。整个遍历过程不需要锁只需要用CAS保证原子性。这种设计的最大好处是在单生产者单消费者场景下Add和Take几乎全是CAS成功没有任何等待。在多生产者多消费者场景下只有多个线程同时操作同一个槽位时才会发生CAS重试而这种情况概率很低除非任务分配极度不均。3.3 全局计数与缩容策略新版维护了一个全局的m_count字段用Interlocked原子增减。为什么要维护全局计数因为ConcurrentBag需要支持Count属性而Count的要求是返回一个近似值。如果没有全局计数获取Count又得遍历所有列表代价太高。新版的做法的确返回了一个弱一致性快照Count可能略大于或略小于实际元素数量但在绝大多数业务场景中够用。值得留意的是新版引入了自动缩容当某个线程对应的链表长时间为空时系统会定期检查并可能把它从全局列表中摘除避免遍历时的长尾。这是旧版没有的优化。3.4 新旧版本的性能对比我简单整理了一个对比表说明两代实现的差异维度旧版.NET Framework新版.NET Core 2.0存储方式ThreadLocalList双向链表线程槽位 无锁单链表并发控制锁 原子操作混合纯CAS原子操作Add性能本地操作无锁偷取时可能锁本地操作无CAS竞争极端情况下重试Take性能本地非空无锁偷取时锁本地非空无CAS竞争偷取时CAS重试空闲线程回收不彻底自动摘除空链表遍历支持支持弱一致快照支持弱一致快照实测下来新版在8线程、20万次Add 20万次Take的混合压力下比旧版有差不多30%-50%的提升尤其在分布均匀的场景下更明显。4. ConcurrentBag和ConcurrentQueue/ConcurrentDictionary的本质区别4.1 顺序语义的差异很多初学者会问既然ConcurrentBag在取出时也不保证顺序那和ConcurrentQueue有什么区别ConcurrentQueue保证严格的FIFO也就是说它内部有一个明确的队列逻辑顺序。ConcurrentBag的内部顺序则取决于线程本地操作和偷取行为A线程Add的元素可能被A线程自己Take走也可能被B线程偷走甚至可能被C线程偷走。最终谁先被取出完全取决于运行时的并发调度无法预测。因此如果你的业务依赖处理顺序绝不能使用ConcurrentBag。比如任务队列、消息管道、事件流处理都应该用ConcurrentQueue。反之结果收集、日志批量发送、去重前缓冲这类对顺序无要求的场景ConcurrentBag可以发挥更好性能。4.2 增删查操作的复杂度对比集合类型AddTakeCount是否有界ConcurrentQueueO(1)入队O(1)出队O(1)计数无界ConcurrentStackO(1)压栈O(1)弹栈O(1)计数无界ConcurrentBagO(1)本地操作O(1)本地或O(n)偷取O(1)弱一致无界ConcurrentDictionaryO(1)均摊O(1)均摊O(1)无界注意Take在不同情况下的差异当线程从自己的本地列表取时O(1)当必须偷其他线程时最坏情况需要遍历所有线程列表如果线程数量很大而且大部分列表都为空开销会很明显。4.3 线程亲和性的影响ConcurrentBag的一个隐藏特性是线程亲和性。一个线程放入的元素会优先被同一线程取出其次是相邻线程。这看起来像BUG其实是设计使然。这种亲和性对某些场景非常有利。比如一个在线程池里反复执行的任务每个线程处理完一批数据就Add(bag)下次再来时TryTake大概率能拿到自己上次放的数据这个数据在CPU L2/L3缓存里还有余温读取成本更低。虽然元素是引用类型的话缓存优势有限但如果是值类型收益是可观的。反过来如果你的所有生产者都是同一个线程所有消费者都是另一个线程那么ConcurrentBag的亲和性优势完全发挥不出来——每次Take都要偷每次偷都要走链表甚至CAS重试。这种严格生产消费分离的场景ConcurrentQueue反而更稳定。4.4 实际选择建议我用一张判断流程帮助你做决定需要严格FIFO → ConcurrentQueue需要严格LIFO → ConcurrentStack需要按键值对访问 → ConcurrentDictionary只需要无序收集结果且生产者和消费者可能重叠 → ConcurrentBag元素少、竞争低且代码要最简单 → 直接Lock List也行第5条可能很多人不同意但我要说不要为了并发而并发。如果实际线程数不超过2个总量只有几百个元素用一个普通的List加一把锁代码可读性好性能也不差。ConcurrentBag的优势在高并发、高写入频率、顺序无关的大规模场景下才真正体现。5. 高频使用中容易踩的坑Take随机性、快照一致性、性能陷阱5.1 Take返回顺序不可预测带来的业务BUG我曾经在一个实时数据聚合项目里用ConcurrentBag收集多个线程的计算结果然后定时整体取出来做聚合。代码本身没问题但测试时发现聚合结果偶尔对不上。排查到最后问题出在业务逻辑隐含了对收集顺序的依赖——虽然不需要有序但需要同一批结果的相对时间顺序而ConcurrentBag的LIFO特性直接把这个顺序打乱了。最终我改成了ConcurrentQueue。这个教训告诉我在引入任何并发集合前先明确你的业务对顺序的容忍度。如果有一丁点顺序需求就不要碰ConcurrentBag。5.2 GetConsumers遍历的弱一致性GetConsumers()或直接foreach遍历ConcurrentBag时返回的是一个弱一致快照。这意味着遍历过程中其他线程仍然可以往里Add或TryTake你看到的元素集合可能不完整或多出来。如果你需要某一瞬间的精确快照正确做法是T[] snapshot bag.ToArray();但注意ToArray()需要遍历整个内部链表结构开销不小。在高频写入场景下每次ToArray都会影响生产者线程吗不会因为它不锁生产者但它会经历多次CAS和链表遍历占用CPU时间。所以快照频率要控制建议批处理而不是每条数据都快照。5.3 空转偷取的性能陷阱假设一个消费者线程持续TryTake但所有生产者的本地列表都空了。此时消费者必须遍历全局链表找非空列表遍历一圈找不到返回false。如果有8个线程每次都遍历8个列表空转成本就很高。更糟的是高竞争时CAS重试。两个消费者同时发现了同一个非空列表同时执行CAS弹出头部只有一个成功另一个重试。如果重试时列表已经被偷空它又得从头遍历。这种遍历-失败-再遍历的模式在消费者数量大于生产者时会被放大。优化思路控制消费者线程数不要让空闲消费者高频轮询同一个bag。或者结合SemaphoreSlim做信号量等待有数据再触发Take。5.4 Count属性不是精确的新版实现的Count是弱一致的所以不要依赖它做精确判断。比如下面这段代码if (bag.Count 0) { item bag.Take(); // 可能抛异常 }ConcurrentBag的TryTake在空集合时返回false但如果你先判断Count再Take中间可能有其他线程把元素拿走导致Take抛异常。正确写法永远是if (bag.TryTake(out item)) { // 处理item }这是并发集合使用的第一原则永远使用TryXXX模式不要先检查后操作。5.5 大量短命线程导致的列表膨胀新版虽然会自动摘除空列表但如果你的程序频繁创建线程执行一次性任务每个线程都会创建自己的本地链表用完即弃。这些链表理论上会被回收但回收时机并不是实时的。在高并发短任务场景下内部链表数量可能短暂膨胀拖慢偷取遍历。解决办法是使用线程池而不是频繁新建Thread。线程池复用线程ID本地链表也能被复用避免了反复创建销毁的开销。6. 实际性能测试与结论分享6.1 测试场景设计我自己用一个.NET 8控制台项目做了简单压测四组场景每组都是20万次操作场景生产者线程数消费者线程数操作模式A11生产者只Add消费者只TakeB88每个线程随机Add/TakeC161多生产单消费D116单生产多消费6.2 结果分析场景A下ConcurrentBag和ConcurrentQueue差距不大ConcurrentQueue还略有优势因为单生产者单消费者模型下队列可以用无锁环形缓冲区优化。场景B下ConcurrentBag表现最好比ConcurrentQueue快约35%。原因在于每个线程都在操作自己的本地列表几乎零竞争。场景C和D是两个极端。场景C多生产者单消费者中消费者频繁偷取所有生产者都在写入偷取时CAS竞争加剧ConcurrentBag的吞吐量明显下降和ConcurrentQueue差距缩小到5%以内。场景D单生产者多消费者中多个消费者同时偷取同一份数据CAS竞争最为激烈ConcurrentBag甚至比ConcurrentQueue慢20%。结论很清晰ConcurrentBag最适合多线程各自生产并各自消费或者生产者消费者重叠的场景。如果严格区分生产者和消费者且数量不均衡表现并不好。6.3 实践建议汇总最后把我的经验整理成几个要点用Interlocked原子操作而不是lock重写并发集合是降低竞争的有效手段但前提是竞争概率本身要低。线程关联性是ConcurrentBag的双刃剑理解它才能预测性能。永远用TryTake安全模式不要先查Count再取。做快照时用ToArray但不要高频调用。控制消费者线程数避免大量空转偷取。短命线程场景下务必使用线程池避免列表膨胀。我在实际项目里最常用的组合是ConcurrentBag负责收集每台服务器各线程的运行指标定时批量打包上传ConcurrentQueue负责真正的任务分发管道。两者各有分工各自用在最匹配的环节。写这篇的时候我特意对比了.NET Framework和.NET Core两代实现因为很多老项目还停留在旧版本熟悉两套机制才能快速判断线上问题。理解了一个集合的内部设计写并发代码时心里就有底了。至于什么时候该用哪种集合记住一点没有万能的并发容器只有适合场景的数据结构。