sync.map底层原理

一、sync.Map 的设计哲学

在 Go 中,对一个普通的 map 进行并发读写操作是不安全的,会导致程序 panic。常规的解决方案是使用互斥锁 sync.Mutex 或读写锁 sync.RWMutex 来保护 map

// 使用读写锁保护map
var mu sync.RWMutex
m := make(map[string]int)

// 写
mu.Lock()
m["key"] = 1
mu.Unlock()

// 读
mu.RLock()
_ = m["key"]
mu.RUnlock()

这种方式在大多数情况下都很好用。但是,当读操作的频率远远高于写操作时,sync.RWMutex 仍然存在性能瓶颈:

  • 锁竞争 (Lock Contention): 即使是读锁,在大量并发goroutine的情况下,对锁状态的原子操作(如增加/减少读计数)也会在CPU缓存一致性协议(MESI)层面引起 "缓存行伪共享" (False Sharing) 问题,导致性能下降。
  • 写操作饥饿读操作: 一个写锁请求可能会阻塞后续的所有读锁请求,直到写锁被获取并释放。

sync.Map 的设计目标就是为了解决这个问题,它专为 “读多写少” 的场景优化,核心思想是空间换时间读写分离,实现最大程度的无锁读取

二、核心数据结构 (sync.Map 源码)

要理解 sync.Map,首先要看它的核心结构体定义。

// src/sync/map.go

type Map struct {
	mu Mutex // 互斥锁,用于保护dirty map

	// read 是一个 atomic.Pointer,指向一个 readOnly 结构体。
	// 它包含了map中一部分键值对,专门用于无锁读取。
	// 对 read 的所有操作都必须是原子的。
	read atomic.Pointer[readOnly]

	// dirty 是一个普通的map,包含了最新的键值对。
	// 当写入时,如果key在read中不存在,则会写入dirty。
	// 访问 dirty 前必须持有 mu 锁。
	// 为了节省空间,当dirty为nil时,它被隐式地认为是read中数据的超集。
	dirty map[any]*entry

	// misses 记录了当在 read 中找不到数据,需要加锁去访问 dirty 的次数。
	// 当 misses 达到一定阈值时,会将 dirty 的数据提升(promote)为新的 read。
	misses int
}

// readOnly 是一个只读的数据结构,存储在 Map.read 中
type readOnly struct {
	m       map[any]*entry
	amended bool // 如果 dirty map 中包含了 read.m 中没有的数据,则为 true
}

// entry 是 map 中存储的值的真正结构。它不是直接存储value,而是通过指针。
type entry struct {
	// p 是一个指向用户存储的 value 的指针。
	// 它的状态有三种:
	// 1. nil: entry 已经被创建,但值正在从 dirty map 复制到 read map 的过程中。
	//         如果一个 goroutine 读到 nil,它需要等待复制完成。
	// 2. expunged: 表示这个 entry 已经被删除。这是一个标记,用于避免在遍历时修改map结构。
	// 3. (其他): 指向实际存储的 value。
	p atomic.Pointer[any]
}

结构总结:

  1. sync.Map 内部有两个 mapreaddirty
  2. read 是一个 atomic.Pointer,指向一个 readOnly 结构。这个结构体里的 map (read.m) 是只读的,专门用于并发读取,不需要加锁
  3. dirty 是一个普通的 go map,用于写入和存储最新的数据。所有对 dirty 的操作都必须加锁 (mu)
  4. entry 结构体通过原子指针 p 包装了真正的 value,这使得对单个 value 的修改(更新或逻辑删除)可以原子地完成,而无需锁定整个 map
  5. misses 是一个计数器,是连接 readdirty 的关键,用于触发数据迁移。

三、readdirty 的作用

这是 sync.Map 的核心设计:

  • read (只读层):

    • 作用: 提供无锁的、极速的并发读取能力。
    • 工作方式: 大部分的 Load (读取) 操作直接访问 read 指向的 map。因为 read.m 本身是只读的,所以多个 goroutine 同时读取它不会有任何数据竞争。
    • 数据来源: 它的数据来自于过去的 dirty map。
  • dirty (读写层):

    • 作用: 处理所有写入操作和 read 中找不到的读取操作。它是数据的权威来源。
    • 工作方式: 任何 Store (写入) 或 Delete (删除) 都必须先获取 mu 锁,然后操作 dirty map。如果一个 Load 操作在 read 中没找到数据 (miss),它也必须加锁后去 dirty 中查找。
    • 特性: dirty map 如果存在,它总是包含了 read.m 的所有数据,以及任何新增或修改的数据。

两者关系: 可以把 read 看作是 dirty 的一个快照或缓存。读取时先查 read,查不到再去查 dirty。写入时直接操作 dirty。当 dirty 中积累了足够多的新数据后,会触发一次“数据搬迁”,将 dirty 整体提升为新的 read


四、数据读取过程 (Load 方法)

