接手过一个流量统计服务每天请求量千万级。核心接口里维护一个Dictionarystring, Counter所有读写都用lock包着。并发量低的时候一切正常流量一上来锁竞争直接把 CPU 打满接口 P99 从 30ms 涨到 800ms。排查的时候看到一段代码lock (_syncRoot) { if (!_dict.ContainsKey(key)) _dict.Add(key, value); }就知道问题出在哪了。字典本身没有并发能力靠外部锁把所有读也串行化了。当时有两个选择细化锁粒度或者换成System.Collections.Concurrent.ConcurrentDictionary。我选了后者因为 ConcurrencyDictionary 不是简单地给字典加锁它的内部机制、API 设计和适用边界都值得好好挖一遍。这篇文章不打算堆方法列表而是从它到底怎么实现的为什么读多写少场景特别快有哪些看上去没问题实际上坑死人的用法三条线展开。无论你是刚接触并发编程的 .NET 初学者还是已经在生产环境用了很久但没看过源码的人看完应该都能更清楚地在项目里做选型。1. 从字典锁到并发字典一个问题驱动的演进1.1 Dictionary Lock 方案的两个痛点很多老代码里的线程安全字典长这样private readonly Dictionarystring, int _dict new(); private readonly object _lock new(); public void AddOrUpdate(string key, int value) { lock (_lock) { _dict[key] value; } } public bool TryGet(string key, out int value) { lock (_lock) { return _dict.TryGetValue(key, out value); } }这种做法最大的问题在于读操作也要抢同一把锁。字典在高并发下大部分操作是读读和读之间根本没有冲突但锁不区分大家排着队拿锁吞吐量直接被压死。第二个痛点是复合操作必须自己保证原子性。不存在才添加、存在才更新、更新前先比较旧值这些场景如果拆成ContainsKeyAdd两步执行中间随时可能被另一个线程插入结果就错了。所以你得花大量精力把复合逻辑塞进lock里代码越写越长锁的粒度和边界也越来越难控制。而且一不留意还可能死锁——嵌套调用别的加锁方法时锁顺序一旦出现环线上服务就直接挂起。1.2 并发集合的设计目标把读解放出来ConcurrentDictionary 想解决的就是让读操作尽量无锁、让写操作只影响局部、让常用的复合操作本身是原子的。它属于System.Collections.Concurrent这个专门为多线程场景设计的集合家族ConcurrentQueue、ConcurrentStack、ConcurrentBag、BlockingCollection都是同一批产品。它们的设计原则是使用细粒度的同步机制而不是用一个全局锁保护整个对象。读多写少时读路径可以完全并行写操作只锁住受影响的桶bucket互不干扰的 key 写入可以真正同时发生。为实现这个目标ConcurrentDictionary 没有沿用普通字典的结构而是做了重新设计。这也是它和Dictionarylock最本质的区别。2. 拆开 ConcurrentDictionary 的肚子桶、链表和细粒度锁2.1 存储骨架哈希桶数组加节点链表ConcurrentDictionary 的底层不是一棵树也不是一个大数组直接存值而是一组哈希桶每个桶后面挂着一条节点链表。节点里存着 Key、Value 和指向下一个节点的引用。多了链表这一层哈希冲突时就往链表后面追加节点不用像普通数组那样线性探测。计算 Key 的哈希值后通过位运算快速定位到某个桶的索引。这里有个细节桶的数量通常是 2 的幂这样hash (buckets.Length - 1)一条位运算就能算出桶下标性能比取模%高很多。链表本身是无序的所以并发修改时只需要保护当前桶对应范围的节点而不是整个数组。读到这你可能会想链表查找不是 O(n) 吗哈希冲突严重时岂不很慢是的理论上存在这个问题。但 ConcurrentDictionary 在插入时会监控冲突程度元素数量超过阈值就触发扩容重新散列到更大的桶数组里把链表打散把查找复杂度压回常数级。所以扩容不仅是容量问题也是哈希冲突的修复手段。2.2 写入路径条带化锁与扩容防护写操作添加、更新、删除不能完全无锁因为涉及修改链表结构。但 ConcurrentDictionary 没有用一把大锁而是维护了一个锁数组。具体逻辑是锁数组的长度是 2 的幂长度最多和并发级别默认取 CPU 核心数相关。每次写操作时再用同一个哈希值计算出锁数组里的某个锁下标。比如hash (buckets.Length - 1)定位桶hash (locks.Length - 1)定位锁这样做的效果是不同的 key 可能落到同一个桶但还会根据哈希值分散到不同锁上多个线程的写操作可以并行只要它们最终抢的不是同一把锁。由于锁的数量小于桶的数量必然出现多个桶共享一把锁的情况但这已经比全字典一把锁细粒度得多。拿到锁之后再重新读一次当前的表状态确认在获取锁的间隙没有发生扩容如果没有就正常操作链表插入新节点、更新已有节点的 Value或者摘除目标节点。如果发现已经扩容了就改用最新的桶数组重新执行。这里有个细节值得注意读取时无锁写入时加锁但写入线程读到的必须是最新且一致的桶数组。高频并发写入场景下如果两个线程同时触发扩容必须保证只有一个线程真正执行重建其他线程等待。这也是 ConcurrentDictionary 源码里比较复杂的部分但对使用方来说是透明的你要知道的是频繁扩容会带来性能抖动最好在一开始就传入足够的预估容量。2.3 扩容的成本远超你的想象ConcurrentDictionary 的扩容和普通 Dictionary 扩容不一样。普通 Dictionary 扩容时只需要把内部数组重新散列期间访问者会被锁挡住ConcurrentDictionary 扩容时需要获取所有锁然后遍历旧的所有桶把每一个节点重新散列到新的桶数组再一次性切换内部引用。这意味着扩容瞬间所有写线程都被挡住。扩容过程是 O(n) 的元素一多延迟就会突刺。频繁扩容会放大锁竞争性能曲线会出现明显的锯齿。我见过有人拿new ConcurrentDictionarystring, X()默认构造在启动阶段一次性灌几十万条数据结果前几分钟服务卡顿得厉害。解决办法很简单预估容量并传进去比如new ConcurrentDictionarystring, X(Environment.ProcessorCount * 4, 200000)。第二个参数是初始容量让它一步到位避免在数据灌入过程中反复扩容。这个习惯能省掉很多莫名其妙的性能问题。3. API 使用方法论从 TryAdd 到 AddOrUpdate 的选择3.1 高频方法速查ConcurrentDictionary 提供了一套精心设计的 API每个方法名都对应一种并发语义用错了虽然不会编译报错但逻辑会出偏差。我用一张表把核心方法说清楚方法行为推荐场景TryAdd(key, value)key 不存在则添加存在则返回 false分布式锁、幂等写入TryGetValue(key, out value)尝试读取失败返回 false读多写少场景的主路径TryUpdate(key, newValue, comparisonValue)仅当当前值等于 comparisonValue 时才更新CAS 式的条件更新TryRemove(key, out value)删除并返回旧值消费完成后移除this[key]读取key 不存在抛异常确信 key 一定存在时GetOrAdd(key, valueFactory)key 不存在则执行工厂创建并添加存在则返回现有值缓存场景AddOrUpdate(key, addValue, updateFactory)不存在则添加存在则基于旧值计算出新值计数累加索引器的赋值语法dict[key] value实际上是无脑覆盖无论 key 存不存在都会写入。如果你要的是不存在才添加一定要用TryAdd用索引器会把已存在的值覆盖掉。TryUpdate是个容易被忽略但很好用的方法。它很像Compare-And-Swap传入我期望的旧值如果此时字典里的值已经不是你期望的那个了就返回 false由你决定是重试还是放弃。这比先用TryGetValue拿到值、再赋值的方式安全得多因为它把比较和更新合并成了一个原子操作。3.2 GetOrAdd / AddOrUpdate 的委托陷阱很多人第一次写缓存代码时会写var config cache.GetOrAdd(appConfig, key LoadConfig(key));看起来没问题没有就加载有就用缓存。但官方文档和源码都明确说了传给 GetOrAdd 的 valueFactory 可能在锁外被多次调用。两个线程同时发现 key 不存在它们都会去执行LoadConfig只不过最终只有一个结果被放进字典另一个结果被丢弃。后果是工厂方法的开销会被放大如果工厂逻辑很重查数据库、调远程接口高并发下等于同一份数据查了好几次。工厂方法如果有副作用发消息、写日志、改计数器会出现执行了但结果没被采用的脏副作用。你不能依赖 factory 的调用次数来统计真实命中率。AddOrUpdate的 updateValueFactory 同理它在锁内还是锁外执行并没有可靠保证如果试图在 factory 里再反过来读字典的其他 key容易读到不一致的状态。正确的应对方式有两种。第一种如果工厂逻辑很轻直接忍受多次调用第二种结合LazyT让创建真正只发生一次。3.3 缓存场景标准答案GetOrAdd 配 Lazy用GetOrAdd(key, k new LazyT(() Load(k)))返回的是LazyT对象再访问.Value才真正触发加载。这样一来即使GetOrAdd的工厂被并发调用多次也只是创建了多个LazyT包装真正昂贵的加载逻辑由LazyT自己保证线程安全只有一个线程会执行内部逻辑其余线程拿到同一个结果。private readonly ConcurrentDictionarystring, LazyOrderSummary _orderCache new(); public OrderSummary GetOrderSummary(string orderId) { var lazy _orderCache.GetOrAdd(orderId, id new LazyOrderSummary(() LoadOrderSummary(id))); return lazy.Value; }这段代码在高并发下是安全的同时避免了重复加载。代价是每个 key 多了一个LazyT对象的开销但对绝大多数缓存场景来说这点内存换线程安全非常划算。3.4 弱一致性它的语义可能和你想象的不同ConcurrentDictionary 的读取操作是弱一致的。具体说枚举时不会抛InvalidOperationException哪怕另一个线程正在修改字典。枚举器看到的可能是在枚举开始后发生的修改也可能看不到在枚举开始前已经发生的修改。它不保证是某个时间点的精确快照。Count属性也是近似值。多线程并发写入时Count返回的数可能比实际少或多只保证状态正确不保证实时精确。ContainsKey同样只代表方法执行那一刻的近似判断。这不是并发字典偷懒而是有意的设计取舍为了读取时不加锁只能接受弱一致。你要是拿它当强一致性的数据源在分布式事务或账务核对里用会踩大坑。在需要先判断再决策的业务里比如库存扣减、金额累加别用它做判断 写入的组合除非用TryUpdate配合 CAS 思路。4. 性能差异到底有多大从实测到选型4.1 读多写少无锁读的优势一目了然我在自己的机器上做过一个粗糙对比实验.NET 88 个逻辑核心16 个线程并发操作同一批 key场景Dictionary 全局锁ConcurrentDictionary16 线程纯读10 万次迭代约 85ms约 18ms16 线程 9 读 1 写10 万次迭代约 120ms约 45ms16 线程 1 读 9 写10 万次迭代约 160ms约 190ms结果很直观读比例越高ConcurrentDictionary 优势越大。因为读路径无锁线程之间几乎不互相干扰。而全局锁方案无论读多少都要排队拿锁线程一多光锁等待就有明显开销。但注意最后一行写密集型场景下ConcurrentDictionary 的优势几乎没了甚至略差。原因前面讲过写操作要抢锁锁数量受限于并发级别多个共享同一锁的 key 依然会互相阻塞还要处理扩容时的全锁等待。所以如果你的业务是高频写入、几乎不读别指望 ConcurrentDictionary 性能奇迹反而要想想数据结构设计能不能简化。4.2 哪些场景不该用 ConcurrentDictionary选型不是无脑并发就用 ConcurrentDictionary我总结了几个明确的反例第一个是单线程场景。单线程下普通 Dictionary 无论读写都比 ConcurrentDictionary 快因为并发字典多了一层哈希定位锁、无锁读取的 Volatile 屏障开销这些成本在没有竞争时完全白白付出。第二个是需要频繁整体快照的场景。如果你经常要ToArray()、遍历Values、统计所有元素ConcurrentDictionary 的弱一致性和快照生成成本会让你优势尽失。这时候用DictionaryReaderWriterLockSlim反而更合适读锁允许并行写锁互斥整体快照时拿读锁获取的是强一致快照。第三个是容量会在运行中剧烈变化的场景。比如一个不断塞入新 key、永不删除的进程级缓存ConcurrentDictionary 在扩容时的全锁操作会造成延迟毛刺。对这种场景可以考虑预分配超大容量或者用LazyT缓存 定期重建而不是让它在运行中反复扩容。4.3 用一个决策表帮你判断你的场景推荐方案多线程强并发读多写少key 查找频繁ConcurrentDictionary多线程并发但需要判断-执行原子复合操作ConcurrentDictionary 的 TryAdd/TryUpdate/GetOrAdd单线程使用追求极致性能普通 Dictionary需要强一致快照、经常整体枚举Dictionary ReaderWriterLockSlim高并发计数累加ConcurrentDictionary 的 AddOrUpdate或直接用Interlocked 普通数组/字典有天然的扩容重启窗口ConcurrentDictionary 预分配容量这个表不能覆盖所有情况但应该能帮你避开最常见的选型错误。5. 生产环境里我踩过的坑和替代方案5.1 Count 是近似值别拿它做空判断项目里出现过一次诡异现象一个监控告警判断if (dict.Count 0)认为缓存清空但实际上瞬间又有写入线程补充了数据告警误报。问题就出在 ConcurrentDictionary 的Count不会加锁遍历它是在遍历过程中边数边变的结果自然是约等于。想判断是否为空用IsEmpty不要用Count 0。虽然IsEmpty也是弱一致但至少语义正确而且内部实现上它检查桶是否全为空比遍历所有节点快。更稳妥的做法是在业务层面用一个独立的volatile bool标记记录当前是否有数据读取时先看标记再走字典查询。同理不要对Count做精确断言不要拿它去和某个业务阈值 1000做比较。它适合做监控面板上的近似展示不适合做控制流条件。5.2 Keys、Values、ToArray 的隐藏代价ConcurrentDictionary.Keys每次访问都会生成一个快照集合。也就是说你写个定时任务每秒遍历一次dict.Keys每次都在偷偷生成一个完整列表元素多时内存分配量非常吓人。我见过有人把dict.Keys放在 foreach 里循环里还去dict.TryGetValue等于同一份数据被快照了两次。正确的做法是如果只是遍历查询直接foreach (var kvp in dict)枚举器是弱一致的但足够用于大多数统计场景如果必须拿到某个时间点的强一致快照那就明确接受它的成本把快照结果缓存起来复用。ToArray()同理它是我在做快照的显式声明高频调用会让 GC 压力飙升。生产环境里如果有定期扫描任务建议改成一次快照 多次复用或者用IsEmpty先判断有没有扫描的必要。5.3 值对象本身的线程安全依然要自己负责ConcurrentDictionary 保证的是字典操作的线程安全比如添加、删除、更新引用但它不保证你存储在字典里的对象内部状态是线程安全的。比如你在字典里存了一个Liststring类型的 valuedict.GetOrAdd(key, _ new Liststring()).Add(item);这句代码极其危险拿到 list 之后多个线程可以同时往同一个 list 实例里Add而ListT不是线程安全的轻则数据不一致重则直接把进程打崩。正确做法要么这个 value 是不可变对象要么 value 本身也是线程安全的集合比如ConcurrentBag、ConcurrentQueue要么你就用lock或TryUpdate来控制对 value 内部状态的修改。最狠但也最稳的做法是每次更新都替换整个 value 引用让字典里的值始终指向一个发布时已完整的对象。5.4 和其他并发集合的分工如果只是需要线程安全字典ConcurrentDictionary 确实是首选。但没有银弹其他并发集合在某些场景更合适先进先出的消费队列用ConcurrentQueueT接口简单出队入队都是无锁或低并发冲突比拿字典模拟队列高效。需要阻塞等待消费者取任务的场景用BlockingCollectionT包一层ConcurrentQueue天然支持超时取消代码干净。元素只进不出的无序集合比如统计一批去重后的 ID用ConcurrentBagT比字典省内存。一对多的发布订阅场景优先考虑ChannelT尤其适合流式数据处理背压控制写得好。选错集合的常见症状是用字典当集合用为了不重复还要给每个元素造一个 bool value内存翻倍代码还绕。这时候回头想想数据模型往往能用专门的并发集合简化掉一大半复杂度。最后说一点个人体会ConcurrentDictionary 是那种用起来简单、用对很难的类型。它解决的是字典操作本身的并发安全不解决多个字典操作组合在一起的业务原子性也不解决value 内部状态的并发安全。遇到问题时不要急着加lock补救先回到语义层面想清楚你要的是 CAS、是原子加还是快照把这个想明白了ConcurrentDictionary 才真正是你手上最顺手的工具。