前言
Go 语言原生 map 并不是线程安全的,要对它进行并发读写操作时,一般有两种选择:
- 原生map搭配Mutex或RWMutex
- 使用sync.Map
本文将介绍sync.map的整体结构,查,增,删,改的实现原理,以及适用场景
数据结构
go
type Map struct {
// 互斥锁,保护对 dirty 的访问
mu Mutex
// 无锁化的只读 map
read atomic.Pointer[readOnly]
// 加锁处理的读写 map
dirty map[any]*entry
// 记录访问 read 的失效次数,累计达到阈值时,达到阈值后重建 dirty
misses int
}
type readOnly struct {
// 实现从 key 到 entry 的映射
m map[any]*entry
// 标识 read map 中的 key-entry 对是否存在缺失,需要通过 dirty map 兜底
amended bool
}
// kv 对中的 value
type entry struct {
p atomic.Pointer[any]
}
read和dirty中,相同key底层引用了同一个entry,因此对read中的entry修改,也会影响到dirty

entry.p 的指向分为三种情况:
状态 | 含义 |
---|---|
nil | entry 被删除(但key仍在 readOnly和dirty中) |
expunged | entry 被删除(key仍在 readOnly中,但不在dirty中)。后续如果Store该key时,才知道要不要往dirty插入该KV |
其他(*interface{}) | 正常值,可通过 *p 解引用获取 |
expunged为一个全局变量
go
var expunged = new(any)
读
go
func (m *Map) Load(key any) (value any, ok bool) {
read := m.loadReadOnly()
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
read = m.loadReadOnly()
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key]
m.missLocked()
}
m.mu.Unlock()
}
if !ok {
return nil, false
}
return e.load()
}
- 从read中尝试获取,如果存在直接返回
- 否则加锁,再次从read中获取一次
- 这里是经典的双重检查做法,在sync.Map中大量使用。因为在从read读和加锁期间,可能有其他线程对map进行了操作,使read中有该键值对了
- 如果还是没有,就从dirty中获取,并执行missLocked方法
由于 readonly 是只读模式,所以新增KV只会插入到dirty 中,导致readonly数据是 dirty 的子集
- sync.Map 中通过
misses
计数器记录 readonly 被读操作击穿的次数- missLocked方法中,不管是否获取成功都对
m.misses++
- missLocked方法中,不管是否获取成功都对
- 当该次数达到阈值时,会将 dirty 中的全量数据覆盖到 readonly
- 目的:将全量的数据提升到read中,使得后续的操作能直接在read中完成,无需加锁访问dirty
go
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
}
entry.load:
go
func (e *entry) load() (value any, ok bool) {
p := e.p.Load()
if p == nil || p == expunged {
return nil, false
}
return *p, true
}
- 如果 entry 的指针状态为 nil 或者 expunged,说明 key-entry 对已被删除,则返回 nil;
- 如果 entry 未被删除,则读取指针内容,并且转为 any 的形式进行返回
写
go
func (m *Map) Store(key, value any) {
_, _ = m.Swap(key, value)
}
- 如果read中存在该键值对,CAS更新其value
- 若不存在,加锁,执行后面的逻辑:
- 如果加锁后发现read中有了,该e是expunged,将其更新为nil,并且给dirty中增加该键值对,因为此时dirty中没有。然后更新e的值
- 如果read没有,但dirty有,更新dirty中该entry的值,返回
- 如果dirty,read都没有
- 如果read.amended是false,需要将read全量拷贝到dirty中
- 如果不是,则只在dirty中增加该键值对
go
func (m *Map) Swap(key, value any) (previous any, loaded bool) {
read := m.loadReadOnly()
// 尝试在 read.m 中查找 key,找到了
if e, ok := read.m[key]; ok {
// 调用 e.trySwap(&value) 尝试无锁交换值
if v, ok := e.trySwap(&value); ok {
if v == nil {
return nil, false
}
return *v, true
}
}
m.mu.Lock()
// 再次读取 read,因为可能在加锁前已被其他 goroutine 更新
read = m.loadReadOnly()
if e, ok := read.m[key]; ok {
// 若 entry.p == expunged,则将其恢复为 nil(表示存在但无值),并返回 true。
if e.unexpungeLocked() {
// 并且给dirty中增加该键值对,因为此时dirty中没有
m.dirty[key] = e
}
// 更新value
if v := e.swapLocked(&value); v != nil {
loaded = true
previous = *v
}
// read没有,但dirty有,更新dirty中该entry的值
} else if e, ok := m.dirty[key]; ok {
if v := e.swapLocked(&value); v != nil {
loaded = true
previous = *v
}
// dirty,read都没有
} else {
if !read.amended {
// 将read全量拷贝到dirty中
m.dirtyLocked()
m.read.Store(&readOnly{m: read.m, amended: true})
}
// 只将键值对加到dirty中
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()
return previous, loaded
}
entry.trySwap:通过CAS修改entry的值
go
func (e *entry) trySwap(i *any) (*any, bool) {
for {
p := e.p.Load()
// 判断当前 entry 是否已被"驱逐"
if p == expunged {
return nil, false
}
if e.p.CompareAndSwap(p, i) {
return p, true
}
}
}
entry.unexpungeLocked:将entry的值从expunged改为nil
go
func (e *entry) unexpungeLocked() (wasExpunged bool) {
return e.p.CompareAndSwap(expunged, nil)
}
dirtyLocked:将所有KV从readOnly拷贝到dirty
amended 状态 | 含义 |
---|---|
FALSE | read 是完整的,dirty 要么为空,要么未初始化 |
TRUE | dirty 中有 read 中没有的 key(即新插入的 key) |
为啥需要拷贝?如果!read.amended
,表示这是第一次向 sync.Map
写入一个新 key,此时必须初始化 dirty
并从 read
拷贝所有有效 entry,以保证 dirty
成为一个完整的可写映射 ,从而支持后续写操作和未来可能的 read
升级
go
func (m *Map) dirtyLocked() {
if m.dirty != nil {
return
}
read := m.loadReadOnly()
m.dirty = make(map[any]*entry, len(read.m))
for k, e := range read.m {
// 如果e之前存储的不是nil,也就是没被删除,才把该KV放到dirty
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}
在拷贝的过程中,对每个entry要先调用tryExpungeLocked方法:
如果entry.P存储nil,将其设置为expunged。表示这个KV在dirty中没有,只在readOnly中有。并返回该entry之前存储的是不是nil
go
func (e *entry) tryExpungeLocked() (isExpunged bool) {
p := e.p.Load()
for p == nil {
if e.p.CompareAndSwap(nil, expunged) {
return true
}
p = e.p.Load()
}
return p == expunged
}
为啥被删除的KV,不会被复制到dirty中?
- 如果复制过去,但后续没有再对这个被删除的键值对进行操作 ,就会浪费内存空间
- 这也是彻底删除该Key的一个环节
- 这样复制过去后,会出现这个key只在
readOnly
中存在,但在dirty
中不存在的情况。此时需要将entry用一个特殊标识expunged
标记,表示这种情况。后面对该key进行Store操作时,才知道:- 是否需要往dirty中插入该KV
- entry为expunged
- 需要加锁
- 还是修改entry即可
- entry为nil
- 无需加锁
- 是否需要往dirty中插入该KV
写操作总的来说就是分各种情况处理:
- read有 :无锁更新read中的数据
- 如果read有,但entry是expunged时,需要加锁,然后给dirty加上该KV
- read没有但dirty有:更新dirty中该entry的值
- read没有dirty也没有 :将新的键值对添加到dirty中
- 如果
read.amended
为false,需要将read中的数据拷贝到dirty中
- 如果
删除
go
func (m *Map) Delete(key any) {
m.LoadAndDelete(key)
}
go
func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
read := m.loadReadOnly()
e, ok := read.m[key]
// // 如果在只读视图中没有找到,并且amended标志为true(意味着有未同步到read map中的dirty数据)
if !ok && read.amended {
m.mu.Lock()
read = m.loadReadOnly()
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key]
// 如果该key不在read中,在dirty中,调用map原生的删除方法删除
delete(m.dirty, key)
m.missLocked()
}
m.mu.Unlock()
}
if ok {
return e.delete()
}
return nil, false
}
entry.delete:将entry.P的值,CAS成nil
go
func (e *entry) delete() (value any, ok bool) {
for {
p := e.p.Load()
if p == nil || p == expunged {
return nil, false
}
if e.p.CompareAndSwap(p, nil) {
return *p, true
}
}
}
- 为啥read的删除不像dirty一样,调用内置delete函数删除?
- 因为read是只读 结构,不能对hash表的结构做修改,而只能做逻辑删除,即将entry.p设为nil
- 那read中该被删除的key,啥时候真正删除 ?
- 触发从read拷贝到dirty时,不会拷贝entry为nil的键值对
- 假设后续没有对该key进行操作,等后续misses达到阈值,将dirty提升为read时,就能真正的从sync.map中删除该键值对
适用场景
- 适用场景:
- 如果key在read中,那么读,更新,删除流程都能在read中快速完成,可以视为广义上的读操作。只有当key不在read时,才需要加锁操作dirty
- 因此,sync.Map适用于
读多写少;更新写多,新增写少的场景
- 不适用的场景:
大量新增键值对操作
:这种场景下,整个map退化为单线程操作频繁统计len的操作
:sync.Map不能直接根据len(read)和len(dirty)统计KV数量,因为可能已经被删了,但key还在read中。因此需要遍历所有一遍才能知道KV对的数量