Load 的逻辑体现了 sync.Map 的分层设计。

  1. 快路径 (Fast Path) - 无锁读取:

    • 原子地加载 read 指针,获取 readOnly 结构。
    • read.m 这个 map 中查找 key
    • 情况 A (命中): 如果找到了 entry,并且 entry.p 指针不是 expunged (未被删除),则原子地加载 entry.p 指向的 value 并返回。这是最快、最理想的情况。
    • 情况 B (未命中): 如果在 read.m 中没有找到 key,或者 entry 被标记为 expunged,则进入慢路径。
  2. 慢路径 (Slow Path) - 加锁回源:

    • 加锁 m.mu.Lock()
    • 双重检查 (Double-Checking): 再次检查 read。因为从快路径判断失误到加锁成功这段时间,read 可能已经被其他 goroutine 更新了。如果此时在新的 read 中找到了数据,就直接返回。这可以避免不必要的 dirty 操作。
    • 查询 dirty: 如果双重检查后 read 中仍然没有,就去 dirty map 中查找。
      • 如果 dirty 中找到了,说明数据是最新的,直接返回。
      • 如果 dirty 中也没找到,说明这个 key 确实不存在,返回 nil, false
    • 记录 Miss: 每次在 read 中未命中,最终需要去 dirty 中查找的,都会让 misses 计数器加一。
    • 触发数据搬迁: 如果 misses 的数量达到了 len(m.dirty),意味着有大量的数据只存在于 dirty 中,read 的缓存命中率已经很低了。此时会触发数据搬迁过程(missLocked 方法)。
    • 解锁 m.mu.Unlock()

五、数据写入过程 (Store 方法)

Store 过程比 Load 更复杂,因为它可能需要创建 dirty map。

  1. 尝试更新 read (无锁):

    • 首先,它会尝试在不加锁的情况下,直接更新 read 中的 entry
    • 它在 read.m 中查找 key。如果找到了对应的 entry,并且这个 entry 没有被标记为 expunged,它会尝试使用 atomic.CompareAndSwapPointer (CAS) 操作,原子地将 entry.p 指针从旧值替换为新值。
    • 如果 CAS 成功, 操作完成!这是一个非常高效的 "更新" 操作,因为它没有使用任何锁,只做了一次原子操作。
  2. 加锁写入 dirty (慢路径):

    • 如果上述无锁更新失败(比如 key 不在 read 中,或者 entry 已被删除,或者 CAS 竞争失败),就必须加锁。
    • m.mu.Lock()
    • 再次检查 read:Load 类似,加锁后再次检查 read。因为 read 可能已被更新。
      • 如果 keyread 中,但被标记为 expunged,说明这个 key 正在被删除或已经被删除。这时,必须确保它也在 dirty 中。
      • 如果 keyread 中且有效,直接更新其 entry 的指针 p.Store(value)
    • 操作 dirty map:
      • 如果 dirty map 为 nil: 说明这是自上次数据搬迁以来的第一次写入。此时需要初始化 dirty map,并将 read.m 中的所有未被删除的数据复制到新的 dirty map 中。
      • dirty map 中添加或更新 keyentry
    • m.mu.Unlock()

核心点: Store 会优先尝试原子更新,失败后才会加锁操作 dirty。如果 dirty 不存在,它会把 read 的内容完整地 "拷贝" 过来作为基础,再进行写入。


六、数据搬迁过程 (missLocked 方法)

数据搬迁是 sync.Map 性能调优的精髓,它发生在 Load 的慢路径中,当 misses >= len(m.dirty) 时被触发。这个过程必须在持有锁的情况下进行。

  1. 提升 dirtyread:

    • sync.Map 将当前的 dirty map 直接提升为新的 read map。
    • 它创建一个新的 readOnly 结构,其 m 字段就是当前的 m.dirty
    • 使用 m.read.Store() 原子地将 Map.read 指针指向这个新的 readOnly 结构。
  2. 清空 dirty:

    • m.dirty 设置为 nil
    • m.misses 计数器重置为 0。
  3. 完成:

    • 解锁。

搬迁过程的意义:

  • 原子性: 整个 read 的切换是一瞬间完成的原子操作 (m.read.Store)。正在并发读取的 goroutine 要么读到旧的 read,要么读到新的 read,不会读到中间状态。
  • 垃圾回收: 旧的 readOnly 结构在没有任何 goroutine 引用它之后,会被 Go 的垃圾回收器自动回收。这同时清理了旧 read 中所有被标记为 expunged 的 "垃圾" 数据。
  • 性能提升: 搬迁后,所有之前只存在于 dirty 中的新数据现在都存在于 read 中了,后续对这些数据的读取将直接在 read 中命中,回到快速无锁路径。

七、总结与适用场景

  • 原理总结: sync.Map 使用 readdirty 两个 map 实现了读写分离。read 用于无锁并发读取,dirty 用于加锁写入。通过 misses 计数器和数据搬迁机制,动态地将 dirty 中的新数据同步到 read 中,从而在“读多写少”的场景下,最大化地减少锁竞争,提升读取性能。
  • Delete 操作: 删除操作是通过将 entry 的指针 p 原子地替换为 expunged 标记来实现的(逻辑删除)。这个被标记的 entry 会在下一次数据搬迁时被物理地清除。
  • 适用场景:
    • 当一个 key 只会被写入一次,但会被读取很多次时(如缓存场景)。
    • 当不同的 goroutine 操作不同的 key 集合,冲突很少时。
  • 不适用场景:
    • 写多读少: 大量的写操作会导致频繁的锁竞争和数据搬迁,性能可能还不如 map + sync.RWMutex
    • 需要遍历: sync.MapRange 方法为了保证一致性,可能会遍历 readdirty 两个 map,且在遍历期间可能需要加锁,性能不稳定。如果需要频繁遍历,map + RWMutex 更可控。

打 赏