07-01-并发-ConcurrentDictionary-TKey-TValue-无锁读取与分锁写入

发布时间:2026/9/3 16:19:34
07-01-并发-ConcurrentDictionary-TKey-TValue-无锁读取与分锁写入 ConcurrentDictionaryTKey, TValue无锁读取与分锁写入系列C# 与常用数据结构源码剖析 · 并发集合篇阅读时间约 80 分钟源码基线.NET 8.0.0dotnet/runtime的System.Collections.Concurrent/ConcurrentDictionary.cs版本边界本文解释该 tag 的核心结构。锁数量、快速取模、字符串碰撞防御和增长策略是实现细节其他 .NET、Mono 与 Unity 版本必须重新核验。代码约定所有代码均明确标为结构化节选、教学伪代码或业务示例不冒充逐字运行时源码。一、线程安全不等于“整张表无锁”普通DictionaryTKey,TValue在并发写入下没有安全保证。最直接的修复是让所有访问共享一把锁这很容易证明正确也常常足够// 业务示例锁必须覆盖所有访问和完整复合操作。 lock (gate) { if (!map.TryGetValue(key, out Value value)) { value CreateValue(key); map.Add(key, value); } Use(value); }问题不在lock天生慢而在单锁把互不相关的读写也排成一列并且调用方容易漏锁。ConcurrentDictionary采用更细的协议查找路径通常不获取互斥锁写入只锁某一组桶扩容等全局操作按固定顺序取得所有锁发布新表后已经拿到旧表引用的读者仍可安全完成读取。因此准确标题应是“无锁读取、分锁写入”而不是“无锁字典”。TryAdd、TryRemove、TryUpdate和索引器写入会加锁Count、ToArray等需要一致全局观察的操作也可能获取全部锁。并发集合的价值是内置一套可证明的同步协议不是消灭同步成本。二、Tables、桶、节点、锁和计数2.1 一次读取先固定 Tables 世代.NET 8基线把一组相互匹配的数组与比较器封装在Tables中字典用 volatile 字段指向当前世代// 结构化节选字段名对应 .NET 8.0.0省略其他状态。 private volatile Tables _tables; private int _budget; private readonly bool _growLockArray; private sealed class Tables { internal readonly IEqualityComparerTKey? _comparer; internal readonly VolatileNode[] _buckets; internal readonly object[] _locks; internal readonly int[] _countPerLock; } private struct VolatileNode { internal volatile Node? _node; }readonly表示数组引用不会在同一 Tables 内换成另一数组不表示数组元素永远不变。桶头、链表、每锁计数都由并发协议更新。把 tables 引用读到局部变量后后续的桶数、锁数组、计数数组和比较器必须都来自这个局部快照不能一半用旧表、一半重新读取新表。VolatileNode[]不是业务数据层它让每个桶头拥有明确的 volatile 访问语义同时避免在某些架构/JIT 组合下因获取数组元素引用而丢失 volatile 意图。具体缘由与代码形态属于版本实现应结合该 tag 注释阅读。2.2 Node 是不可变骨架加受控可变值// 结构化节选不含构造函数。 private sealed class Node { internal readonly TKey _key; internal TValue _value; internal volatile Node? _next; internal readonly int _hashcode; }键和缓存哈希在节点建立后不变_next构成桶内单向链。新节点通常接到链头并通过 volatile 桶头发布。读者先取得已发布头节点再沿 volatile next 前进因此能观察到构造完成的节点图。值字段不能简单声明为volatile TValueC# 不允许任意泛型 T 成为 volatile 字段而且某些值类型读写并非硬件和 CLI 保证的单次原子操作。.NET 8在更新已有键时区分两类情况若 TValue 的写入可安全原子完成就在相应锁内更新节点值否则创建包含新值的 Node在锁内把链前驱或桶头改指向新节点。无锁读者看到旧节点或新节点都合法却不会看到大结构体的一半旧位、一半新位。这也是为什么“大 TValue”不仅影响复制量还可能改变更新路径并增加节点分配。不能把不同架构、不同 TValue 的表现归结成固定倍数。2.3 桶怎样映射到锁哈希先映射为 bucket 编号再由 bucket 编号映射为 lock 编号。概念公式是bucketNo normalizedHash mod bucketCount lockNo bucketNo mod lockCount基线可使用针对当前平台的快速取模辅助方法所以这不是逐条机器指令承诺。一个锁负责多个桶同锁下的写入互斥不同锁下的写入有机会并行。碰巧散列到不同桶却属于同一锁的两个键仍会竞争。默认锁数不应写死为“CPU 数乘四”。构造函数的concurrencyLevel、默认并发度和是否允许锁数组增长共同决定初始与后续锁数。.NET 8的自动增长有上限显式指定并发级别的实例可能采用不同增长行为。锁越多并非总越好全局操作要获取更多锁锁与计数数组也占空间。_countPerLock[i]记录第 i 把锁覆盖区域的元素数在对应锁保护下更新。总 Count 需要汇总多个计数若要求一个一致观察就必须协调这些锁而不是读取一个始终精确的无锁全局整数。三、内存模型发布的是一个合法历史不是“永远最新”并发算法必须同时回答互斥和可见性。锁保护同一组写者volatile 桶头/next 与_tables发布则让无锁读者看到构造完成的对象关系。扩容建立新 Tables迁移元素并完成内部数组后最后发布_tables。之后的新操作可使用新世代已持有旧 Tables 的读者继续在旧世代上查找。这不承诺无锁读在物理时间上得到“最新一次写”。若读与写并发且没有额外 happens-before 关系读可能在线性化顺序上位于写前或写后两种结果都可能是合法历史。ConcurrentDictionary保证单个公开操作的线程安全与规定的原子语义不提供跨多个调用的事务快照。无锁也不等于 wait-free。查找会遍历碰撞链线程可能被操作系统调度暂停比较器也可能执行很久。这里只是正常查找路径不主动获取字典的互斥锁。另一个边界是值对象内部。字典能原子地发布或替换 TValue 引用不会自动保护该对象的可变字段// 反例字典安全不等于 Stats 的两个字段形成原子快照。 if (players.TryGetValue(id, out PlayerStats stats)) { stats.Wins; stats.Losses; }若不变量要求两个字段一起变化应让 Value 不可变并用TryUpdate替换整个对象或给 Value 自己增加同步协议。四、TryGetValue无锁读取的准确边界查找先验证 key读取当前 Tables计算哈希和桶号再从 volatile 桶头沿链比较缓存哈希及键// 教学伪代码省略 null、比较器专用路径和快速取模细节。 bool TryGetValueCore(TKey key, out TValue value) { Tables tables _tables; // volatile 获取当前世代 int hash GetHashCode(tables._comparer, key); int bucket GetBucket(hash, tables._buckets.Length); for (Node? node ReadBucket(tables, bucket); node is not null; node node._next) { if (node._hashcode hash KeysEqual(tables, node._key, key)) { value node._value; return true; } } value default!; return false; }这里没有统一写成Volatile.Read(ref node._value)因为泛型 TValue 不满足该调用适用于任意类型的假设。安全性来自发布协议以及非原子值更新时替换 Node。只读方法的期望复杂度为 O(1)最坏取决于碰撞链长度和比较器成本。哈希良好时链短恶意或错误比较器让大量键聚到同桶时退化。并发不会修复低质量哈希。ContainsKey本质上也完成查找。业务代码不要先 Contains 再读索引器键可能在两次调用之间被删除。需要值时一次TryGetValue同时返回存在性和值。五、TryAdd、TryRemove 与 TryUpdate分锁写的循环写操作不能只读取一次 Tables 后永久使用。等待桶锁期间其他线程可能已经扩容并发布新表。因此内部写入通常在循环中执行计算目标锁获得该 Tables 对应的锁然后检查_tables是否仍是同一个对象若不是释放锁并按新表重算哈希/桶/锁。读取 tables 和 comparer计算 hash 循环 用 tables 计算 bucketNo 与 lockNo 获取 tables._locks[lockNo] 再检查 tables 是否仍为当前 _tables 不同退出锁换新 tables 后重试 在锁内扫描目标链 执行插入、条件更新或删除 更新 countPerLock 退出锁 必要时在锁外触发 GrowTableTryAdd(key,value)仅在比较等价键不存在时插入并返回是否成功。TryRemove(key,out value)只在存在时摘链并返回移除值。带KeyValuePair或内部条件的删除路径还可以要求值匹配具体公开重载应按目标框架查看。TryUpdate(key,newValue,comparisonValue)相当于并发字典上的 compare-and-swap 语义只有当前值按EqualityComparerTValue.Default等于 comparisonValue 时才更新。它适合构造重试循环先读旧不可变值计算新值再尝试替换失败说明别人抢先更新应重新读取。索引器dictionary[key] value是单次原子添加或替换但“读取、业务计算、写回”整体不是原子操作。计数器优先使用AddOrUpdate的返回结果、不可变 Value 配合 TryUpdate或在 Value 内使用 Interlocked选择取决于副作用和冲突率。六、GetOrAdd 与 AddOrUpdate原子提交不包括用户委托GetOrAdd(key, factory)的字典提交是原子的最终只有一个值与该 key 关联。但 factory 为避免在内部锁下执行任意用户代码通常在锁外运行。多个线程同时未命中时可以各自调用 factory只有一个候选值被提交其余候选被丢弃。// 反例factory 可能执行多次不能直接放不可重复副作用。 Session session sessions.GetOrAdd(userId, id { ChargeAccount(id); // 危险可能重复扣款 return OpenSession(id); });常见惰性初始化模式是把竞争对象变成LazyTvar cache new ConcurrentDictionarystring, LazyResource(); Resource resource cache.GetOrAdd( key, static k new LazyResource( () LoadResource(k), LazyThreadSafetyMode.ExecutionAndPublication)).Value;多个Lazy包装仍可能被创建但只有字典中获胜包装的.Value被当前返回路径使用其 Lazy 保证自己的初始化协议。还要决定异常是否缓存、失败项是否移除以及 Resource 如何释放这个模式不是万能所有权方案。AddOrUpdate的 add factory 与 update factory 同样可能执行多次。流程可以反复“读取旧值—锁外计算候选—锁内条件提交”竞争导致旧值变化时委托可能再次调用。因此委托必须尽量纯、可重试、快速不可把发送邮件、扣库存或发放奖励等 exactly-once 副作用直接放进去。“委托在原子操作内部”也是错误表述。原子的是键值关联的最终检查与提交不是用户代码执行。若业务需要跨字典、多键或外部数据库的事务必须在更高层建立锁、事务、幂等键或消息协议。七、扩容、预算与锁增长每把锁关联计数超过预算时写路径可请求GrowTable。触发请求不一定立即扩大桶数组若整体负载并不高可能是哈希分布差导致某把锁过载基线会调整预算以避免反复做无效扩容。具体阈值属于实现细节。真正换表时某个线程先取得第 0 把锁确认传入 Tables 仍是当前表再按固定顺序取得其余锁。只有掌握所有写锁才能稳定迁移节点与计数。随后创建更大的桶数组若实例允许也可能增长锁数组基线设有上限。所有元素按目标比较器与新桶数重新安置构造新 Tables最后发布_tables。写者 W锁定旧表全部锁 - 构建新表 - 发布新 Tables - 释放锁 旧读者 R1已持有旧 Tables安全完成旧桶链遍历 新读者 R2取得新 Tables从新桶链查找 等待写者 W2获得旧锁后发现 Tables 已变退出并在新表重试扩容期间无锁读通常不等待字典锁但占用 CPU、内存带宽和 GC 资源仍会相互影响不能宣称“扩容对读完全无影响”。写者可能等待全局换表扩容也会产生新数组和新节点/链结构。字符串键的碰撞防御在某些基线路径中会从非随机化字符串比较器切换到具有随机化特性的默认比较方式并要求重新散列。这个机制依赖键类型、比较器种类和版本不能推广为任意自定义 TKey 都自动免疫哈希攻击。构造时若能合理估计容量可减少早期扩容。容量不是并发级别前者影响桶规模后者影响锁分区。盲目把两者都设得极大会提高常驻内存和全锁操作成本。八、枚举、Count、Keys 和 ToArray 的快照边界ConcurrentDictionary的枚举器可与并发读写共同使用不会像普通 Dictionary 那样仅因版本变化就立即失败。但它不表示某一瞬间的完整快照并发添加可能出现或不出现并发删除的项也可能仍被观察到。它保证的是安全遍历协议而不是事务视图。foreach (KeyValuePairTKey, TValue pair in dictionary) { // 不要据此断言 pair 集合等于循环开始或结束瞬间的全部内容。 }.NET 8基线的ToArray()会获取全部锁并复制一致内容适合确实需要点-in-time 键值数组的场景但会阻塞写者并分配 O(n) 数组。Keys、Values等集合获取也可能为了一致复制而取得全部锁。Count汇总分锁计数需要全局协调不要在高频热循环中反复调用它只为轮询状态。即使有一个字典快照也只冻结键与 TValue 值/引用不会深拷贝 Value 指向的可变对象。需要不可变业务快照时应采用不可变 Value、复制对象图或领域专用快照结构。IsEmpty可有不同于 Count 的优化路径但它仍只是单次观察。if (!dict.IsEmpty) dict.First()不是复合原子操作下一步观察可能不同。并发 API 的每次调用边界必须明确。九、复合操作单方法原子不等于业务事务9.1 错误的检查后执行// 反例两个线程都可能看到不存在并各自执行副作用。 if (!rewards.ContainsKey(playerId)) { GrantReward(playerId); rewards[playerId] reward; }改成TryAdd只能决定谁赢得字典键副作用必须放在赢得之后并处理 Grant 失败后的状态if (rewards.TryAdd(playerId, RewardState.Reserved)) { try { GrantReward(playerId); rewards[playerId] RewardState.Completed; } catch { // .NET 8ICollection.Remove 仅在键和值仍同时匹配时删除。 ((ICollectionKeyValuePairint, RewardState)rewards).Remove( new KeyValuePairint, RewardState(playerId, RewardState.Reserved)); throw; } }这仍不是持久化 exactly-once 协议进程可能在外部副作用完成后、状态更新前崩溃。涉及金钱和奖励应使用数据库唯一约束、幂等事务或可靠消息。9.2 多键不变量“从 A 余额减 10同时给 B 加 10”无法用两个AddOrUpdate组成原子转账中间状态对其他线程可见第二步也可能失败。需要外部锁定义锁顺序、不可变总状态上的单次 CAS或数据库事务。ConcurrentDictionary 不提供跨键事务。9.3 条件删除的 ABA 类问题先TryGetValue得到旧引用再无条件TryRemove(key)可能删除别人刚替换的新值。需要“仅当键仍映射到这个旧值才删除”的条件接口路径在.NET 8可使用 ConcurrentDictionary 实现的ICollectionKeyValuePairTKey,TValue.Remove或设计显式重试协议。比较策略也必须符合领域身份否则值相等不一定代表同一代资源。十、哈希、可变键与拒绝服务边界键放入后参与GetHashCode和Equals的状态必须保持不变。可变键会留在旧桶后续 Contains、更新或删除可能找不到它。并发环境只会让症状更难复现。比较器应满足等价关系契约相等键必须产生相同哈希Equals 应自反、对称、传递且稳定。哈希分布差会增加链长、锁冲突和比较次数。来自不可信输入的键还要考虑碰撞拒绝服务优先使用运行时提供的合适字符串比较器避免暴露攻击者可控制的恒定/低熵自定义哈希。对组合键可使用不可变record struct或显式实现并确保哈希包含与 Equals 相同的字段。大结构体键会在哈希、比较和传参时复制并发字典不是自动解决数据布局成本的工具。十一、GC、大值和 false sharing只下可验证的结论每个条目通常有独立 Node对引用类型键值还会指向其他对象。扩容创建新桶数组并可能为迁移/更新产生节点旧 Tables 与节点要等仍使用旧世代的读者不再可达后才能回收。高更新率、大容量和大值可能形成显著分配与存活压力。非原子可写 TValue 更新采用节点替换意味着频繁更新大结构体可能增加分配同时每次返回 TValue 也可能复制它。通常更适合保存不可变小值、稳定引用或把高频计数放进专用并发对象但可变引用内部必须另行同步。_countPerLock的相邻元素可能被不同写线程更新从缓存一致性角度存在 false sharing 的可能锁对象、Node 和数组的实际布局又受运行时分配器与硬件影响。没有硬件计数器和受控实验不应断言它是某个业务的瓶颈更不能写固定损耗倍数。先用 CPU profile、锁竞争事件、分配记录和规模变化实验建立证据。对象池也非直接答案。内部 Node 不对外开放反射池化会破坏运行时不变量。业务 Value 池化则必须解决并发所有权被移除的值可能仍被先前 TryGetValue 的调用者使用不能一移除就立刻归还池。十二、Unity、Mono 与 IL2CPPAPI 名相同不等于实现相同Unity 项目能否使用ConcurrentDictionary取决于 Unity 版本、Api Compatibility Level/目标框架、类库配置和目标平台。Unity 内置 Mono 的 BCL 实现不一定与.NET 8.0.0同一份源码因此本文的VolatileNode、锁增长和字符串比较器细节不能直接套用。IL2CPP 是 AOT 工具链不是“把 ConcurrentDictionary 变成无锁原生字典”。它编译项目所用类库实现并保留其锁与内存同步语义性能、泛型代码生成、线程原语和 GC 行为应在对应 Unity 版本与真机验证。WebGL 等目标还可能因线程能力、构建设置或浏览器隔离条件具有额外限制。游戏架构中跨线程共享字典并不总是最佳方案。后台线程可生成不可变结果通过并发队列交给主线程主线程独占普通 Dictionary这会减少共享可变状态。若确实需要资产缓存或会话索引的并发查找ConcurrentDictionary 才提供合适的单键协议。Unity 性能报告应记录编辑器/Player、Mono/IL2CPP、Development/Release、目标设备、线程数、键分布、读写比例和容量。编辑器一次测试不能代表 IL2CPP Player更不能引用脱离环境的“.NET 8 快若干倍”。十三、故障反例清单在GetOrAddfactory 中发送不可重复网络请求竞争时请求多次发出。用可变对象作键修改参与哈希的字段后无法移除。先 ContainsKey 再索引器读取并发删除导致第二步失败。认为枚举是开始时快照用它做精确结算而漏记并发项。在 AddOrUpdate 委托中修改外部 List重试导致重复追加。两次 AddOrUpdate 实现转账暴露资金凭空消失的中间状态。移除池化 Value 后立即复用而旧读者仍持有引用。自定义比较器把所有哈希返回 0导致长链与热点锁。把 Count 当无成本信号高频轮询制造全局协调压力。看到 API 在 Unity 编译通过就假定内部与.NET 8字段和性能一致。十四、可复现实验验证语义再测性能14.1 factory 多次执行用Barrier让多个任务同时调用同一键的GetOrAddfactory 中只做Interlocked.Increment(ref calls)并返回唯一候选编号。断言字典最终 Count 为 1、所有调用返回同一获胜值但 calls 允许大于 1。不要断言必然大于 1因为调度可能让后续任务直接命中通过在 factory 内加第二个 Barrier 可控制竞争窗口。14.2 大值不撕裂定义含多个字段且超过自然原子写宽度的不可变结构体写线程在两组整体一致的模式间更新同一键读线程反复 TryGetValue 并验证字段组合只能是完整模式之一。长时间运行记录运行时、架构和构建配置。这个实验验证公开可观察语义不要通过反射假定 Node 是否替换。14.3 枚举不是快照在受控 Barrier 后一个线程枚举另一个线程增删一组已知键。断言枚举不因正常并发修改而损坏或产生无效键值但不要断言结果等于开始/结束集合。然后对ToArray()做独立实验验证返回数组自身不会随后变化Value 若为可变引用另测其对象仍可改变。14.4 哈希分布与锁竞争准备正常哈希和故意恒定哈希两种 comparer保持键、线程、操作序列一致分别采集吞吐、CPU、锁竞争、链比较次数代理指标和分配。此实验展示分布影响不应发布成普遍倍数。攻击测试应在隔离环境进行不对生产服务施压。14.5 与 lock(Dictionary) 公平对照两种实现必须完成同样语义使用相同预生成操作轨迹与键分布不要把 Random、任务创建或日志时间混入被测区。分别测试只读稳定集、不同键写入、同一热点键更新、扩容和需要全局快照的场景。报告中位数与尾部、吞吐、分配、GC、锁等待并解释单锁方案是否允许锁外计算。正确性压力测试可维护操作日志再离线检查是否存在符合单键线性化的顺序。普通“跑一百万次没崩”只能发现部分故障不能证明并发算法正确。十五、选择与审查清单适合 ConcurrentDictionary 的场景通常具有多个线程共享、以单键查找/条件更新为主、允许弱一致枚举、比较器稳定。若整个状态总要在一把业务锁内成组修改普通 Dictionary 加同一把锁可能更简单若读多写少且按批发布可考虑不可变快照若只是线程间传递工作队列或 Channel 更贴切。审查时逐项确认key 和 comparer 在入表后是否稳定哈希是否具有合理分布factory/update delegate 是否纯、可重试且没有 exactly-once 副作用不变量是否只涉及一个键若跨键外层事务在哪里TValue 是不可变值、线程安全对象还是未经保护的可变对象枚举需要弱一致遍历还是严格快照是否误用 Count 做复合判断容量与并发级别是否来自测量而不是越大越好的猜测移除后的资源何时可以释放或归还池旧读者是否仍可能持有目标是 CoreCLR、Unity Mono 还是 IL2CPP具体版本和平台是否复测十六、总结并发正确性来自边界而不是类型名.NET 8的 ConcurrentDictionary 用 volatile Tables 世代把桶、锁、计数和比较器绑定在一起。读者固定一个世代后沿已安全发布的节点链无锁查找写者锁定桶所属分区并在取得锁后确认 Tables 未被扩容替换。全局换表需要协调所有写锁完成新表后一次发布旧读者仍可结束旧世代读取。TValue 更新还必须尊重原子写宽度可原子写的值可在锁内更新可能撕裂的大值则通过替换 Node 发布。GetOrAdd 与 AddOrUpdate 只保证最终单键提交用户委托在锁外且可能多次执行。枚举支持并发却不是时刻快照ToArray 等一致复制要付出全锁和分配成本。它解决的是单个字典操作与若干条件更新的线程安全不解决可变 Value 内部同步、跨键事务、外部副作用幂等、资源回收所有权或哈希攻击的全部问题。只有把源码版本、内存发布、原子边界和业务不变量同时写清楚“线程安全字典”才不是一句危险的口号。下一篇ConcurrentQueueTSegment 无锁 FIFO 设计