sync.Map 详解
Go 的 sync.Map 是标准库里专门为并发读多、写少场景优化的一种 Map 实现。它和普通 map + sync.RWMutex 最大的区别是:
-
普通 map:所有访问都围绕一把锁。
-
sync.Map:通过 read-only map + dirty map + 原子操作 + 延迟提升机制,减少锁竞争。
下面我们结合 Go 源码(以 Go 1.20+ 实现为例)分析。
源码位置:
src/sync/map.go
1. sync.Map 的核心结构
源码:
type Map struct {
mu Mutex
// read 包含只读数据
read atomic.Pointer[readOnly]
// dirty 包含最新数据
dirty map[any]*entry
// misses 记录 read 中没有命中的次数
misses int
}
可以画成:
sync.Map
+----------------+
| read |
| (无锁读取) |
+----------------+
|
|
miss 后查找
|
v
+----------------+
| dirty |
| (加锁访问) |
+----------------+
|
misses 达阈值
|
v
dirty -> read
三个核心字段:
| 字段 | 作用 |
|---|---|
| read | 读热点数据,支持无锁访问 |
| dirty | 最新数据,需要加锁 |
| misses | 统计 read 未命中的次数 |
2. readOnly 结构
继续看源码:
type readOnly struct {
m map[any]*entry
amended bool
}
其中:
m map[any]*entry
是真正的数据。
例如:
read
{
"name": entry1,
"age": entry2
}
amended 非常关键:
amended bool
表示:
dirty 是否包含 read 没有的新 key
例如:
read:
{
a:1,
b:2
}
dirty:
{
a:1,
b:2,
c:3
}
那么:
amended=true
表示:
dirty 比 read 更新
3. entry 结构
sync.Map 并不是直接存 value:
源码:
type entry struct {
p atomic.Pointer[any]
}
结构:
entry
+-------------+
| atomic ptr |
+-------------+
|
v
value
为什么不用:
map[key]interface{}
而设计成:
key -> entry -> value
原因:
为了支持:
-
原子修改
-
删除
-
替换
例如:
key="a"
entry
|
|
v
100
修改:
100
↓
200
只需要:
atomic.StorePointer()
不用加锁。
4. Load() 源码分析
我们先看读取。
源码:
func (m *Map) Load(key any) (value any, ok bool) {
read, _ := m.read.Load()
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
read, _ = m.read.Load()
e, ok = read.m[key]
if !ok {
e, ok = m.dirty[key]
m.missLocked()
}
m.mu.Unlock()
}
if ok {
return e.load()
}
return nil,false
}
拆开看。
第一步:无锁查 read
read,_ := m.read.Load()
atomic 加载。
然后:
e, ok := read.m[key]
直接查。
例如:
Load("name")
read
+------+
name ->|entry |
+------+
直接返回
没有锁。
这就是 sync.Map 快的原因。
5. read 找不到怎么办?
假设:
read:
{
a:1
}
dirty:
{
a:1,
b:2
}
查询:
Load("b")
read 找不到:
read miss
但是:
read.amended=true
说明:
dirty 里面可能有。
于是:
m.mu.Lock()
进入慢路径。
然后:
e,ok=m.dirty[key]
查 dirty。
6. misses 机制
重点来了。
每次:
read miss
+
dirty 查成功
都会:
m.missLocked()
源码:
func (m *Map) missLocked(){
m.misses++
if m.misses < len(m.dirty){
return
}
m.read.Store(&readOnly{
m:m.dirty,
})
m.dirty=nil
m.misses=0
}
意思:
如果:
miss次数 >= dirty大小
说明:
大量读取已经绕过 read。
例如:
dirty:
10000 个key
连续:
10000次 read miss
说明:
read 已经过期。
直接:
dirty 提升 read
变成:
旧:
read
|
|
dirty
新:
read
|
最新数据
7. Store() 写入流程
源码:
func (m *Map) Store(key,value any){
read,_:=m.read.Load()
if e,ok:=read.m[key];ok{
if e.tryStore(&value){
return
}
}
m.mu.Lock()
defer m.mu.Unlock()
...
}
情况1:key 已存在
例如:
read:
a -> entry
执行:
tryStore()
直接:
atomic CAS
不用锁。
类似:
entry
100
↓
200
情况2:新 key
例如:
Store("new",1)
read:
没有
进入:
mu.Lock()
然后:
dirty 添加。
8. dirty 是怎么生成的?
第一次写:
例如:
m.Store("a",1)
此时:
read:
nil
dirty:
nil
加锁。
执行:
dirtyLocked()
源码:
func (m *Map) dirtyLocked(){
if m.dirty!=nil{
return
}
m.dirty=make(map[any]*entry)
for k,e:=range read.m{
m.dirty[k]=e
}
}
复制:
read
a
b
c
复制
dirty
a
b
c
然后新增:
dirty
a
b
c
d <-- 新数据
并:
read.amended=true
9. Delete 删除
删除非常有意思。
源码:
func (m *Map) Delete(key any){
read,_:=m.read.Load()
if e,ok:=read.m[key];ok{
e.delete()
}
}
删除不是直接 remove map。
而是:
entry
value
↓
nil
也就是:
逻辑删除。
为什么?
因为:
read map:
key
|
entry
|
nil
这样:
Load
发现:
nil
即可。
避免修改 map 结构。
10. 为什么不用普通 map + RWMutex?
比较:
RWMutex Map
结构:
map
+
RWMutex
读:
RLock
读取
RUnlock
大量 goroutine:
10000读
↓
锁竞争
sync.Map
读:
atomic.Load
直接访问
无锁
结构:
goroutine
|
|
v
read map
11. sync.Map 适用场景
源码注释:
The Map type is optimized for two common use cases:
场景1:只写一次,多读
例如:
缓存:
配置中心
key -> config
写:
启动加载一次
读:
百万次
非常适合。
场景2:多个 goroutine 操作不同 key
例如:
用户ID -> session
goroutine1:
user100
goroutine2:
user200
写之间没有竞争。
12. 不适合的场景
例如:
频繁:
Store
Store
Store
Delete
Store
Delete
热点 key:
key="count"
10000 goroutine
同时修改 count
sync.Map 优势下降。
普通:
map + RWMutex
可能更快。
13. 整体流程图
Load(key)
|
v
查询 read map
|
+----------+----------+
| |
命中 未命中
| |
v v
atomic读取 amended?
|
+-------+------+
| |
no yes
| |
返回 加锁查dirty
|
v
miss++
|
misses >= len(dirty)?
|
+------+------+
| |
yes no
|
v
dirty提升read
14. 一句话总结 sync.Map 原理
sync.Map 本质是 用空间换锁竞争:维护一个只读 read Map,通过 atomic 实现无锁读取;写入进入 dirty Map;当 read 命中率下降时,把 dirty 整体提升为新的 read,从而在读多写少场景获得极高性能。
如果你理解过 Go map 扩容机制,可以把它类比成:
sync.Map 的 read/dirty
≈
Go map 扩容中的 oldBucket/newBucket
read 是旧稳定视图
dirty 是正在变化的新视图
通过渐进式迁移避免一次性阻塞
这个类比其实非常接近 sync.Map 的设计思想。
这是理解 sync.Map 的一个关键点。你问的这个过程对应源码里的:
func (m *Map) dirtyLocked() {
if m.dirty != nil {
return
}
read, _ := m.read.Load()
m.dirty = make(map[any]*entry, len(read.m))
for k, e := range read.m {
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}
也就是:
当第一次向一个已经有 read 数据的 sync.Map 写入新 key 时,为什么要把 read 全量复制到 dirty?
先说结论
因为 dirty 必须成为一个完整的数据视图。
之后:
-
read:只读快照
-
dirty:可修改版本
所有未来的修改都发生在 dirty 上。
如果 dirty 不包含 read 中已有的数据,那么:
写入 dirty 后,read 和 dirty 会出现两个不一致的数据集合,后续无法正确提升。
我们一步一步看。
1. 初始状态
假设:
m.Store("a", 1)
m.Store("b", 2)
经过一段时间后:
read
+-----+
| a 1 |
| b 2 |
+-----+
dirty=nil
为什么 dirty 是 nil?
因为:
-
当前所有数据都稳定
-
没有新数据
-
read 已经足够提供无锁读取
所以:
read = 完整数据
dirty = 不存在
2. 现在新增一个 key
执行:
m.Store("c",3)
流程:
先查:
read["c"]
不存在:
read
a
b
没有 c
于是进入:
m.mu.Lock()
发现:
dirty == nil
于是调用:
dirtyLocked()
3. 为什么不能直接:
dirty
c -> 3
呢?
看起来好像可以:
read:
a
b
dirty:
c
但是问题来了。
问题1:dirty 不是增量表,而是未来版本
sync.Map 后面有一个动作:
dirty -> read
也就是:
提升
例如:
现在:
read:
a
b
dirty:
a
b
c
提升后:
read:
a
b
c
非常简单。
但是如果:
read:
a
b
dirty:
c
提升:
read = dirty
结果:
read:
c
原来的:
a
b
丢失。
所以 dirty 必须包含完整数据。
4. 问题2:删除和修改怎么办?
假设:
read:
a=1
b=2
然后:
Store("a",100)
如果 dirty 只有新增:
dirty:
a=100
那么:
read:
a=1
b=2
dirty:
a=100
两个版本如何合并?
sync.Map 不做 merge。
它设计的是:
read
|
| miss
|
dirty
|
| 达到条件
|
v
read = dirty
即:
dirty 是 read 的未来完整版本。
不是:
read + dirty
合并。
5. 为什么不一直维护 dirty?
因为性能。
假设:
m.Store("user1",xxx)
之后:
99.99% 都是:
Load("user1")
如果一直保持:
read
user1
dirty
user1
浪费内存。
所以设计:
稳定阶段:
read
|
|
无 dirty
变化阶段:
read
|
|
dirty
6. 这个设计类似 Copy-On-Write
其实 sync.Map 用的是:
Copy-On-Write(写时复制)
类似:
Linux fork:
父进程内存
|
|
写入时复制
|
v
新的副本
sync.Map:
稳定状态:
read
a
b
写操作发生:
read
a
b
↓ copy
dirty
a
b
c
7. 那为什么 read 不直接修改?
因为 read 要保证:
Load()
无锁。
如果修改:
goroutine1:
Load("a")
goroutine2:
Store("a")
同时修改 map:
会导致:
fatal error:
concurrent map read and map write
Go 原生 map 不允许。
所以:
read:
永远只读
dirty:
允许修改
8. 为什么复制 entry,而不是复制 value?
注意源码:
m.dirty[k] = e
不是:
m.dirty[k] = newEntry(value)
复制的是:
entry 指针
结构:
read
a
|
v
entry
|
v
100
dirty
a
|
v
同一个 entry
|
v
100
共享 entry。
这样:
修改:
entry.value
100
↓
200
通过 atomic 更新即可。
避免复制大量对象。
9. 整个过程总结
当:
read存在
dirty=nil
新增key
执行:
read
A
B
↓
dirty复制
read
A
B
dirty
A
B
C
原因:
-
dirty 必须是完整数据集
-
dirty 是未来 read 的来源
-
避免 read/dirty 合并
-
保证提升时不会丢数据
-
保证 read 永远只读,实现无锁读取
所以一句话:
dirtyLocked()复制 read 不是为了保存历史,而是为了创建一个“可修改的新版本”,后续所有写操作都在这个新版本上进行,最终整体替换 read,这就是 sync.Map 的 Copy-On-Write 思想。
这个设计和你之前问的 Go map 扩容 old bucket → new bucket 有很强的相似性:都是维护两个版本,通过逐步切换避免一次性阻塞。