读写分离sync.Map
sync.Map 概述
在 Go 中,普通的 map 在并发读写时是不安全的,需要通过 sync.Mutex 或 sync.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,否则返回nil和false。
- 从
- **
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 | value, ok := m.Load("name") |
(3)LoadOrStore
如果 key 已经存在,返回旧值和 true;
如果 key 不存在,存储新值并返回它和 false。
1 | actual, loaded := m.LoadOrStore("age", 25) |
(4)Delete
删除一个键
1 | m.Delete("name") |
(5)Range
遍历所有键值对(函数返回 true 才继续遍历)
1 | m.Range(func(key, value any) bool { |
读写分离的实现原理
简要版
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]*entry和amended bool(如果dirty有read.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 的含义(源码注释可见)可以分成三种主要状态:
- 有效(valid):
p指向一个*interface{}(即保存了 value),表示存在且可读写(用原子替换)。 - 已删除(deleted but not expunged):
p == nil—— 表示该 entry 已被删除(但是在某些时刻,脏表/只读表的管理会决定是否把它“真正清除/标记为 expunged”)。 - 已彻底移除 / expunged:
p == expunged(源码里var expunged = new(any)作为特殊哨兵指针)—— 表示该 entry 在脏表中不存在且已被标记为不可恢复(要再写入需先把它“unexpunge”并放进 dirty)。
这些状态通过原子 Load/CompareAndSwap/Swap 操作在 entry 层面进行转换(因此单个 entry 可以在并发场景下做无锁更新或删除的部分工作)。
操作流程(按方法逐一描述核心块/慢路径与状态转移)
Load(key)
- 快路径(无锁):读取
read := m.loadReadOnly()(原子读),查read.m[key]。如果找到对应*entry,再调用entry.load()(p := e.p.Load(); if p==nil||p==expunged return notfound; else return *p)。这就是绝大多数读的无锁快路径。 - 慢路径(需要锁):如果
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实现的。Swap对read做尝试性无锁更新(例如尝试在 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做原子替换为nil(CompareAndSwap(p, nil)),这将使entry.load()以后返回不存在。注意:当dirty被下次创建时,tryExpungeLocked会把nil状态的 entry 变为expunged(即CompareAndSwap(nil, expunged)),从而在read中表明这是“不可恢复并且不应当出现在 dirty 中”的条目。
Range
- 如果
read.amended == false,则read.m可以被安全遍历(只读视图)。如果amended == true(说明m.dirty有read没有的键),Range会持锁并“把 dirty 立刻提升为新的 read”(把read替换为readOnly{m: m.dirty},并把m.dirty = nil、m.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”。
- 实现细节:
- 删操作先把
p置nil(代表已删除,但read中依然可能保有 entry 指针)。 - 当
dirty被创建(例如第一次发生写入导致 dirtyLocked),tryExpungeLocked会把那些p==nil的 entry 原子改成expunged。expunged的意思是“这个 entry 不应出现在 dirty 中,且要把它从 read 的语义上视为彻底移除”。 - 如果后来想在该 key 上写入,且
read中该 entry 是expunged,则要先unexpungeLocked(把expunged-> nil),并把 entry 明确放入dirty,然后在dirty上写入。这样能安全地让“被标记为彻底删除”的项被重新写回。
- 删操作先把
为何采用这种“read + dirty + expunge + miss”设计?(设计动机)
- 目标是让绝大多数读操作走无锁快路径(仅一次原子加载 read 指针 + map 查找 + entry 的原子加载),避免读侧锁竞争。
- 写入与删除被推到脏表并在恰当时机批量合并(promote)到只读视图,以把单次写造成的开销摊薄(amortize)。
- 适合两类场景(源码注释指出):
- key 只写一次但读很多(如只增不删的缓存);
- 多 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高效。