concurrentdictionary不是dictionary的线程安全平替,仅保证单方法原子性;组合操作须用tryadd、addorupdate等专用方法,否则存在竞态风险。

ConcurrentDictionary 不是 Dictionary 的线程安全平替,它只保证单个方法调用原子,组合操作(比如先查再写)必须换用 TryAdd、AddOrUpdate 等专用方法,否则照样出竞态 bug。
别用 ContainsKey + 赋值组合
这是最常写的“看起来合理但实际危险”的代码:if (!dict.ContainsKey("user123")) dict["user123"] = newUser();。两步之间存在竞态窗口:两个线程同时通过 ContainsKey 检查(都返回 false),然后都执行赋值,最终只有一个值留下,另一个被覆盖,还可能触发意外逻辑分支。
-
ContainsKey本身线程安全,但只要它后面跟着任何写操作,整个片段就不是原子的 - 正确做法是改用
TryAdd:它原子地完成“检查不存在 + 添加”,失败就说明键已存在 - 如果目标是“有则不管、无则新建”,
TryAdd最简洁;如果想“有则更新”,直接跳到下个副标题
AddOrUpdate 参数顺序错会导致旧值被无视
AddOrUpdate 的签名是 AddOrUpdate(TKey, Func<tkey tvalue>, Func<tkey tvalue>)</tkey></tkey>,即:
- 第二个参数是“新增工厂”:只接收
key,用于键不存在时生成新值 - 第三个参数是“更新函数”:接收
key和oldValue,用于键存在时计算新值 - ❌ 错误写法:
dict.AddOrUpdate("k", (k, v) => v * 2, _ => 42)—— 第二个参数签名不匹配,编译失败 - ✅ 正确写法:
dict.AddOrUpdate("k", _ => 42, (k, old) => old * 2)—— 不存在时设为 42,存在时翻倍 - 如果只是想“存在就覆盖”,用
dict["k"] = newValue更简洁,它本身线程安全
GetOrAdd 工厂函数可能重复执行,Lazy 是标准解法
GetOrAdd 保证最终只有一个值被存入,但不保证工厂函数只执行一次。多个线程同时发现 key 缺失,会各自触发一次工厂函数——HTTP 请求发三遍、数据库连接建三次,都是真实踩过的坑。
- 常见错误:
dict.GetOrAdd("cfg", _ => LoadConfigFromDb()) - 推荐写法:
dict.GetOrAdd("cfg", _ => new Lazy<config>(() => LoadConfigFromDb()))</config> - 后续使用时取
.Value,Lazy<t></t>内部已做线程安全初始化,只执行一次 - 不要在工厂函数里写副作用逻辑(如写日志、发通知),除非你明确接受可能重复执行
遍历时不能删元素,TryRemove + 预收集 key 是唯一安全路径
对 ConcurrentDictionary 做 foreach 是安全的,它返回的是某个时间点的快照;但只要在循环体内调用 TryRemove 或 Clear,运行时大概率抛 InvalidOperationException。
- ❌ 危险写法:
foreach (var kvp in dict) { if (ShouldRemove(kvp.Key)) dict.TryRemove(kvp.Key, out _); } - ✅ 安全写法:先收集待删 key,再批量删除:
var keysToRemove = dict.Keys.Where(ShouldRemove).ToList(); foreach (var k in keysToRemove) dict.TryRemove(k, out _); - 注意:
Keys属性返回的是快照,所以ToList()后再删是安全的
真正容易被忽略的点在于:所有“看似原子”的复合语义(比如“查一下再决定怎么写”)都不安全,必须交给 TryAdd、AddOrUpdate、GetOrAdd 这类专为此设计的方法;而这些方法内部的委托执行边界和并发行为,又得靠 Lazy<t></t> 或幂等设计来兜底。











