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

原因:

  1. dirty 必须是完整数据集

  2. dirty 是未来 read 的来源

  3. 避免 read/dirty 合并

  4. 保证提升时不会丢数据

  5. 保证 read 永远只读,实现无锁读取

所以一句话:

dirtyLocked() 复制 read 不是为了保存历史,而是为了创建一个“可修改的新版本”,后续所有写操作都在这个新版本上进行,最终整体替换 read,这就是 sync.Map 的 Copy-On-Write 思想。

这个设计和你之前问的 Go map 扩容 old bucket → new bucket 有很强的相似性:都是维护两个版本,通过逐步切换避免一次性阻塞。