
做后端三年的人一定用锁过Dictionary但锁的粒度怎么选、死锁怎么避、吞吐量上不去怎么办这些问题绕到最后都会指向同一个名字ConcurrentDictionary。这篇文章不打算抄 MSDN也不打算列一份 API 大全而是把我在生产环境里用这个东西攒下来的经验摊开讲——它底层到底是什么、高频读写下哪些坑容易踩、怎么调参才不至于在压测里翻车。如果你是正在用 C# 写高并发缓存、计数统计或动态配置的同学这篇内容应该能帮你省掉不少试错时间老手可以直接跳到第三章看避坑清单那部分全是实战里长出来的教训。1. ConcurrentDictionary 解决的并发问题1.1 为什么普通 Dictionary 在高并发下会崩先讲清楚背景。.NET 的DictionaryTKey, TValue本身不是线程安全的底层是一个数组加链表/冲突链的哈希结构当多个线程同时做Add、Remove、Resize时会出现两类严重问题。第一类是可看见的异常两个线程同时触发扩容时内部数组的状态可能错乱后续的索引遍历就会抛ArgumentOutOfRangeException或IndexOutOfRangeException严重时直接死循环。第二类是更隐蔽的脏读由于没有内存屏障一个线程写入的键值对在另一个线程里可能长时间不可见尤其在不同 CPU 核心各自的 L1/L2 缓存中明明已经Add成功了另一个线程去TryGetValue却返回 false。这里有个生活化的类比普通Dictionary像一块共享白板两个人同时往上面写字互相覆盖、顺序错乱还可能因为抢擦写动作扭伤胳膊。ConcurrentDictionary则像是银行的柜台窗口——每个窗口有自己的队列跨窗口的协调交给统一机制处理所以整体上既保证不串号又不需要让所有客户排队等同一支笔。注意这不意味着加一个lock就能万事大吉。在低竞争场景下普通Dictionary加一把全局锁固然能保证安全但锁的粒度是整个表任何一次读操作都会阻塞所有其他操作而Dictionary的TryGetValue在最理想情况下只需要一次数组索引访问被锁堵住之后延迟会从纳秒级跳到微秒级吞吐量直接掉一个数量级。ConcurrentDictionary的初衷就是在保证线程安全的同时让读操作尽量不走全局锁。1.2 适用场景的边界判断很多文章会把ConcurrentDictionary吹成万能并发容器但实际项目中要克制。它适合的典型场景是三高高读高写交替频繁、键集合动态变化、并发操作需要在单条目级别上原子完成。在线商城里的库存扣减、跑批任务里的去重统计、网关里的 IP 限流计数这些都很适合。但它也有明确的不适用场景。如果你的数据量很小几百条以内且几乎全是读操作直接用普通Dictionary加ReaderWriterLockSlim反而更简单因为 RWL 的读写分离在低写竞争下几乎没有锁开销。如果你的操作需要跨多个键完成事务性更新比如转账时要同时更新两个账户ConcurrentDictionary也帮不上忙——单条目原子不等于多条目原子这种场景该上锁或者该上数据库事务就别硬塞进容器里。还有一点容易被忽略对象的引用语义。ConcurrentDictionary保证的只是容器结构的线程安全并不保证你存入的对象内部状态是线程安全的。如果你往里面放了一个可变对象多个线程拿到同一个引用后去改它的字段该加锁还是得加锁。这一点我会在后面的实战部分再展开。2. 核心设计原理从锁分段到无锁读取2.1 演进路线为什么老的锁分段方案被放弃了解原理有助于正确使用。老版本的 .NET Framework4.0 早期里ConcurrentDictionary采用的是锁分段Segment-based设计把哈希桶分成若干个段每个段有一把独立的锁。写入某个桶时只锁它所在的段其他段不受影响。这个设计在低并发时效果尚可但问题也很明显段的数量是固定的配置不当会在高并发下仍然产生锁竞争扩容和计数时依然要遍历所有段造成全局阻塞段锁的实现导致内存占用偏高GC 压力也随之增加。所以从 .NET Core 2.0 开始官方重写了底层实现核心思路是用一组更细粒度的锁配合无锁读取路径。读操作TryGetValue不再获取任何锁它直接按照哈希值去访问对应的冲突链节点通过Volatile和Interlocked来保证可见性写操作在定位到具体桶后才对该片区域的节点做短暂加锁或 CAS 更新。这就像白板边上每个人手里都有自己的记事本只有真正要改共享记录时才去抢一个叫桶锁的小窗口。我在实际项目里最直观的体感是同样在高并发读写下老版本在 16 线程时就可能出现明显的锁争抢而新版本在 32 线程下依然能保持稳定的吞吐曲线。所以如果你还在维护 .NET Framework 老项目要注意ConcurrentDictionary的性能表现和 SDK 版本强相关升级到 .NET Core 3.1 或 .NET 8 后很多并发瓶颈会自动缓解。2.2 读路径的内存可见性设计理解ConcurrentDictionary的读路径核心在于Volatile.Read。哈希表本身是一个节点数组数组引用和节点的 Next 指针都用 volatile 语义发布。当线程 A 往某个桶写入新节点时先构造完节点的所有字段再通过原子操作把该节点挂到冲突链的头部线程 B 在读的时候通过Volatile.Read读取桶引用的最新值再从链表头开始遍历。因为节点的所有字段都是构造完成后才发布的所以线程 B 只要看到了这个节点就一定能看到它内部完整的键值信息不需要额外加锁。这里有个很关键的细节ConcurrentDictionary中的键值对一旦被写入整体发布是不可分割的。它不会出现别的线程看到 Key 但看不到 Value这种半初始化状态。这一点在设计业务时很有用——你可以在写入前准备好一个不可变对象然后一次性发布到字典里读线程拿到的永远是完整快照。我用一个简化版的代码来说明读路径的基本思路// 简化版仅用于说明原理 internal volatile Node[] _tables; public bool TryGetValue(TKey key, out TValue value) { ref Node? bucket ref GetBucket(key); Node? node Volatile.Read(ref bucket); while (node ! null) { if (EqualityComparerTKey.Default.Equals(node.Key, key)) { value node.Value; return true; } node node.Next; // Next 字段也是 volatile } value default!; return false; }实际源码比这复杂它会处理哈希碰撞、扩容搬迁、删除打标记等场景但基本骨架就是无锁遍历。理解了这个读路径你就知道为什么并发读场景下它比Dictionary 全局锁优秀得多。2.3 写路径的原子更新机制写操作的核心是 CASCompare-And-Swap。当你要插入一个新 Key 时先找到目标桶然后把新节点的 Next 指向当前链头再用 CAS 操作把桶引用从当前链头更新为新节点。如果 CAS 失败说明有别的线程抢先改了桶引用需要重试。这就是典型的乐观并发策略——绝大多数情况下不会冲突冲突时重试一次就好开销远小于直接上锁。对于已有 Key 的更新ConcurrentDictionary会尝试把节点里的 Value 字段做原子替换。如果 Value 是引用类型赋值操作本身是原子的如果是值类型且大小超过指针宽度则不一定保证原子。官方源码里对非原子值类型做了特殊处理必要时会上锁。从使用者的角度我们只需要知道对引用类型 Value 的原子更新是有保证的但复合值类型还是要谨慎处理。我经常把这种机制类比成快递柜换货柜子号不变里面放的包裹被整体换掉取件人不会看到半新半旧的包裹。这也是为什么我会在设计并发缓存时强烈建议把 Value 设计成不可变对象——你把更新变成整体换包而不是拆包改内容并发问题就少了一大半。3. 关键 API 的使用要点与高频踩坑3.1 GetOrAdd 的工厂委托陷阱GetOrAdd大概是ConcurrentDictionary里最常用的 API签名是GetOrAdd(key, valueFactory)。很多人以为有 Key 就返回旧值没 Key 才执行工厂方法对但不完全对。官方实现里工厂方法的执行并不是在锁内完成的它只保证最终只有一个结果被写入字典。也就是说在并发场景下同一个 Key 的valueFactory可能被多个线程同时执行多次只不过只有其中一个结果会被采纳并返回给所有人。这就有个大坑如果你的工厂方法有副作用比如执行一次网络请求、写一次日志、扣一次积分那它会重复执行。我在生产里见过有人用GetOrAdd做 access_token 缓存刷新结果高并发下瞬间打到认证服务器上几十个重复请求直接把上游打挂了。正确做法有两种一是把工厂方法设计成纯函数从外部传入的参数计算出一个结果不带任何副作用二是如果实在有副作用必须在工厂内部再用锁或信号量做一次去重也就是双检锁模式。再看返回值并发争抢时获胜线程的工厂执行完会把结果发布到字典所有等待线程拿到的是同一个结果。从使用者的角度这实际上是虚拟单飞模式理解这一点能帮你避免很多思维误区。我之前见过一个团队把耗时的 DB 查询放在工厂里结果高峰时期一堆线程都卡在同一个 Key 的查询上线程池被拖垮。这种情况更合适的做法是LazyT后面我会给出完整代码。3.2 TryUpdate 与条件更新的双检语义TryUpdate(key, newValue, comparisonValue)这个 API 很实用它实现的是 CAS 语义只有当字典中该 Key 的当前值等于comparisonValue时才把它替换为newValue并返回 true否则什么都不做返回 false。多线程环境下如果你想实现a b a这类复合操作靠TryUpdate链式重试是标准姿势。int oldValue; int newValue; do { oldValue dict.GetOrAdd(key, 0); newValue oldValue delta; } while (!dict.TryUpdate(key, newValue, oldValue));这段代码的意思是先拿到当前值计算出新值然后用TryUpdate尝试原子替换。如果别的线程在我计算期间先改了 ValueTryUpdate会返回 false我就重新读、重新算、再试。因为绝大多数情况下没有竞争这个循环平均只会执行一次比在外部包一大把锁要高效得多。但要注意如果竞争非常激烈某些线程可能陷入较长重试必要的时候可以加入最大重试次数兜底。我在写库存扣减的时候特别推荐这个模式因为它天然支持乐观并发不锁整表只在提交前校验版本。类似的思路在Entity Framework的乐观并发控制里也能看到只不过ConcurrentDictionary把刷新语义直接集成在了 API 里。3.3 Keys 和 Values 的快照本质Keys属性、Values属性以及Count属性在ConcurrentDictionary里的代价都比普通Dictionary高。官方实现的Keys/Values会返回一个快照集合它的生成过程本质上是在枚举当前所有条目并把键或值复制一份而不是用迭代器实时遍历。在项数量很大的字典上频繁调用Keys会导致明显的 CPU 和内存开销。如果你只是想判断某个键是否存在用ContainsKey或TryGetValue不要用Keys.Contains。Count也不是完全免费的。新版本实现里它需要加总全局计数和各个线程的本地计数CPU 核数越多、操作越频繁开销越明显老版本 Framework 里Count甚至要遍历所有段百万级条目下调用一次能吃掉几百微秒。官方提供了IsEmpty属性用于快速判断空表它在无锁读路径上做了优化比Count 0要快得多。根据我在 .NET 8 下的基准测试百万级字典上调用一次Count大约耗时几十微秒而IsEmpty只需要几纳秒。差距非常大能用IsEmpty就别用Count。4. 实战场景高并发计数、缓存与动态配置4.1 用 TryUpdate 实现高并发计数器最常见的业务场景是统计接口调用量、错误次数、在线用户数。很多人第一反应是直接写AddOrUpdate但要注意AddOrUpdate的更新委托和GetOrAdd一样也不是原子执行的——它只是保证最终写入这个动作是原子的。如果你的逻辑是旧值加一可以有两种写法一种是用TryUpdate重试循环另一种是用AddOrUpdate但接受计数器可能不精确。这里我推荐一种折中方案对于累加固定值这种最简单场景直接构造一个新对象然后整体替换 Value让 Value 本身不可变这样就能避免复合更新问题。但如果 Value 就是intTryUpdate循环反而是最准确且直观的。我把三种典型写法的优劣整理成一张表写法原子性准确性性能TryUpdate 重试循环精确 CAS高中AddOrUpdate 委托最终一致可能丢更新高整体替换不可变对象最终一致高高实际项目中如果计数器只增不减且不要求瞬时精确我会优先选整体替换不可变对象如果要求严格的精确计数我用TryUpdate循环并加上限次重试。比如限流器里统计滑动窗口内的请求数精确权威值很重要我会用后者如果是监控大盘上的近似 QPS用前者完全够用还省 CPU。4.2 用 GetOrAdd Lazy 实现单飞缓存缓存场景里有个常见需求每个 Key 只初始化一次但多个线程可能同时触发初始化。如果不做额外处理直接用GetOrAdd就会遇到前面说的工厂重复执行问题。标准解法是让 Value 本身变成LazyT把LazyT的初始化模式设为ExecutionAndPublication这样多个线程虽然都会创建Lazy实例但只有第一个访问实例.Value的线程真正执行初始化逻辑其他线程在.Value上会等待同一个结果。ConcurrentDictionarystring, LazyIMyService _cache new(); public IMyService GetService(string name) { return _cache.GetOrAdd(name, n new LazyIMyService(() CreateService(n))) .Value; }这里有个细节LazyT的默认线程安全模式就是ExecutionAndPublication但我仍然建议显式传参避免阅读代码的人误解。另外如果CreateService可能抛异常Lazy默认会缓存异常下一次访问.Value还会继续抛同一个异常。如果你的初始化是临时网络故障希望下次重试这就需要在Lazy里做异常包装或者直接捕获异常后移除缓存项否则一个坏连接能卡住整个 Key 的恢复。4.3 动态配置中心的不可变发布实践我做过一个轻量级动态配置中心核心就是ConcurrentDictionary存配置项配合监听线程做定期拉取。配置项的值是一个不可变对象每次更新时整体替换 Value这样读取配置的线程永远能拿到一致的快照不需要加锁。这里的关键点是Value 要用不可变类型或者至少是发布后不再修改的 readonly 对象。我见过有人把配置对象做成可变类更新时直接拿引用改字段结果半个系统读到的配置一会儿是新值一会儿是旧值排查了整整一天才发现问题。遵循不可变发布原则后这类并发问题基本绝迹。还有一个附加好处因为读路径无锁配置变化频率即使很高也不会影响系统的主链路延迟最多只是让读到旧值的线程晚一个周期拿到新值业务上完全可接受。5. 性能调优与内存细节5.1 初始容量与并发级别的设置ConcurrentDictionary构造函数支持concurrencyLevel和capacity两个参数。concurrencyLevel表示预计会同时写入的线程数这个值会影响内部锁分组的粒度官方建议设置为处理器核心数的倍数但实际影响在多数场景下并不明显。capacity是初始桶数如果预估数据量很大提前设置可以避免多次扩容因为扩容是全局操作成本较高。实测中发现把capacity设为预估条目数 / 填充因子比较合理比如预估 10 万条数据初始容量可以设到 13 万左右。.NET 8 里对容量做了更好的处理设置小了也不会像以前那样频繁触发 Resize但提前规划仍是好习惯。如果你想精确控制还可以先做一轮压测观察Capacity增长曲线再回填参数比拍脑袋靠谱得多。5.2 读多写少场景下的吞吐表现我用一个简单的压力测试对比过ConcurrentDictionary、Dictionarylock和ReaderWriterLockSlimDictionary三种方案。测试环境是一台 8 核 Linux 虚拟机线程数从 1 到 32比例是 95% 读、5% 写。结果显示在低并发1 到 4 线程时三者差距不大Dictionarylock甚至微微占优到 8 线程以上ConcurrentDictionary的优势显现吞吐量是Dictionarylock的 3 到 5 倍到 32 线程时ReaderWriterLockSlim方案由于写锁的全局性表现最差。这个结果其实说明了关键点ConcurrentDictionary不是所有情况下的最优解它是为高并发读写混合设计的。如果你的系统读占 99% 且几乎不写用ImmutableDictionary或Dictionary copy-on-write 反而更讨好。选型时要看业务曲线的真实分布别被并发容器 快这个口号误导。5.3 内存占用与 GC 表现ConcurrentDictionary的节点包含 Key、Value、Next 指针内存占用比普通Dictionary高不少这是为了无锁遍历付出的代价。在长期运行的进程中如果频繁增删节点会被反复创建和回收需要关注 GC 压力。.NET 8 在这一点上做了优化内部支持数组池和更紧凑的节点分配但老版本 .NET Framework 里大并发下ConcurrentDictionary在 GC 上浪费的时间可以达到整体 CPU 的 15% 以上。如果你的场景是不断加入新 Key旧 Key 偶尔清理注意别让字典无限增长。合理的做法是定期用快照方式清理过期条目或者在写入时记录时间戳用后台任务按批清理。我自己常用的一个技巧是在 Value 里放一个DateTime LastSeen字段后台线程每五分钟扫一次快照删除超过生命周期的条目。虽然扫描快照本身有成本但比让字典变成内存炸弹要划算得多。6. 常见问题与排查技巧6.1 Count、IsEmpty 和索引器的误用我上面提到Count不是零成本的这里再补充一个真实案例。有一次线上服务为了上报监控每 10 秒调用一次dict.Keys.Count结果发现监控接口的 P99 延迟从 40ms 飙到 500ms。排查后发现字典里累积了 800 万条记录每次Keys都要把所有键拷贝成一个新集合CPU 直接被拉满。换成IsEmpty或自己维护的原子计数器之后问题消失。还有TryGetValue的返回值问题。很多人喜欢用索引器写法var value dict[key]这在高并发下可能抛KeyNotFoundException因为另一个线程可能刚好移除了该 Key。正确姿势永远是TryGetValue或GetValueOrDefault.NET 5。索引器只适合我确定键存在且不会被移除的只读场景。6.2 序列化与跨线程一致性ConcurrentDictionary在 .NET Core 3.0 以后也支持System.Text.Json序列化但要注意的是序列化过程中如果并发写入得到的 JSON 是不能保证一致快照的。如果必须要快照请先拿到 Keys 快照再逐项读取或者干脆在序列化前加一把业务锁。我在项目中就遇到过一次字典里同时写入了 100 条配置序列化出去却只有 98 条原因就是序列化遍历过程中有条目被移除了。虽然概率低但线上偶发问题往往就是这种角落逻辑引起的。更稳妥的做法是维护一个专门的快照属性比如每次配置更新后主动生成一份不可变的ImmutableDictionary用于序列化。读线程和序列化线程都只读快照写线程更新主字典时同时刷新快照引用。这个模式牺牲了一点写性能换来的是读取和序列化的绝对一致性在配置中心这类场景下非常实用。6.3 更新委托里的嵌套锁ConcurrentDictionary自身的锁是细粒度的不会互相持有形成循环等待但你在它外围加上业务锁以后局面就不同了。比如线程 A 持有了字典的某个桶锁但同时去获取另一个普通lock线程 B 持有那个普通lock又去写这个字典——死锁就可能悄悄出现。我的建议是永远不要在ConcurrentDictionary的更新委托updateValueFactory、valueFactory里去请求外部锁或者做任何可能阻塞的操作。委托是在字典内部锁的环境下执行的一旦在里面睡过去整个桶的并发能力就废了。另外还有个细节不要在valueFactory里面访问同一个字典的同一个 Key。这不一定会死锁但很容易造成逻辑混乱或栈溢出。总之委托保持纯净把复杂的业务逻辑放在外面只让它做根据旧值算新值这一件事。7. 最后说点个人的实战体会我在相当多项目里把ConcurrentDictionary当作并发基础设施来用但真正让我受益的不是它有多快而是不可变发布 原子替换这一整套思维。用好了它你会自然养成一个习惯写到共享容器的对象尽量是不可变的读的时候不去假设对象会原地修改。这个习惯带到了数据库、缓存、消息队列的设计里都是通用的。如果你刚开始接触并发编程建议先拿它练手把TryUpdate循环、GetOrAddLazy这些模式写熟再考虑去碰更底层的锁和无锁结构。踩过几次坑之后你会发现绝大多数并发问题都不是靠堆高级 API 解决的而是靠想清楚数据什么时候发布、什么时候替换、读线程能看到什么这三件事。ConcurrentDictionary只是帮你把这三件事压缩成了几个简单可靠的 API想明白这一点你才算真正掌握它。