读写分离sync.Map

sync.Map 概述

在 Go 中,普通的 map 在并发读写时是不安全的,需要通过 sync.Mutexsync.RWMutex 来保护。

为了减少手动加锁的复杂度和一些场景的性能开销,Go 在 Go1.9 引入了 **sync.Map**,一个并发安全的 map 实现。其提供了以下的特性:

  • 并发安全:sync.Map 支持并发读写操作,读操作和写操作都能安全地在多个 Goroutine 中进行。
  • 读写分离:sync.Map 内部实现优化了读操作的性能,特别是在高并发场景下。

基础用法

sync.Map 提供了几种主要的方法:

  • **Load(key interface{}) (value interface{}, ok bool)**:
    • sync.Map 中加载指定 key 对应的值。
    • 如果 key 存在,则返回值和 true,否则返回 nilfalse
  • **Store(key, value interface{})**:
    • 存储或更新指定 key 对应的值。
  • **Delete(key interface{})**:
    • 删除指定 key 对应的值。
  • **Range(f func(key, value interface{}) bool)**:
    • 遍历 sync.Map 中的所有键值对。f 是一个回调函数,用于处理每个键值对。如果 f 返回 false,则遍历会中止。

示例

sync.Map 没有 make,直接定义就能用:

1
var m sync.Map

(1)Store

存储键值对(类似 map[key] = value

1
m.Store("name", "Jack")

(2)Load

读取键值

1
2
3
4
value, ok := m.Load("name")
if ok {
fmt.Println("找到:", value)
}

(3)LoadOrStore

如果 key 已经存在,返回旧值和 true
如果 key 不存在,存储新值并返回它和 false

1
2
actual, loaded := m.LoadOrStore("age", 25)
fmt.Println(actual, loaded) // 如果之前有值,loaded = true

(4)Delete

删除一个键

1
m.Delete("name")

(5)Range

遍历所有键值对(函数返回 true 才继续遍历)

1
2
3
4
m.Range(func(key, value any) bool {
fmt.Println("key:", key, "value:", value)
return true
})

读写分离的实现原理

简要版

sync.Map 通过内部的设计来优化读操作,包括以下机制:

1. 读操作优化

  • 读缓存

    • sync.Map 通过内置的读缓存来优化读操作。当一个键值对被读取时,它会被存储在一个专用的读缓存中。之后的读取操作会优先访问这个缓存,而不是直接访问底层的存储结构。
  • 读优化数据结构

    • sync.Map 使用了一种特殊的数据结构(分层的数据结构),例如写时复制(Copy-on-write)和延迟删除,来提高读取性能。在并发情况下,读操作不需要加锁,可以直接从缓存中读取数据,从而减少锁竞争的开销。

2. 写操作

  • 写操作锁定

    • 虽然 sync.Map 优化了读操作,但写操作仍然需要加锁,以保证并发环境下的正确性。写操作包括存储、更新和删除操作,它们会获取锁以确保数据一致性。
  • 分离读写

    • sync.Map 的设计允许读操作和写操作在不同的数据结构中进行,从而避免了读操作对写操作的阻塞。通过这种方式,读操作可以在没有锁的情况下进行,而写操作则会进行锁定。

详细版

sync.Map一个只读快表 + 一个“脏”表 + 少量锁与原子操作的组合来保证读操作的无锁快速路径,同时在必要时把写入合并到脏表并“按需”把脏表提升为新的只读表,从而在读多写少或“写只发生一次后大量读取”的场景下减少锁竞争、提高吞吐。


关键数据结构(基于源码)

在源码中,核心是 type Map struct,主要字段为:

  • mu Mutex:用于保护对脏表(dirty)和其他状态的修改(写路径的互斥)。
  • read atomic.Pointer[readOnly]:只读视图(immutable),可以无锁安全读取。readOnly 包含 m map[any]*entryamended bool(如果 dirtyread.m 没有的键则为 true)。
  • dirty map[any]*entry:脏表,需要持 mu 时访问;脏表包含了那些在 read 中不存在或未被“清理”的项。
  • misses int:统计“读没命中只读表需要去锁查脏表”的次数,用来判断何时把 dirty 提升为新的只读表(以 amortize 拷贝成本)。

readOnly 是不可变的结构体(通过 atomic 存取),这样读者可以无锁读取该结构体的 map 引用。


entry:每个 key 的内部表示与三种状态

每个 key 对应 *entry,其中最重要的是 p atomic.Pointer[any] —— 它指向 *interface{} 值 的指针。p 的含义(源码注释可见)可以分成三种主要状态:

  1. 有效(valid)p 指向一个 *interface{}(即保存了 value),表示存在且可读写(用原子替换)。
  2. 已删除(deleted but not expunged)p == nil —— 表示该 entry 已被删除(但是在某些时刻,脏表/只读表的管理会决定是否把它“真正清除/标记为 expunged”)。
  3. 已彻底移除 / expungedp == expunged(源码里 var expunged = new(any) 作为特殊哨兵指针)—— 表示该 entry 在脏表中不存在且已被标记为不可恢复(要再写入需先把它“unexpunge”并放进 dirty)。

这些状态通过原子 Load/CompareAndSwap/Swap 操作在 entry 层面进行转换(因此单个 entry 可以在并发场景下做无锁更新或删除的部分工作)。


操作流程(按方法逐一描述核心块/慢路径与状态转移)

Load(key)

  1. 快路径(无锁):读取 read := m.loadReadOnly()(原子读),查 read.m[key]。如果找到对应 *entry,再调用 entry.load()p := e.p.Load(); if p==nil||p==expunged return notfound; else return *p)。这就是绝大多数读的无锁快路径。
  2. 慢路径(需要锁):如果 read.m 没找到且 read.amended == true(说明 dirty 里可能有该 key),则会 m.mu.Lock(),重新读取 read(防止竞争),然后去 m.dirty 查找;无论脏表是否命中,都会调用 m.missLocked() 记录一次 miss(因为这个 key 走了慢路径)。missLocked 负责在累计足够多的 miss 后把 dirty 推升为新的 read,以 amortize 拷贝成本。

