sync.Map 底层实现全解析:read、dirty 双 map 设计与 dirty 提升机制
引言
「sync.Map 是怎么实现的?为什么要设计成两个 map?」「什么场景下 sync.Map 比 map+Mutex 快?」「sync.Map 的 Range 是快照吗?」—— 这是并发面试的压轴题之一。前面我们讲过普通 map 的底层实现与扩容迁移(第 23 期),也讲过 Mutex 与 RWMutex 的区别(第 10 期)和 atomic 原子操作(第 15 期),sync.Map 正是建立在这些基础之上的「官方并发 map 答案」。
一个必须先说清的事实:sync.Map 的实现分新旧两代。Go 1.24 及更早版本(含 1.25)使用经典的「read / dirty 双 map + misses 提升」设计;Go 1.26 起官方将其重写为 internal/sync.HashTrieMap——一个基于 CAS 的无锁哈希字典树。两代的 API 完全兼容,但面试答法已经变了,本文两代都讲。
一、为什么需要 sync.Map:map+Mutex 的痛点
Go 的内置 map 不是并发安全的,并发读写会直接触发 fatal error: concurrent map read and map write。常规做法是用一把 sync.RWMutex 包住它:
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func (s *SafeMap) Get(key string) (int, bool) {
s.mu.RLock() // 读也要先拿锁
defer s.mu.RUnlock()
v, ok := s.m[key]
return v, ok
}
问题出在读路径也要加锁。RWMutex 的读锁虽然可以并发持有,但内部依赖原子操作和 goroutine 排队,当读请求量极大、key 集合又稳定时,锁本身的开销(尤其是高核数机器上的缓存行争抢)会成为瓶颈。sync.Map 的目标就是:读操作彻底不加锁。
二、经典实现(Go 1.25 及更早):read / dirty 双 map
2.1 数据结构
对照 go1.25.0 的 src/sync/map.go(本文所有源码细节均已对照官方源码核实),核心结构只有四个字段:
type Map struct {
read atomic.Pointer[readOnly] // 原子指针,读操作的无锁入口
mu sync.Mutex // 只保护 dirty 相关操作
dirty map[any]*entry // 受锁保护的新写入数据
misses int // read 未命中计数,触发提升的判据
}
type readOnly struct {
m map[any]*entry
amended bool // true 表示 dirty 中存在 read.m 里没有的 key
}
type entry struct {
p atomic.Pointer[any] // 三种状态:有效值 / nil / expunged
}
设计思想一句话概括:读走原子指针,写走加锁的 dirty,攒够 miss 后整体换岗。
read:通过原子指针访问,Load 在这里命中时全程无锁。entry.p也是原子指针,读出来就能用。dirty:所有新写入(read 中不存在的 key)都要先拿mu写进 dirty,因此是「脏数据区」。entry.p的三个状态:nil表示该 key 已删除且 dirty 未建立;expunged是一个哨兵指针,表示该 key 已删除且不在 dirty 中(再次写入需要先把它「unexpunge」并搬回 dirty);否则为有效值。
2.2 Load:无锁快路径 + 加锁慢路径
func (m *Map) Load(key any) (value any, ok bool) {
read := m.read.Load()
e, ok := read.m[key]
if !ok && read.amended { // read 里没有,且 dirty 可能有
m.mu.Lock()
read = m.read.Load()
e, ok = read.m[key]
if !ok && read.amended { // 双重检查
e, ok = m.dirty[key] // 这一步必须加锁
m.missLocked() // 记录一次 miss
}
m.mu.Unlock()
}
// ... 原子读取 e.p 并返回
}
逻辑像双重检查锁(DCO):快路径原子读 read.m,命中即返回,零加锁;未命中且 amended 为真才加锁去 dirty 里找。注意第二次检查 m.read.Load() 是必需的——加锁期间 dirty 可能刚被提升为 read。
2.3 Store:三种情况分开处理
- key 在 read 中且未被删除:直接 CAS 更新
entry.p,无锁。 - key 在 read 中但已被 expunge:加锁,先
unexpungeLocked恢复标记,再写入 dirty。 - key 不在 read 中:加锁写 dirty;如果 dirty 还是 nil,先执行
dirtyLocked——把 read 里所有未删除的 entry 引用拷贝一份生成新 dirty(注意拷贝的是*entry指针,值只有一份),并置read.amended = true。
2.4 dirty 提升(promotion):misses 达到阈值时换岗
这是面试最爱追问的点。每次 Load 在 dirty 里也没找到(或不得不查 dirty),missLocked 会计数:
func (m *Map) missLocked() {
m.misses++
if m.misses < len(m.dirty) {
return
}
m.read.Store(&readOnly{m: m.dirty}) // dirty 直接整体提升为 read
m.dirty = nil // 原 dirty 指针交给 read 后不再持有
m.misses = 0
}
阈值是 misses >= len(dirty)。含义很朴素:当「白跑一趟去读 dirty」的次数已经等于 dirty 的 key 数时,说明 read 已经旧到不划算,不如把 dirty 整体提拔为 read,让所有读回到无锁快路径。提升只是改一次原子指针(两个 map 的 entry 指针本就共享),代价极低。这回答了「为什么读多写少时 sync.Map 快」:提升之后的长尾读取全部无锁。
三、适用边界:sync.Map 不是银弹
官方文档(含新旧两版)都强调 sync.Map 只适合两类场景,且有明确陷阱:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 只写一次、读很多次(只增缓存、配置表) | sync.Map | 提升后读全程无锁 |
| 多个 goroutine 操作互不相交的 key 集合 | sync.Map | 不会互相触发 dirty 复制 |
| 大量 goroutine 读写同一批 key | map + RWMutex | sync.Map 的 dirty 复制和反复提升反而更慢 |
| 需要类型安全(强类型 value) | map + Mutex | sync.Map 的 key/value 都是 any,需断言 |
| 计数器、累加器 | sync/atomic | 原子操作足够,无需 map |
| Range 期间需要一致快照 | 自己拷贝 | Range 不保证任何一致性快照 |
三个高频陷阱:
- Range 不是快照。并发增删时 Range 可能漏掉新 key、重复看到旧 key,文档原话是它不对应任何一致快照。需要快照就自己先拷出来。
- 首次使用后不可拷贝(结构体含
noCopy),用go vet可以检查。 - 零值即就绪:
var m sync.Map直接能用,不要 make,也不要用指针包一层——方法都定义在指针接收者上,传参时记得传*sync.Map。
四、基本用法与正确示例
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map // 零值即可用
m.Store("name", "golang")
v, ok := m.Load("name")
fmt.Println(v, ok) // golang true
// 已存在则返回旧值,不存在才写入(读多写少缓存的典型用法)
actual, loaded := m.LoadOrStore("name", "other")
fmt.Println(actual, loaded) // golang true
// Range:f 返回 false 提前终止遍历
m.Range(func(key, value any) bool {
fmt.Println(key, value)
return true
})
if v, ok := m.LoadAndDelete("name"); ok {
fmt.Println("deleted:", v)
}
}
「只写一次读多次」的标准写法:
var configCache sync.Map
func GetConfig(key string) any {
if v, ok := configCache.Load(key); ok {
return v // 99% 的请求走无锁快路径
}
cfg := loadFromDB(key) // 只发生一次
actual, _ := configCache.LoadOrStore(key, cfg)
return actual
}
如果这个 map 的 QPS 高到锁成为瓶颈,还可以参考 《使用 sync.Map 结合分片技术进行优化》 的分片思路;日常使用入门见 《深入理解 sync.Map:使用技巧、示例与实现机制》。
五、新一代实现:Go 1.26 的 HashTrieMap
如果你在 2026 年的面试中被问到「sync.Map 怎么实现的」,只答 read/dirty 已经不够了。对照官方仓库提交记录(aecb8faa91,“make HashTrieMap the default implementation of Map”;e15a14c4dd,移除 synchashtriemap 实验开关):
- Go 1.26 起,
sync.Map内部直接委托给internal/sync.HashTrieMap[any, any]; - HashTrieMap 是一个基于 CAS 的无锁哈希字典树(lock-free hash trie):用 key 的 64 位哈希逐段定位到间接节点(indirect node),写入通过
CompareAndSwap完成,整条路径没有全局互斥锁,读写互不阻塞; - 它不再区分 read/dirty,自然也没有 misses 提升和 expunged 状态,Range 的无快照语义保留不变;
- 公开的 API、
sync.Map的适用场景建议(写一次读多 / key 集合不相交)均未变化。
两代实现的面试速记:旧代「读原子、写加锁、miss 换岗」,新代「哈希字典树 + CAS 全程无锁」。回答时主动提一句版本差异,是非常加分的细节。
六、面试速答清单
- sync.Map 为什么读快? 旧代靠原子指针 read 命中时零加锁;新代(1.26+)整条路径 CAS 无锁。
- dirty 提升的触发条件?
missLocked中misses >= len(dirty)时,原子地把 dirty 提升为 read 并清空 dirty 与 misses。 - amended 是什么? readOnly 的字段,标记 dirty 中是否存在 read 没有的 key,Load 靠它决定要不要走加锁慢路径。
- expunged 和 nil 有什么区别? nil 是「已删但 dirty 未建」,expunged 是「已删且确定不在 dirty」,再次写入前必须先 unexpunge 并搬回 dirty。
- sync.Map 适合什么场景? 写一次读多次;多 goroutine 操作不相交的 key。高频写同一批 key 不如 map+RWMutex。
- Range 是快照吗? 不是,不保证一致性,需要快照自行拷贝。
版本说明
- 经典 read/dirty 实现:对照
go1.25.0的src/sync/map.go逐行核实(2026-10-28 核对,源码:github.com/golang/go,tag go1.25.0)。 - 新版 HashTrieMap:对照
go1.26.0的src/sync/map.go与src/internal/sync/hashtriemap.go核实,变更提交aecb8faa91/e15a14c4dd(2026-10-28 核对)。 - 本文写作时 Go 最新发布版本为 go1.27.3 / go1.26.10(go.dev/dl 核实,2026-10-28)。
- 文中示例仅使用标准库,不依赖第三方模块。
本文是「Go语言面试题」系列第 32 篇。上一期:《Go 内存分配机制全解析:mspan、mcache、mcentral 与 mheap 三级缓存设计》;相关阅读:《深入理解 Go Map 底层实现:哈希冲突、扩容迁移与并发安全》、《sync.Pool 深度解析:对象复用原理、victim 机制与使用陷阱》、《Go 内存模型与 happens-before》。全系列目录见 《Go语言面试题总目录》。
- 《Go 程序设计语言》(Go 语言圣经,机械工业出版社) —— Go 官方团队出品,公认入门第一书
- 《Go 语言实战》(异步图书,人民邮电出版社) —— 实战导向,面试前巩固工程手感
- 《Go 语言编程之旅:一起用 Go 做项目》(博文视点,电子工业出版社) —— 项目驱动入门,从零写出完整应用
- Keychron K1Max 矮轴机械键盘(Mac/Win 双系统,QMK/VIA 开源改键) —— 轻薄矮轴 + 开源固件,程序员桌面常客
- 罗技 MX Master 3S 无线鼠标(Flow 跨屏,静音电磁滚轮) —— 人体工学设计,写代码一整天手不酸
以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。