Go sync.Map 源码深度解析:写时复制、无锁读与延迟删除的设计精髓
# 背景
Go 原生 map 不支持并发读写,多 goroutine 直接并发操作会触发 panic。常规解决方案是搭配 sync.Mutex/sync.RWMutex 加锁,但这在读多写少、键值对更新远多于新增的场景下,锁竞争会成为严重的性能瓶颈。
sync.Map 正是为解决这个场景而生,它通过读写分离、写时复制(COW)、延迟删除、原子操作替代锁等一系列精妙设计,实现了绝大多数读操作的无锁化,大幅降低了高并发场景下的锁竞争开销。
本文基于 Go 1.25.0 版本的 sync/map.go (opens new window) 源码,深入拆解其无锁读、写时复制、延迟删除三大核心设计的精髓,帮助开发者吃透高并发容器的底层设计思想。
# 一、核心数据结构:双map+entry状态机的底层基石
sync.Map 的所有精妙设计,都建立在极简又极具巧思的数据结构之上,核心是read/dirty 双map读写分离,以及entry 三状态的原子操作。
# 核心源码定义
// Map 是并发安全的map,零值可用,首次使用后不可复制
type Map struct {
// 互斥锁,保护dirty map的所有操作,以及read map的结构变更
mu Mutex
// read 存储只读的readOnly结构体,通过原子操作存取,读操作完全无锁
// 其中的map本身不会被修改结构(新增/删除key),仅会修改entry内的value指针
read atomic.Pointer[readOnly] // 内部存储readOnly类型
// dirty 是写操作的核心map,包含全量有效key(read未删除的key + 新增key)
// 所有操作必须加mu锁,当未命中次数达到阈值时,会被提升为新的read map
dirty map[any]*entry
// misses 统计read map未命中、需要加锁访问dirty的次数
// 达到len(dirty)阈值时,触发dirty到read的提升
misses int
}
// readOnly 是原子存储在Map.read中的不可变结构体
type readOnly struct {
m map[any]*entry
amended bool // 标记dirty中存在read.m里没有的新key
}
// expunged 哨兵指针,标记entry已被彻底清除,不存在于dirty map中
var expunged = new(any)
// entry 是value的包装结构体,核心是支持原子操作的unsafe.Pointer指针
// 实现了value的无锁更新,避免修改map本身的结构
type entry struct {
p atomic.Pointer[any] // 指向*interface{}类型的value
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 核心设计解读
双map读写隔离
read map:读热点区,所有读操作优先无锁访问,仅支持原子修改entry内的value,不会新增/删除key、修改map结构,彻底避免并发读写panic。dirty map:写操作区,所有map结构变更(新增key、清理删除key)都在这里完成,操作必须加锁,是冷数据区。
amended 快速判断标记:仅用一个bool值,就能让读操作快速判断dirty中是否有read没有的新key,避免无意义的加锁操作。
entry 原子包装层:原生map的value不可寻址,无法直接原子修改。通过entry包装成指针后,map仅存储entry的指针,修改value时只需原子更新entry内的指针,无需修改map结构,为无锁更新、无锁删除奠定了基础。
entry 三状态机:是删除逻辑的核心,三个状态通过原子指针区分:
有效状态:
p指向有效的*interface{},key存在,可正常读取value;软删除状态:
p = nil,key被标记删除,仍存在于read map中,读取时返回不存在;彻底清除状态:
p = expunged,key被标记删除,且已从dirty map中剔除,不会被复制到新的dirty中。
# 二、无锁读的实现:99%场景的零开销访问
sync.Map 最核心的性能优势,就是绝大多数读操作完全无锁,无需竞争互斥锁,这也是它在读多写少场景下远超 mutex+map 的核心原因。
# Load 方法核心源码
// Load 读取key对应的value,ok标记key是否存在
func (m *Map) Load(key any) (value any, ok bool) {
// 1. 原子加载readOnly,完全无锁,无竞争
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
// 2. 仅当read中无key,且dirty有新key时,才需要加锁
if !ok && read.amended {
m.mu.Lock()
// 3. 双重检查:加锁后再次加载read,避免并发过程中dirty已被提升
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
// 4. 再次确认read无key,且dirty有新key,才访问dirty
if !ok && read.amended {
e, ok = m.dirty[key]
// 5. 未命中计数+1,达到阈值时触发dirty提升
m.missLocked()
}
m.mu.Unlock()
}
// 6. 无对应entry,返回不存在
if !ok {
return nil, false
}
// 7. 原子加载entry中的value
return e.Load()
}
// missLocked 处理未命中计数,达到阈值时将dirty提升为新的read
func (m *Map) missLocked() {
m.misses++
// 阈值为dirty的长度,平衡提升频率与访问开销
if m.misses < len(m.dirty) {
return
}
// 原子替换read为dirty,零拷贝!仅传递map引用,无数据复制
m.read.Store(readOnly{m: m.dirty})
// 清空dirty,重置计数
m.dirty = nil
m.misses = 0
}
// Load 原子加载entry中的value
func (e *entry) Load() (value any, ok bool) {
p := atomic.LoadPointer(&e.p)
// 指针为nil或expunged,说明key已被删除
if p == nil || p == expunged {
return nil, false
}
return *(*any)(p), true
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 无锁设计的精髓
读操作优先无锁访问:所有读操作第一步都是原子加载readOnly,完全无锁。对于读多写少、key基本不变的场景,几乎所有读操作都在这一步完成,零锁开销,无上下文切换。
双重检查(Double Check)范式:加锁后会再次加载read map,避免并发过程中其他goroutine已将dirty提升为read,导致重复访问dirty;同时保证了并发安全,彻底避免竞态问题,是无锁编程的经典实践。
自适应的未命中阈值设计:
misses的阈值设为len(dirty),这个设计极具巧思:当dirty很小时,阈值很低,快速触发提升,避免频繁加锁访问小dirty;
当dirty很大时,阈值很高,避免频繁触发全量map替换,减少性能抖动;
保证只有当访问dirty的累计开销,超过提升dirty的一次性开销时,才会触发提升,完美平衡了读写性能。
entry的原子读保障:即使在read map中命中key,读取value也是通过原子操作加载entry的指针,保证并发更新value时,读操作不会拿到脏数据,同时全程无需加锁。
# 三、写时复制(COW):平衡读写性能的核心设计
写时复制(Copy-On-Write)是sync.Map的核心设计思想,它彻底避免了传统加锁map中读写竞争锁的问题,把map结构的变更和读操作完全隔离开,仅在必要时执行浅拷贝,把复制开销降到了最低。
sync.Map的写操作分为更新已存在的key和新增不存在的key两种场景,分别对应了无锁更新和写时复制的核心逻辑。
# Store 方法核心源码
// Store 存储key-value对
func (m *Map) Store(key, value any) {
// 1. 先无锁加载read,尝试直接更新已存在的key
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok && e.tryStore(&value) {
// 已存在的key,原子更新成功,直接返回,完全无锁!
return
}
// 2. 无法无锁更新,加锁处理
m.mu.Lock()
// 双重检查,加锁后再次确认read中的状态
read, _ = m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {
// 2.1 key在read中存在,但被标记为expunged,需先恢复到dirty中
if e.unexpungeLocked() {
// 恢复成功,将entry加入dirty,保证dirty包含全量有效key
m.dirty[key] = e
}
// 原子更新entry的value
e.storeLocked(&value)
} else if e, ok := m.dirty[key]; ok {
// 2.2 key不在read中,但在dirty中,直接更新dirty内的entry
e.storeLocked(&value)
} else {
// 2.3 全新的key,read和dirty中均不存在,触发写时复制逻辑
if !read.amended {
// amended为false,说明dirty当前为nil,需初始化dirty
// 写时复制核心:将read中未删除的key浅拷贝到dirty
m.dirtyLocked()
// 标记dirty包含read没有的新key
m.read.Store(readOnly{m: read.m, amended: true})
}
// 新key仅加入dirty,read map完全不修改
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()
}
// tryStore 尝试无锁原子更新entry的value,仅当entry非expunged状态时成功
func (e *entry) tryStore(i *any) bool {
for {
p := atomic.LoadPointer(&e.p)
// entry已被彻底清除,无法无锁更新,返回失败
if p == expunged {
return false
}
// CAS原子更新value指针
if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
return true
}
}
}
// unexpungeLocked 将expunged状态的entry恢复为nil,确保被加入dirty
// 必须持有mu锁时调用
func (e *entry) unexpungeLocked() (wasExpunged bool) {
return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}
// storeLocked 强制原子更新entry的value,必须持有mu锁时调用
func (e *entry) storeLocked(i *any) {
atomic.StorePointer(&e.p, unsafe.Pointer(i))
}
// dirtyLocked 初始化dirty,将read中未删除的entry浅拷贝到dirty
// 必须持有mu锁时调用
func (m *Map) dirtyLocked() {
if m.dirty != nil {
return
}
read, _ := m.read.Load().(readOnly)
// 初始化dirty,容量与read map一致,避免多次扩容
m.dirty = make(map[any]*entry, len(read.m))
// 遍历read map,浅拷贝未删除的entry
for k, e := range read.m {
// 尝试标记entry为expunged,仅未删除的entry会被复制到dirty
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}
// tryExpungeLocked 尝试将nil状态的entry标记为expunged
// 返回true表示entry已被删除,无需复制到dirty;必须持有mu锁时调用
func (e *entry) tryExpungeLocked() (isExpunged bool) {
p := atomic.LoadPointer(&e.p)
for p == nil {
// 将软删除的entry,CAS设置为expunged,标记为彻底清除
if atomic.CompareAndSwapPointer(&e.p, nil, expunged) {
return true
}
p = atomic.LoadPointer(&e.p)
}
return p == expunged
}
// newEntry 创建一个新的entry
func newEntry(i any) *entry {
return &entry{p: unsafe.Pointer(&i)}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# 写时复制的设计精髓
# 场景1:更新已存在的key——完全无锁的原子更新
这是sync.Map最极致的性能优化点:对于已存在的key,更新value全程无需加锁。
当Store一个已存在的key时,首先在read map中找到对应的entry,调用
tryStore方法,通过CAS原子操作更新entry内的p指针。整个过程完全没有修改map的结构(没有新增/删除key,map哈希表完全不变),仅修改了entry内的指针,因此无需加锁,也不会和读操作产生任何竞争。
这也是为什么
sync.Map在更新远多于新增的场景下,性能远超mutex+map——传统方案更新也要加锁,而sync.Map实现了零锁开销的更新。
# 场景2:新增key——写时复制的懒初始化与浅拷贝
新增key是写时复制的核心场景,这里的设计彻底避免了修改read map的结构,把所有结构变更都限制在加锁的dirty map中,同时通过懒拷贝把复制开销降到最低。
新增key绝不修改read map:read map被多个goroutine无锁并发读,直接修改其结构会触发并发map读写panic。因此所有新增key只会写入dirty map,read map完全不动,彻底隔离了读写操作的竞争。
懒初始化与写时复制:第一次新增key时,dirty为nil,此时会调用
dirtyLocked方法,把read map中未删除的entry浅拷贝到dirty中。这里的复制是浅拷贝:仅复制map的key和entry的指针,不会复制value本身,即使read map体量很大,复制开销也极低。
这就是写时复制的核心:只有当需要新增key时,才会复制read的内容到dirty,平时完全不执行复制操作,把复制开销延迟到第一次写的时候,避免了不必要的性能损耗。
amended标记的联动:初始化dirty后,会将readOnly的amended标记设为true,告知后续的读操作:dirty中存在read没有的新key,读不到时需要加锁访问dirty。
零拷贝的dirty提升:当
misses达到阈值后,会直接将dirty原子存储到read中,这个过程是零拷贝的——仅把dirty的map引用赋值给readOnly的m字段,没有任何数据复制,原来的dirty直接变成新的read map,随后dirty被置为nil,等待下一次新增key时再初始化。- 写时复制机制保证了:若旧的read map还有goroutine在读,依然可以正常访问,因为旧的map是不可变的,不会被修改,直到无引用后被GC回收,完全无并发安全问题。
误区纠正:很多人以为写时复制是每次写都要全量复制map,实则sync.Map的COW设计,仅在第一次新增key时浅拷贝read内容到dirty,后续新增key直接加锁写入dirty,无需复制;仅当dirty提升为read后,下一次新增key才会再次复制,复制频率由misses阈值严格控制,开销极低。
# 四、延迟删除的优雅设计:避免锁竞争与map结构频繁变更
sync.Map的删除操作,是最容易被忽略却又极其精妙的设计。它没有采用直接删除key的方式,而是用软删除+延迟清理的机制,把删除操作也变成了无锁的原子操作,仅在必要时才真正清理删除的key,大幅减少了锁的持有时间和map结构的变更频率。
# Delete 方法核心源码
// Delete 删除key
func (m *Map) Delete(key any) {
m.LoadAndDelete(key)
}
// LoadAndDelete 删除key,返回被删除的value和是否存在
func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
// 1. 先无锁加载read,查找key
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
// 2. read中无key,且dirty有新key,加锁处理
if !ok && read.amended {
m.mu.Lock()
// 双重检查
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok && read.amended {
// key仅在dirty中,直接从dirty删除,这是真正的结构删除
e, ok = m.dirty[key]
delete(m.dirty, key)
// 未命中计数+1
m.missLocked()
}
m.mu.Unlock()
}
// 3. 无对应key,直接返回
if !ok {
return nil, false
}
// 4. 找到entry,原子标记为软删除,完全无锁!
return e.delete()
}
// delete 原子将entry标记为删除,返回旧的value和是否存在
func (e *entry) delete() (value any, ok bool) {
for {
p := atomic.LoadPointer(&e.p)
// 已被删除,直接返回
if p == nil || p == expunged {
return nil, false
}
// CAS将p设为nil,标记为软删除
if atomic.CompareAndSwapPointer(&e.p, p, nil) {
return *(*any)(p), true
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# 延迟删除的设计精髓
# 1. 软删除:无锁的删除操作
对于存在于read map中的key,删除操作全程无需加锁!
不是直接从read map中删除key(那样会修改map结构,需要加锁,还会触发并发读写panic),而是通过CAS原子操作,把entry的
p指针设为nil,标记为软删除。软删除后的key仍存在于read map中,但读取时会判断
p=nil,返回不存在,完全不影响读操作的正确性,同时整个删除过程无锁、无竞争。
# 2. 延迟清理:开销平摊,无额外性能损耗
软删除的key,会在下一次初始化dirty的时候被真正清理,无需单独的清理逻辑。
当新增key需要初始化dirty时,会调用
dirtyLocked方法遍历read map的所有entry,调用tryExpungeLocked方法。对于已软删除(
p=nil)的entry,会通过CAS将p设为expunged,标记为彻底清除,不会被复制到dirty中。当dirty被提升为新的read map后,旧的read map失去引用,会被GC回收,这些被标记为
expunged的key就会被彻底清理,完全不需要额外的锁操作和遍历开销。这个设计的精妙之处在于:把删除key的清理开销,合并到了新增key的写时复制过程中,不需要单独启动goroutine清理,也不会在删除时占用锁的时间,把批量清理的开销平摊到了极少的写操作中。
# 3. expunged状态:解决软删除的并发冲突
expunged哨兵指针是整个删除逻辑的灵魂,它完美解决了软删除后,再次Store同一个key的并发冲突问题。
当一个entry被标记为
expunged后,说明它已被从dirty中剔除,read map中有这个key,但dirty中没有。此时如果再次Store这个key,不能直接无锁更新entry,因为dirty中没有这个key,会导致dirty提升时,这个key丢失。
因此
tryStore方法会判断,若entry是expunged状态,直接返回失败,进入加锁逻辑:调用unexpungeLocked方法将entry从expunged状态恢复为nil,再把entry加入dirty,最后更新value,保证dirty始终包含全量有效key。expunged状态完美区分了“软删除但未清理的entry”和“已清理出dirty的entry”,解决了软删除后再次写入的并发安全问题,同时没有增加任何额外的锁开销。
# 五、设计精髓总结与开发启示
sync.Map 不是为了替代所有的mutex+map方案,而是针对读多写少、键值对更新频繁、新增删除少的场景做了极致优化,它的核心设计思想,值得所有开发者学习和借鉴:
读写分离,冷热隔离:把读热点数据放到无锁的read map,把写操作(结构变更)放到加锁的dirty map,让绝大多数读操作完全避开锁竞争,这是高并发容器设计的核心思路。
写时复制,懒加载+浅拷贝:不是每次写都复制,而是把复制延迟到必要的时候,且仅做浅拷贝,把复制开销降到最低;同时通过原子替换实现零拷贝的map升级,完美平衡了读写性能。
原子操作替代锁:把value包装成支持原子操作的entry,让更新、删除操作都可以通过CAS原子操作完成,完全不需要锁,大幅降低了并发竞争的开销。
延迟处理,平摊开销:删除操作采用软删除+延迟清理,把批量清理的开销平摊到极少的写操作中,避免了高频操作的性能损耗,同时减少了锁的持有时间。
双重检查,保证并发安全:所有加锁操作前,都先做无锁检查,加锁后再次做双重检查,既避免了无意义的加锁,又保证了并发安全,是无锁编程的经典范式。
# 适用场景与避坑指南
适合场景:读多写少,尤其是key的更新远多于新增;多个goroutine读写不相交的key集合;并发访问的热点key集中,冷key很少。
不适合场景:写多读少,尤其是频繁新增大量新key的场景(会导致dirty频繁初始化和提升,性能反而不如
mutex+map);遍历操作非常频繁的场景。