Store / Swap / LoadOrStore

  • Store 实际上是通过 Swap 实现的。Swapread 做尝试性无锁更新(例如尝试在 entry 上做 trySwap),如果失败(例如 entry 不存在或被 expunged),则进入持锁路径:
    • 如果 read 中存在但是 expunged,需要先 unexpungeLocked(将 expunged -> nil),并把 entry 放入 m.dirty
    • 如果 m.dirty 为 nil 且要添加新键,调用 dirtyLocked():它会以 read.m 为蓝本创建 dirty 的浅拷贝(排除已经 expunged 的 entry),并把 read 标记为 amended=true(表示 dirty 含有 read 没有的 key),然后把新键加入 dirty
  • LoadOrStore 先尝试只读表的快路径(无锁),命中时直接调用 entry.tryLoadOrStore(原子尝试);否则走锁路径,必要时初始化 dirty 并把新 entry 放入 dirty,同时可能 m.read.Store(&readOnly{... amended:true})。总之写入通常会落到 dirty,以避免频繁拷贝 read

Delete / LoadAndDelete

  • 删除对 entry.p 做原子替换为 nilCompareAndSwap(p, nil)),这将使 entry.load() 以后返回不存在。注意:当 dirty 被下次创建时,tryExpungeLocked 会把 nil 状态的 entry 变为 expunged(即 CompareAndSwap(nil, expunged)),从而在 read 中表明这是“不可恢复并且不应当出现在 dirty 中”的条目。

Range

  • 如果 read.amended == false,则 read.m 可以被安全遍历(只读视图)。如果 amended == true(说明 m.dirtyread 没有的键),Range 会持锁并“把 dirty 立刻提升为新的 read”(把 read 替换为 readOnly{m: m.dirty},并把 m.dirty = nilm.misses=0),然后再遍历。源码也明确指出 Range 不保证一致性快照:并发的 Store/Delete 可能导致看到不同时间点的映射。

miss / promotion 策略(为什么有 misses)

  • 每次读在只读表 miss 并且需要锁去查脏表时,会调用 m.missLocked():它会 m.misses++,当 m.misses >= len(m.dirty) 时,认为把 dirty 直接提升为 read 的成本被 amortized(相当于 “多次慢查找的成本 >= 一次把 dirty 拷贝为 read 的成本”),于是把 m.read.Store(&readOnly{m: m.dirty})m.dirty = nil,并 m.misses = 0。这就是“慢查次数触发脏表提升”的策略,目的是在读多、某些键频繁走慢路径时避免长期的慢查找。

entry 的 expunge/unexpunge 细节(为什么要 expunged)

  • 设计目标:在不频繁分配/复制读表的情况下,既要支持删除又要在必要时“让写能重新写回被删除的 key”。
  • 实现细节:
    • 删操作先把 pnil(代表已删除,但 read 中依然可能保有 entry 指针)。
    • dirty 被创建(例如第一次发生写入导致 dirtyLocked),tryExpungeLocked 会把那些 p==nil 的 entry 原子改成 expungedexpunged 的意思是“这个 entry 不应出现在 dirty 中,且要把它从 read 的语义上视为彻底移除”。
    • 如果后来想在该 key 上写入,且 read 中该 entry 是 expunged,则要先 unexpungeLocked(把 expunged -> nil),并把 entry 明确放入 dirty,然后在 dirty 上写入。这样能安全地让“被标记为彻底删除”的项被重新写回。

为何采用这种“read + dirty + expunge + miss”设计?(设计动机)

  • 目标是让绝大多数读操作走无锁快路径(仅一次原子加载 read 指针 + map 查找 + entry 的原子加载),避免读侧锁竞争。
  • 写入与删除被推到脏表并在恰当时机批量合并(promote)到只读视图,以把单次写造成的开销摊薄(amortize)。
  • 适合两类场景(源码注释指出):
    1. key 只写一次但读很多(如只增不删的缓存);
    2. 多 goroutine 对互斥的 key 集合进行读写(不同 key 集合互不干扰,读少写少的冲突少)。
      否则(写非常频繁且热点很大),普通 map + RWMutex 可能更简单且更快。

状态机小结(便于记忆)

  • entry.p:
    • nil —— 已删除(但尚未被 expunge)
    • expunged —— 已彻底移除(在 dirty 中不存在),写入前需 unexpunge
    • 指向 *interface{} —— 有效值
  • Map 层面:
    • read:无锁可读的稳定视图(immutable);
    • dirty:需锁访问的可变视图,包含 read 未包含的键(read.amended==true 表示存在这种情况);
    • misses:控制何时把 dirty 升级为 read。

(以上实现细节均直接反映在 Go 标准库 src/sync/map.go 中的实现与注释。)Go

适用场景

sync.Map 适用于以下场景:

  • 高并发读多写少:在读操作远远多于写操作的情况下,sync.Map 的性能优势明显。
  • 需要并发安全的映射:当你需要在多个 Goroutine 中安全地读写映射时,sync.Map 是一个很好的选择。但是写操作频繁时,不如 map + RWMutex 高效。