在一次线上接口性能异常的排查中,我们遇到了一个非常典型但又常被忽视的问题 ——
在多线程任务中并发操作 Dictionary,导致 CPU 飙升并触发 Dictionary.FindEntry 的热点。
本文将完整复现问题背景、分析原因,并给出最终可落地的解决方案,帮助你避免类似的踩坑。
🧩 一、问题背景
线上某接口突然出现大量 CPU 占用过高的告警,通过 dump 分析后,发现大量线程卡在:
System.Collections.Generic.Dictionary.FindEntry
如下图所示(简化后):
Dictionary<TKey, TValue>.FindEntry
OrderMainService.QueryPrice
UnifiedPriceService.GetUnifiedPrice
Task.Run(...)
...
进一步追踪代码,发现在一个方法内创建了多个 Task.Run,并在任务中同时对同一个字典 unifiedPriceMap 进行 Add、ContainsKey、索引访问 等操作:
var unifiedPriceMap = new Dictionary<SupplierTypeEnum, T3EstimatePriceResultDtoModel>();
taskList.Add(Task.Run(() =>
{
unifiedPriceMap.Add(SupplierTypeEnum.XieHua, model);
}));
taskList.Add(Task.Run(() =>
{
unifiedPriceMap.Add(SupplierTypeEnum.JuZi, model);
}));
看似简单,却埋下了灾难的种子。
🔥 二、问题分析:并发写 Dictionary 会导致结构损坏
.NET 中 Dictionary<TKey, TValue> 不是线程安全的。
只要有多个线程同时向同一个 Dictionary 写入,就会出现:
- buckets 与 entries 同时被多个线程修改
- entry 链表被截断
- next 索引形成闭环
- FindEntry 死循环
- Dictionary 内部结构损坏
- CPU 迅速飙升
其中 FindEntry 高 CPU 正是最典型的表现。
也就是 dump 中看到的这个热点:
Dictionary<TKey, TValue>.FindEntry
✔ 为什么读写会冲突?
为了添加新元素,Dictionary 会:
- 计算 hash
- 修改 bucket
- 修改 entry 数组
- 修改 entry.next
- 扩容时对整个结构整体重排
在多线程写入时,同时执行上述步骤,非常容易造成:
- bucket 指针错链
- next 指针循环
- entries 覆盖
- 甚至内部 Resize 时数组损坏
最终导致 CPU 占用不断攀升。
这正是你观察到的现象。
🛠 三、解决方案
根据实际情况,可以用三种方式解决该问题。
✅ 方案一:使用 ConcurrentDictionary(最简单、最安全)
最推荐的方案,只需一行代码即可修复所有并发问题:
var unifiedPriceMap = new ConcurrentDictionary<SupplierTypeEnum, T3EstimatePriceResultDtoModel>();
写入方式:
unifiedPriceMap[SupplierTypeEnum.XieHua] = model;
优点:
- 原生线程安全
- 无需加锁
- 性能表现稳定
- 完全规避 Dictionary 结构损坏问题
这是最通用、最易落地的方案。
✅ 方案二:加锁保护 Dictionary(性能更高)
如果你的字典 Key 很少(如供应商就几个),加锁反而更高效:
var locker = new object();
var unifiedPriceMap = new Dictionary<SupplierTypeEnum, T3EstimatePriceResultDtoModel>();
taskList.Add(Task.Run(() =>
{
var value = service.GetUnifiedPrice(...);
lock(locker)
{
unifiedPriceMap[SupplierTypeEnum.XieHua] = value;
}
}));
优点:
- Dictionary 性能极高
- 加锁范围很小(只包含写入)
缺点:
- 比 ConcurrentDictionary 稍微麻烦一些
❌ 方案三(不推荐):每个 Task 使用局部变量,最后合并
可行,但代码复杂,不够优雅。
🎯 四、经验总结
1. Dictionary 只能在单线程下写、并发读
只要多线程写,100% 会出问题,迟早都崩。
2. ConcurrentDictionary 是并发写字典的标准解决方案
现代 .NET 并发场景下,应优先使用它。
3. 如果写入频次不高,用 lock 更快
对小数据量、固定 Key 来说,加锁的性能甚至比 ConcurrentDictionary 更高。
4. FindEntry 热点是字典结构损坏的第一现场
只要 dump 看到 Dictionary.FindEntry 高 CPU,基本可以断定是并发写 Dictionary。
🧭 五、结语
这次问题看似简单,但却是 .NET 项目中非常高频、又极易被忽略的典型并发 bug。
并发写 Dictionary = 不定时炸弹
只要做到:
- 并发写 → 用 ConcurrentDictionary 或 lock
- 单线程写、多线程读 → 用 Dictionary
你就能完全规避这类性能事故。
