
在 Rust 的高并发场景里共享 HashMap 的锁选择从来不是小事。DashMap 是解决这类问题最常用的并发容器之一我去年帮同事排查一个性能问题时发现很多人的第一反应仍然是MutexHashMap或者刚升级到RwLockHashMap就觉得万事大吉。这篇文章我就从一个压测失败的案例讲起拆开 DashMap 的分片锁设计给出能直接落地的用法、压测对比和避坑经验适合正在写 Rust 并发服务、做共享缓存、或者被锁竞争折磨的开发者。我会尽量少说废话把原理、代码和实测结果都摆出来。1. 一把锁 vs 无数把小锁并发 HashMap 的性能真相1.1 Mutex 的串行真相大多数 Rust 项目的第一版并发字典都会长这样let map: ArcMutexHashMapString, Vecu8 Arc::new(Mutex::new(HashMap::new()));这个写法本身没问题问题在于它的并发能力等于零。Mutex保证同一时刻只有一个线程能碰到 HashMap不管你是想读还是想写。我接手过一个配置缓存服务16 个 worker 同时往一个MutexHashMap里塞配置项压测到 8 个线程时延迟就开始打嗝CPU 使用率不高但线程全部在锁上排队。这里有个很多人忽略的点读操作也要拿独占锁。HashMap 的get虽然只是查一下内存但为了安全和HashMap内部结构的可变性锁实现只能让所有访问串行化。在最坏情况下一次无竞争锁的加锁/解锁大约 20ns 左右看起来很小可一旦两个线程同时撞上这把锁内核调用和线程调度的开销会把这个数字放大几个数量级。低核数场景下这种问题表现不明显因为线程本来就在等 CPU 时间片。但到了 16 核、32 核的机器上其他线程完全空闲、唯一热点是锁的时候平均延迟和 p99 延迟就会快速拉开。我压测时有个习惯不能只看平均延迟要盯着 p99 和最终吞吐。MutexHashMap的典型特征就是吞吐卡在某个水平怎么都上不去再加线程只会让锁竞争更严重。1.2 RwLock 确实救场但没根治第一波优化通常是把Mutex换成RwLocklet map: ArcRwLockHashMapString, Vecu8 Arc::new(RwLock::new(HashMap::new()));RwLock允许多个读者同时进入临界区写者独占。如果你的场景是 95% 读、5% 写这把锁表现相当不错因为它把读路径的并发能力解放出来了。但如果你把读写比例拉到 50%:50%或者写操作本身就频繁问题就回来了写者与写者之间仍然互斥读者和写者之间也互相阻塞。还有个隐藏问题容易被忽视多数RwLock实现都有“写者优先”或“读者优先”的策略。读者很多的时候新到达的写请求可能长时间拿不到锁造成写延迟毛刺反过来写者持续涌入也会让读请求饥饿。不管哪种策略读写混合场景下全局写锁都会周期性把所有读并发清零。我见过一个服务读请求 p99 原本只有 200ms一旦后台批量任务频繁写配置p99 直接冲到 2 秒问题就出在写锁让所有读排队。1.3 分段锁让不同 key 的线程各回各家理解了全局锁的瓶颈DashMap 的设计思路就很好理解了不要把整个 HashMap 锁住把 map 切成很多“小 map”每个小 map 有自己的锁。一个 key 经过哈希计算后只会落到其中一个小 map 里。线程 A 操作 key1 时只需要锁小 map1线程 B 操作 key2 可以同时锁小 map2 并执行两者完全不冲突。打个比方全局锁相当于整层楼只有一个公共饮水机所有人得排队接水。分段锁就是每层放一个饮水机你按自己的房间号去对应楼层接水队伍自然变短了。理论上只要 key 分布均匀分片越多锁竞争就越低。这个思路听起来简单但实际实现里藏着很多细节分片数量怎么定、哈希值怎么映射到分片、每个分片怎么扩容、遍历时怎么保证一致性。这些就是 DashMap 的“深浅”所在接下来我一个个拆。2. 拆开 DashMap分片、哈希与读写锁的配合2.1 分片数量从哪来DashMap 的默认分片数是和机器核心数挂钩的不同版本略有差异通常是 CPU 核数或核数的二倍。这个默认值的设计逻辑是并发线程数一般不会超过核心数太多分片数跟着核心数走能让不同线程大概率落到不同分片上同时不过度浪费内存。分片数是“并行度”和“内存开销”的权衡。分片太少锁竞争还是存在分片太多每次查询都要多一次哈希定位遍历全 map 时也要串行访问大量分片内存还会被空桶吃掉。我实测过一台 4 核机器上把分片强行调到 512结果内存涨了快三倍遍历还慢了因为空分片也要一个个过。如果你有明确的线程规模可以在构造时按线程数去配置。比如服务常驻 32 个 worker那你把分片数设为 32 或 64 通常比默认值更贴合场景。具体 API 名称不同版本不完全一样用的时候查一下当前依赖版本的文档就行。我的建议是先跑默认值压测再用 perf 看锁竞争的百分比别上来就调分片。2.2 一次 get 请求的完整路径很多人好奇 DashMap 查一个 key 到底干了什么。简化后大概是四步对 key 做哈希拿到一个散列值用散列值定位到某一个分片索引获取该分片的读锁在分片内部的 HashMap 上执行查找返回带引用的结果。注意第 3 步多了一次锁操作。这意味着单线程或少线程场景下DashMap 不一定比普通 HashMap 快因为普通 HashMap 连锁都不用加而 DashMap 至少还要做一次分片定位和加锁。它的性能收益完全来自并发场景多个线程同时访问不同分片时并不相互等待。我见过一个反例某同事在只有一个线程更新配置的场景里也引入了 DashMap结果压测比原来的HashMap还慢了 10% 左右。所以引入 DashMap 前先确认你的问题是不是真的由锁竞争引起的。这是最容易被标题误导的地方“性能怪兽”四个字不等于所有场景都更快。2.3 扩容和内存开销没那么便宜的“空间换并行”HashMap 本身扩容时要做全量 rehashDashMap 也一样只不过它是按分片独立扩容的。某个分片内元素多了只扩这个分片其他分片不受影响。这个设计让写入高峰时的抖动被限制在局部不会像全局 HashMap 那样一次扩容卡住所有线程。代价是内存。每个分片内部都是独立的哈希表每个表都有自己的桶数组锁本身也有开销。假如你开了 32 个分片每个分片初始容量是 4那么即使你只插入 1 个 key也可能占用了接近 128 个桶的元数据空间。数据量大的时候DashMap 的内存占用通常比等效的普通 HashMap 高出不少这是“空间换并行”的必然结果。应对方式有两个一个是构造时给一个合理容量DashMap 会把总容量分摊到各分片避免每个分片从最小容量反复扩容另一个是别把分片数调得太大够用就好。内存敏感的场景可以先做一个单线程预分配测试观察实际 RSS 再决定能不能扛住。3. 实战从基础增删改查到复合原子操作3.1 基础 API 的使用先看一个多线程写入的示例use dashmap::DashMap; use std::sync::Arc; use std::thread; fn main() { let shared: ArcDashMapu64, u64 Arc::new(DashMap::new()); let mut handles Vec::new(); for t in 0..8 { let shared Arc::clone(shared); handles.push(thread::spawn(move || { for i in 0..100_000u64 { let key t * 100_000 i; shared.insert(key, i); } })); } for h in handles { h.join().unwrap(); } println!(len {}, shared.len()); }DashMap本身就是并发安全的所以用ArcDashMap包一层后可以直接在多线程之间共享不需要再在外面套Mutex。这里的len()是各分片长度的即时汇总不是快照但对大多数统计场景够用了。读取和删除也很直观if let Some(entry) map.get(key) { println!(value {}, *entry); } map.remove(key); if map.contains_key(key) { // ... }get返回的是Ref类型它实现了Deref所以*entry能直接拿到值引用。这个Ref活着的时候对应分片的读锁会一直被持有这点在 3.2 里是个关键坑。3.2 复合操作的正确姿势并发环境下最容易写错的是“先查再写”。比如这种if !map.contains_key(key) { map.insert(key.clone(), value); }两个线程同时执行时可能同时发现 key 不存在然后都执行insert后写的覆盖先写的这就是典型的 check-then-act 竞态。正确做法是用 DashMap 的entryAPI把“判断并写入”放进同一个原子操作里map.entry(key).or_insert(value);需要做计数器之类的累计更新时可以这样map.entry(key) .and_modify(|v| *v 1) .or_insert(1);entry的整个调用过程中对应的分片锁会被一直持有所以读改写是原子的。get_mut也能做原子更新不过它拿的是分片的写锁if let Some(mut v) map.get_mut(key) { *v 1; }还有一个常用 API 是alter它允许基于旧值返回一个新值map.alter(key, |_, old| old.unwrap_or_default() 1);这里要重点提醒一句or_insert_with的闭包是在持有锁的情况下执行的。如果你在闭包里做重量级操作比如查数据库、发网络请求那么这个分片上的所有其他操作都会被堵住。表面上写法很优雅实际是给整个分片埋雷。正确做法是把重量级操作放在锁外面完成再回到锁内做短平快的更新。3.3 迭代时的弱一致性与锁释放时机遍历DashMap最直接的方式是iter()for item in map.iter() { println!({}: {}, item.key(), item.value()); }但你要清楚这里的迭代不是全局强一致快照。实现上迭代器会逐个分片拿读锁遍历完当前分片就立刻释放再拿下一个分片。如果有人在你遍历到一半时往里写你看到的数据可能包含一部分新写入也可能漏掉一部分取决于那个 key 落在哪个分片、以及是否已经遍历过。对大部分做统计、打印、聚合的场景这没问题但如果你想基于遍历结果做精确的删改决策就要小心。需要快照时最稳妥的方式是把数据全部收集到普通容器里let snapshot: std::collections::HashMap_, _ map .iter() .map(|r| (r.key().clone(), r.value().clone())) .collect();iter_mut会逐个分片拿写锁遍历期间对应分片无法被其他线程访问并发度会下降得很明显。如果只是做一次性迁移或清理可以接受如果在高频路径上频繁全量遍历DashMap 的弱一致性并发优势会被白白消耗掉。4. 压测对比与调优快是快但别用错地方4.1 三类典型场景的直观对比我在一台 8 核机器上做过一组对比压测数据规模不大目的是观察量级趋势。场景设计成 8 个线程并发操作同一个 map每个线程执行 50 万次操作key 随机均匀分布。结果大致如下场景MutexHashMapRwLockHashMapDashMap95% 读 5% 写基准 1x约 4-6 倍于基准约 5-8 倍于基准50% 读 50% 写基准 1x约 1.5-2 倍于基准约 3-5 倍于基准100% 写随机 key基准 1x约 0.8-1 倍于基准约 6-12 倍于基准这里想强调两点。第一读多写少时RwLockHashMap和 DashMap 的差距并没有想象中大因为读并发已经被RwLock释放了真正的瓶颈不再明显第二一旦写入占比上来RwLock会被全局写锁拖回去而 DashMap 多个分片可以并行写优势会急剧放大。所以我一直认为 DashMap 最适合“读写混合、写入有一定比例”的场景而不是所有并发字典场景。4.2 初始容量与分片数的调优方向DashMap::with_capacity是我最先推荐的一项调优。给一个接近真实数据量的初始容量每个分片可以一次性分配够用避免频繁扩容带来的 CPU 尖刺。如果业务上还知道大概的 key 数量我会在启动阶段就构造好let map: DashMapString, CacheItem DashMap::with_capacity(100_000);分片数的调整需要谨慎。分片太少锁竞争明显分片太多内存和遍历开销变大。我的经验是先观察每个线程是否大概率落在不同分片上。如果你的 16 个 worker 都在频繁写同一个分片区域可以考虑增加分片数如果 key 本身已经非常均匀且并发表现良好那就别动它。另外DashMap支持自定义 hasher。遇到恶意构造的 key 分布不均匀时换一个更强的 hasher 能把冲突降到最低。默认 hasher 已经带随机种子防哈希碰撞攻击的表现不错大部分场景不需要额外处理。4.3 热点 key性能怪兽的最大软肋如果所有并发线程都在操作同一个 key比如一个全局计数器、一个“当前在线设备数”之类的热门指标那么 DashMap 并不会比Mutex快多少。因为同一个 key 永远哈希到同一个分片所有写操作都在这一个分片的锁上排队。分片再多对这个 key 也没有意义。我实际遇到过这种场景一个服务用 DashMap 统计各设备的请求数正常情况下 20 个分片跑得很欢但压测脚本写死了一个设备 ID结果这个分片的锁竞争直接让吞吐掉了 60%。解决方案不是换容器而是改造数据结构如果只是数字累加直接用AtomicU64连 DashMap 都不用如果是“同一逻辑 key”的频繁更新可以把 key 拆成 N 个物理分片比如counter_0到counter_15写入时随机挑一个读取时求和如果业务逻辑复杂考虑用 actor 把对这个 key 的更新串行化避免多线程同时碰它。所以判断一个并发结构是否适合某个场景必须先看key 的分布和读写比例而不是看结构本身有多高级。5. 避坑指南与替代方案DashMap 不是银弹5.1 我实际踩过的几个坑先说一个最常见的死锁坑。DashMap 的get返回Ref会持有分片读锁。如果代码这样写if let Some(value) map.get(key) { // 这个 Ref 还活着 if let Some(mut v) map.get_mut(key) { // 同线程请求写锁可能死锁或 panic } }value还活着的时候再去拿同一个分片的写锁会把自己卡住。解决方式是先缩小Ref的作用域或者显式drop(value)。这个坑非常隐蔽因为不同平台的读写锁行为不一样有些环境报 panic有些环境直接挂起。第二个坑是在alter或or_insert_with的闭包里做耗时操作。闭包执行期间锁被持有一个闭包慢十倍整个分片的所有操作都跟着慢。我建议把“读旧值 - 算新值 - 写回”这个过程拆开中间计算部分放在锁外最后用entry或get_mut做短临界区更新。如果中间态不能被其他线程干扰那才考虑把整段逻辑放进锁内。第三个坑是异步环境里持锁跨.await。Ref和RefMut都不是Send的跨await存活会直接编译报错就算你强行把值克隆出来再 await锁的粒度也会失控。异步代码的共享状态我更倾向用 actor 模式或tokio::sync::RwLock这种异步友好的锁。第四个坑是内存增长。长时间运行的服务如果频繁插入再删除DashMap 每个分片的容量不会自动缩回内存可能会稳定在高位。偶尔做一次“整体迁移重建”比定期清理更有效。5.2 什么时候根本不该用 DashMap我自己会在下面几类场景里主动放弃 DashMap数据量很小比如几十条配置项。直接RwLockHashMap或arc-swap更轻DashMap 分片带来的内存和定位开销属于白给。读极端多、写极少且写时可以直接整体替换。这种场景arc-swap更适合读路径不拿锁只做一个原子的指针交换。需要全局一致性遍历。比如计算分片之间的统计快照、做一次精确的批量复核DashMap 的弱一致迭代不适合应该临时收集到普通 map 或直接上其他结构。需要范围查询。DashMap 本质是哈希表无法高效地“查某个范围内的所有 key”这时用BTreeMap加锁或者跳表结构更合适。这些场景不是 DashMap 不够好而是它设计上没打算解决这些问题。5.3 替代方案一览与我的最终建议做选型时可以简单参考这张表方案适合场景主要代价std::sync::RwLockHashMap读多写少、数据量小写锁全局串行写多时退化成互斥锁DashMap读写混合、key 分布均匀内存放大迭代弱一致热点 key 变瓶颈crossbeam-skiplist有序 key、需要范围查询整体性能低于哈希结构内存更大arc-swap配置类整表替换、读极多写极少不适合高频单 key 更新actor channel复杂业务状态、异步上下文延迟增加需要维护 actor 生命周期回到最初那个压测失败的例子我最后没有无脑把全项目都换成 DashMap。配置缓存这种“读多、写可接受短暂阻塞”的场景我用arc-swap反而更干净而那些需要频繁读写同一个字典的地方才换成 DashMap。实际效果是锁竞争明显下降p99 延迟从秒级回到毫秒级。如果你也遇到高并发下HashMap被锁拖垮我建议先做一件事用perf或tokio-console统计线程的实际等待时间确认瓶颈到底是不是锁竞争。确认之后再决定上 DashMap 还是换 actor 方案。DashMap 确实配得上“性能怪兽”这个称号但它只在你真正理解锁粒度、热点 key 和内存代价之后才会成为顺手的好工具。