sync.Map 源码大变天:一棵 16 叉树如何干掉 read/dirty 双 Map
文章内容摘要
Go 1.24 静默重写了 sync.Map 底层------从 read/dirty 双 Map 替换为 HashTrieMap(16 叉并发前缀树)。全局 Mutex、O(N) dirty 提升全部消失。新方案读路径纯原子操作零锁,写路径只锁单个节点,不同子树并发写互不干扰。本文从源码拆解新数据结构、读写流程、冲突处理及 atomic 内存序如何支撑无锁读。还在背旧版八股的,该更新了。
sync.Map的定位和适用场景
很多人好奇,我使用一个sync.RWMutex + (normal)map不就可以解决并发问题了吗,为什么官方还要自己设计一个sync.Map?
其实sync.Map在以下两种场景有显著优势
-
读多写少(读取无锁)
-
key分片明显,即不同goroutine操作的key集合基本不重叠(操作不同key时CAS原子操作,避免加锁大开销)
Go 1.24 对 sync.Map 做了一次彻底的底层重写,但这个变化非常低调------没有专门的 Proposal 讨论,Release Note
也没有高亮。如果你还在用旧版本的 read/dirty 模型去理解它(或者去面试),是时候更新了。
简单回顾旧版本实现
Go 1.23 以及之前,sync.Map的数据结构如下
go
type Map struct {
mu Mutex // 删除或者增加key会使用到的Mutex
read atomic.Pointer[readOnly] // 无锁读的只读 map
dirty map[any]*entry // 需要加锁的可写 map
misses int // read 未命中计数,达到一定限度会更新dirty为read
}
type readOnly struct {
m map[any]*entry
amended bool // dirty 中是否有 read 没有的 key
}
type entry struct {
p atomic.Pointer[any] // nil / expunged / 实际值
}
读写流程
- 读:先查 read(无锁),命中直接返回;未命中且 amended=true 时加锁查 dirty,misses++
- 写:加锁写 dirty;如果 dirty 为空,先把 read 中未删除的 entry 全量复制到 dirty
- 提升:当 misses >= len(dirty) 时,dirty 整体提升为新的 read,dirty 置空
一些痛点
- dirty提升O(n),dirty重建O(n)
- 状态机转化复杂,写操作锁竞争问题严重
新版本sync.Map的实现
结构介绍
先来看看核心数据结构 Go1.24版本的sync.Map变成了一个薄壳
go
type Map struct {
_ noCopy
m isync.HashTrieMap[any, any] //所有方法委托给一个哈希前缀树
}
哈希前缀树结构详解
顶层结构
go
type HashTrieMap[K comparable, V any] struct {
inited atomic.Uint32
initMu Mutex
root atomic.Pointer[indirect[K, V]] // 树根 ->K本质上是key值到哈希值的映射
keyHash hashFunc // 从 runtime 提取的哈希函数
valEqual equalFunc // value 比较函数
seed uintptr // 随机种子
}
两种节点
go
// 内部节点(中间节点)
type indirect[K comparable, V any] struct {
node[K, V]
dead atomic.Bool // 是否已被删除
mu Mutex // 保护 children 的写入
parent *indirect[K, V] // 父指针,删除时向上收缩用
children [16]atomic.Pointer[node[K, V]] // 16 个子槽位
}
// 叶子节点(存储实际 KV)
type entry[K comparable, V any] struct {
node[K, V]
overflow atomic.Pointer[entry[K, V]] // 完全哈希碰撞链表
key K
value V
}
关键特性
- 每个indirect对应有16个孩子节点,因为对于一个64位hash值,每层取4位,对应16个槽位,结合前缀树原理很好理解(参考我的前缀树原理博客--https://blog.csdn.net/hhhhhh66654/article/details/161560953?sharetype=blogdetail&sharerId=161560953&sharerefer=PC&sharesource=hhhhhh66654&sharefrom=mp_from_link)
- 按需生长:如果某子树某一层只有一个entry那个key直接挂载在那里即可,无需完全延申开来---->我称之为懒延申
流程讲解
无锁读
go
func (ht *HashTrieMap[K, V]) Load(key K) (value V, ok bool) {
hash := ht.keyHash(&key, ht.seed)
i := ht.root.Load()
hashShift := 64
for hashShift != 0 {
hashShift -= 4
n := i.children[(hash>>hashShift) & 0xF].Load()
if n == nil {
return zero, false // 空槽位,key 不存在
}
if n.isEntry {
return n.entry().lookup(key) // 到达叶子,遍历 overflow 链,处理哈希冲突的结构
}
i = n.indirect() // 中间节点,继续下一层
}
}
整个过程:
- 计算 key 的 64 位 hash
- 从 root 开始,每层取 hash 的 4 bit 作为 0~15 的索引
- atomic.Load 读子指针(保证不对读取到修改到一半的指针),三种情况:nil(不存在)、entry(到达叶子)、indirect(继续)
- 到达叶子后遍历 overflow 链表逐个比较 key
全程没有任何锁、没有 CAS,只有一串 atomic load。 这就是高并发读性能的来源。
乐观查找+悲观确认
无锁查找插入点
go
for {
i = ht.root.Load()
hashShift = 64
for hashShift != 0 {
hashShift -= 4
slot = &i.children[(hash>>hashShift) & 0xF]
n = slot.Load()
if n == nil || n.isEntry {
break // 找到插入点
}
i = n.indirect()
}
i.mu.Lock()
n = slot.Load() // double-check
if (n == nil || n.isEntry) && !i.dead.Load() {
break // 状态没变,可以操作
}
i.mu.Unlock() // 状态变了,重试
}
defer i.mu.Unlock()
带锁执行插入
go
// 情况 1:空槽位,直接插入
if n == nil {
slot.Store(&newEntry.node)
}
// 情况 2:已有 entry 且 key 相同,替换值
if swapped := oldEntry.swap(key, new); swapped {
slot.Store(&newEntry.node)
}
// 情况 3:已有 entry 但 key 不同,需要扩展树-->哈希冲突
slot.Store(ht.expand(oldEntry, newEntry, hash, hashShift, i))
注意写入的关键模式:先构造完整的新节点/子树,最后一步 slot.Store()
原子发布。读端要么看到旧状态,要么看到完整的新状态,不存在中间态。
怎么处理哈希冲突
当两个不同 key 落在同一个槽位时:
go
func expand(oldEntry, newEntry, newHash, hashShift, parent) *node {
oldHash := hash(oldEntry.key)
// 完全碰撞:64 位 hash 完全相同,用链表
if oldHash == newHash {
newEntry.overflow.Store(oldEntry)
return &newEntry.node
}
// 部分碰撞:创建中间节点,继续用后续 bit 区分
top := newIndirectNode(parent)
current := top
for {
hashShift -= 4
oi := (oldHash >> hashShift) & 0xF
ni := (newHash >> hashShift) & 0xF
if oi != ni {
current.children[oi].Store(oldEntry)
current.children[ni].Store(newEntry)
break
}
next := newIndirectNode(current)
current.children[oi].Store(next)
current = next
}
return &top.node
}
举个例子:
key A: hash = 0x3A7F...
key B: hash = 0x3B2C...
第一层 0x3 相同 → 冲突
第二层 A=0xA, B=0xB → 不同,分开
只需要 2 层 indirect 就区分开了。
删除节点+向上收缩
go
// 删除叶子
slot.Store(nil)
// 向上收缩空节点
for i.parent != nil && i.empty() {
hashShift += 4
parent := i.parent
parent.mu.Lock()
i.dead.Store(true) // 标记死亡
parent.children[idx].Store(nil) // 从父节点摘除
i.mu.Unlock()
i = parent
}
// dead 标记的作用:当一个 indirect 被标记为 dead,任何正在它上面做 double-check 的写入者会发现状态不对,释放锁从 root
// 重新开始。这避免了在一棵正在被拆除的子树上做无效操作。
并发安全的设计哲学
这一节回答三个问题:为什么锁在父节点上?atomic.Pointer 到底保证了什么?两者怎么配合让"读无锁"成立?
为什么锁在父节点上
先明确一个事实:entry(叶子)上没有锁,锁在 indirect(中间节点)上。
原因很简单------写操作修改的不是 entry 本身,而是父节点 children 数组里的指针。来看一个具体的竞争场景:
indirect (parent)
└─ children[5] → nil
G1 想插入 keyA,hash 索引到 [5]
G2 想插入 keyB,hash 也索引到 [5]
如果不加锁:
- G1 看到
children[5] == nil,构造 entryA - G2 也看到
children[5] == nil,构造 entryB - G1 执行
children[5].Store(entryA)✓ - G2 执行
children[5].Store(entryB)------ entryA 被覆盖,数据丢失
加了父节点的锁之后:
- G1 锁住 parent,确认
children[5]仍是 nil,写入 entryA,解锁 - G2 锁住 parent,发现
children[5]已经是 entryA,走 expand 逻辑把 A 和 B 分到不同子槽位
锁保护的是"判断槽位状态 + 写入"这个组合操作的原子性,防止两个写入者基于同一个旧状态做出冲突决策。
而不同 indirect 节点的锁互不干扰------两个 goroutine 写入不同子树时,各锁各的,完全并行。
atomic.Pointer 到底保证了什么
很多人以为 atomic 是"让改指针和改数据变成一个原子操作"。这是错的------它们是两块不同的内存,物理上不可能合成一步。
atomic.Pointer 真正保证两件事:
第一:指针读写不撕裂
64 位指针的赋值是完整的一次操作,读端不会读到"高 32 位是新地址、低 32 位是旧地址"的拼接值。
第二:内存顺序(这才是关键)
现代 CPU 和编译器会重排指令。没有 atomic 时,下面的代码可能出问题:
go
// 写端(构造新节点)
newEntry.key = "foo" // ① 写数据
newEntry.value = 42 // ② 写数据
slot = &newEntry // ③ 发布指针(普通赋值)
编译器/CPU 可能把 ③ 重排到 ① ② 前面执行。读端就会看到:指针已经指向 newEntry 了,但 key 和 value 还是零值。
atomic.Store 带 release 语义 :保证 ①② 不会被重排到 ③ 后面。
atomic.Load 带 acquire 语义:保证读到新指针后,后续读取数据时一定能看到写端在 Store 之前写入的所有内容。
用一张图表示:
写端 读端
───── ─────
newEntry.key = "foo" ①
newEntry.value = 42 ②
┃ (release barrier)
slot.Store(&newEntry) ③ ─────→ p := slot.Load() ④
┃ (acquire barrier)
print(p.key) ⑤ 保证看到 "foo"
一句话总结:不是"改指针和改数据同时发生",而是"你看到新指针的那一刻,数据一定已经准备好了"。
两者如何配合:读无锁的完整逻辑
现在把 Mutex 和 atomic.Pointer 放在一起看:
写端流程:
1. 构造新节点(普通内存写入,此时没人能看到这个节点)
2. i.mu.Lock() ← 防止其他写入者竞争同一个槽位
3. double-check 槽位状态
4. slot.Store(&newNode) ← release:发布新节点,之前的写入全部可见
5. i.mu.Unlock()
读端流程:
1. n := slot.Load() ← acquire:看到新指针后,数据保证就绪
2. 读取 n.key, n.value ← 安全,因为 entry 创建后不可变
三个机制各司其职:
| 机制 | 解决什么问题 |
|---|---|
| indirect.mu (Mutex) | 多个写入者不会互相覆盖同一个槽位(写-写互斥) |
| atomic.Pointer (Store/Load) | 读端无需加锁就能安全看到写端发布的完整数据(读-写可见性) |
| entry 不可变 + 替换而非修改 | 读端拿到指针后可以放心读,不会有人在背后改字段(读端安全) |
如果缺少任何一个:
- 没有 Mutex → 两个写入者可能互相覆盖,丢数据
- 没有 atomic → 读端可能看到指针更新了但数据还没写完
- entry 可变 → 读端读到一半时写端改了字段,数据不一致
dead 标记:处理结构变更的通知机制
删除操作会向上收缩空节点。如果一个 indirect 被删除了,但此时另一个 goroutine 正好锁住了它准备写入,怎么办?
答案就是 dead 标记。写入者在 double-check 阶段会检查 !i.dead.Load():
go
i.mu.Lock()
n = slot.Load()
if (n == nil || n.isEntry) && !i.dead.Load() {
break // 节点还活着,可以操作
}
i.mu.Unlock() // 节点已死,释放锁,从 root 重新开始
这是一种轻量级的"失效通知"------不需要广播,每个写入者自己检查即可。
新旧方案对比
| 维度 | 旧(read/dirty) | 新(HashTrieMap) |
|---|---|---|
| 读路径 | atomic 读 read map,miss 时加锁查 dirty | atomic 沿树查找,纯无锁 |
| 写路径 | 全局 Mutex | 细粒度节点 Mutex |
| 写并行度 | 零(单锁) | 不同子树完全并行 |
| 批量开销 | dirty 提升/重建 O(N) | 无批量操作 |
| 删除 | 标记 expunged,延迟清理 | 立即删除 + 向上收缩 |
| Clear | 加锁清空两个 map | 原子替换 root,一条指令 |
| 代码复杂度 | expunged 三态状态机 | 节点类型只有两种,逻辑清晰 |
使用建议
新实现让 sync.Map 在更多场景下成为合理选择,但核心原则没变:
适合 sync.Map 的场景:
- 读写比 9:1 以上的缓存
- 不同 goroutine 天然操作不同 key
- 需要 LoadOrStore、CompareAndSwap 等原子语义
适合 map + RWMutex 的场景:
- 写入密集且 key 高度重叠
- 需要批量操作或快照语义
如果你不确定该用哪个,先用 map + RWMutex。 sync.Map 是特化方案,不是通用替代品。
标记 expunged,延迟清理 | 立即删除 + 向上收缩 |
| Clear | 加锁清空两个 map | 原子替换 root,一条指令 |
| 代码复杂度 | expunged 三态状态机 | 节点类型只有两种,逻辑清晰 |
使用建议
新实现让 sync.Map 在更多场景下成为合理选择,但核心原则没变:
适合 sync.Map 的场景:
- 读写比 9:1 以上的缓存
- 不同 goroutine 天然操作不同 key
- 需要 LoadOrStore、CompareAndSwap 等原子语义
适合 map + RWMutex 的场景:
- 写入密集且 key 高度重叠
- 需要批量操作或快照语义
如果你不确定该用哪个,先用 map + RWMutex。 sync.Map 是特化方案,不是通用替代品。