从 Go 1.9 开始,标准库引入了 sync.Map。你或许听说过,它在某些场景下性能远超 map + sync.RWMutex。但官方文档却反复强调:大多数代码应该继续使用普通的 map 加锁,sync.Map 是特化的,只适合极少数场景。
为什么一个官方出品的并发安全 map,反而被官方“劝退”?它的高性能到底建立在什么样的设计取舍之上?今天,我们就从源码层面(基于 Go 1.23+ 的实现),深入拆解 sync.Map 的内在逻辑。
官方定义的黄金使用场景
使用 sync.Map 前,请务必记住它只推荐用于以下两种场景:
- 写一次,读非常多次:例如全局配置、缓存、初始化后不再变化的路由表等。
- 多个 goroutine 读写完全不相交的 key 集合:每个 goroutine 只操作自己专属的 key 子集。
如果你的场景不符合以上两点(例如频繁增删改、写多读少),sync.Map 的性能可能还不如最基础的 map + sync.RWMutex。更多关于如何评估和设计高并发场景,可以参考 云栈社区 上的相关讨论。
核心设计:read + dirty 双 map 结构
sync.Map 的内部结构是其高性能和无锁读的基础。它采用了一种空间换时间的策略。
type Map struct {
mu Mutex // 保护 dirty 和 misses
read atomic.Value // 存储一个 *readOnly 结构(内部是只读map)
dirty map[any]*entry // 包含新写入和逻辑删除但未清理的条目
misses int // read 未命中次数
}
实际存储数据的基本单元是 entry:
type entry struct {
p atomic.Pointer[any] // 指向实际 value 或特殊标记
}
entry.p 有三种状态:
nil:表示该条目已从 dirty 中删除。
expunged:表示该条目已从 read 中删除,但 dirty 中尚存其引用(延迟清理)。
- 指向真实
value 的指针:正常数据。
为什么读能做到无锁?(Fast Path)
读操作的性能是 sync.Map 最大的亮点,其核心逻辑在 Load 方法中:
func (m *Map) Load(key any) (value any, ok bool) {
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
if !ok && read.amended { // 1. key不在read中,且dirty包含更多数据
return m.loadSlow(key) // 进入慢路径(加锁)
}
if e == nil || e.p.Load() == expunged { // 2. 条目已被逻辑删除
return nil, false
}
return *e.p.Load(), true // 3. 直接从entry中原子读取
}
关键点解析:
read 字段是一个 atomic.Value,它所指向的 *readOnly 整个 map 指针可以被原子地替换。这意味着读取这个指针完全不需要锁。
- Fast Path:当 Key 命中
read.m,并且其对应的 entry 状态正常时,整个过程没有任何锁开销,也没有 atomic.Store 操作,仅有一次 atomic.Load,速度极快。
- Slow Path:只有在
read 未命中(read.amended == true)或命中但条目状态异常时,才会进入 loadSlow 方法,该方法是需要加锁的。
写操作的代价有多高?(Slow Path)
Store、LoadOrStore、Delete 等写操作大部分情况下都会走到加锁的慢路径。这个过程比读复杂得多:
- 尝试无锁从
read 中读取。
- 如果需要修改或
read 未命中,则加锁 m.mu。
- 双重检查(double check),防止加锁期间
read 已被更新。
- 如果
dirty 为 nil,则执行一个昂贵操作:将 read 中所有未被标记为 expunged 的 entry 复制到 dirty 中。
- 在
dirty 上执行写入或删除操作。
- 如果
m.misses 计数器达到阈值(len(m.dirty)),则触发一次 提升:将 dirty 提升为新的 read,dirty 置为 nil,misses 重置为 0。
升级的代价是巨大的,它需要对当前的 dirty map 进行一次 O(n) 的复制:
// 概念性伪代码,展示提升过程
func (m *Map) promote() {
newRead := readOnly{m: make(map[any]*entry), amended: false}
for k, e := range m.dirty {
if e.p.Load() != expunged { // 只复制有效条目
newRead.m[k] = e
}
}
m.read.Store(newRead) // 原子替换整个read map
m.dirty = nil
m.misses = 0
}
残酷的设计取舍对比
下表清晰地展示了 sync.Map 与 map + sync.RWMutex 在不同维度上的优劣:
| 维度 |
sync.Map |
map + RWMutex |
适用场景 |
| 纯读命中 |
无锁、原子读、极快 |
需 RLock(),有开销 |
sync.Map 大胜 |
| 首次写入/新Key |
加锁 + 可能触发昂贵的全量复制 |
直接写,锁开销小 |
RWMutex 胜 |
| 频繁写入不同Key |
持续 miss,频繁触发提升操作 |
写锁开销相对稳定 |
RWMutex 完胜 |
| 删除 |
延迟删除(标记 expunged) |
立即删除 |
sync.Map 省锁 |
| Range 遍历 |
快照可能不一致,漏改动,不能 break |
完整一致快照(全程持锁) |
视需求而定 |
| 内存开销 |
read + dirty 冗余,entry 指针开销 |
单 map |
RWMutex 胜 |
| 类型安全 |
any,需运行时转换 |
map[K]V 泛型 |
普通 map 完胜 |
一句话总结:sync.Map 通过牺牲写性能(包括首次写的高昂复制成本和频繁写的锁竞争)、增加内存开销以及牺牲类型安全,换来了 读操作的极致性能。
性能基准参考
以下为基于社区测试的近似数据,可以直观感受其性能差异:
| 场景 |
goroutine 数 |
sync.Map QPS |
map+RWMutex QPS |
胜者 |
| 读多写少 (99%读) |
64 |
~4800万 |
~2200万 |
sync.Map 碾压 |
| 读写均衡 (50%写) |
32 |
~800万 |
~1350万 |
RWMutex 胜 |
| 只写不读 |
16 |
~300万 |
~1800万 |
RWMutex 大胜 |
| 写一次,然后狂读 |
128 |
~6500万 |
~1800万 |
sync.Map 神级 |
什么时候坚决不要用 sync.Map?
- Key 频繁增删:会导致
read 和 dirty 频繁同步,性能开销巨大。
- 写操作比例较高 (>10-20%):此时锁竞争和复制成本会使其性能反超。
- 需要类型安全:应使用泛型
map[K]V,sync.Map 的 any 接口会带来转换开销和运行时风险。
- 需要严格一致性的
Range 遍历:sync.Map 的 Range 可能漏掉遍历过程中的新增改动。
- 追求代码简洁和维护性:
map + Mutex 的逻辑远比 sync.Map 清晰直观。
在这些场景下,老老实实使用 map[K]V + sync.RWMutex 才是最佳实践。如何正确使用锁和 Goroutine,是每个 Go 开发者必须掌握的 Go 并发核心知识。
结论
一句话记住 sync.Map:它并非通用解决方案,而是为特定场景定制的“特种兵器”。
如果你的场景是 “写一次,读万次” 或者 “各写各的,互不干扰”,那么 sync.Map 能为你带来巨大的性能红利。否则,请坚持使用 map + 锁 的标准模式。
理解其双 map 的设计、无锁读的实现以及写操作背后的巨大成本,你才能做出最合适的技术选型。这种深入到源代码层面的剖析能力,也正是高级工程师的必备技能,更多 源码分析 方法值得持续探索。