读写分离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
高效。