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
仍然存在性能瓶颈:
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]
}
结构总结:
sync.Map
内部有两个 map
:read
和 dirty
。read
是一个 atomic.Pointer
,指向一个 readOnly
结构。这个结构体里的 map
(read.m
) 是只读的,专门用于并发读取,不需要加锁。dirty
是一个普通的 go map
,用于写入和存储最新的数据。所有对 dirty
的操作都必须加锁 (mu
)。entry
结构体通过原子指针 p
包装了真正的 value
,这使得对单个 value
的修改(更新或逻辑删除)可以原子地完成,而无需锁定整个 map
。misses
是一个计数器,是连接 read
和 dirty
的关键,用于触发数据迁移。read
和 dirty
的作用这是 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
的分层设计。
快路径 (Fast Path) - 无锁读取:
read
指针,获取 readOnly
结构。read.m
这个 map
中查找 key
。entry
,并且 entry.p
指针不是 expunged
(未被删除),则原子地加载 entry.p
指向的 value
并返回。这是最快、最理想的情况。read.m
中没有找到 key
,或者 entry
被标记为 expunged
,则进入慢路径。慢路径 (Slow Path) - 加锁回源:
m.mu.Lock()
。read
。因为从快路径判断失误到加锁成功这段时间,read
可能已经被其他 goroutine 更新了。如果此时在新的 read
中找到了数据,就直接返回。这可以避免不必要的 dirty
操作。dirty
: 如果双重检查后 read
中仍然没有,就去 dirty
map 中查找。
dirty
中找到了,说明数据是最新的,直接返回。dirty
中也没找到,说明这个 key
确实不存在,返回 nil, false
。read
中未命中,最终需要去 dirty
中查找的,都会让 misses
计数器加一。misses
的数量达到了 len(m.dirty)
,意味着有大量的数据只存在于 dirty
中,read
的缓存命中率已经很低了。此时会触发数据搬迁过程(missLocked
方法)。m.mu.Unlock()
。Store
方法)Store
过程比 Load
更复杂,因为它可能需要创建 dirty
map。
尝试更新 read
(无锁):
read
中的 entry
。read.m
中查找 key
。如果找到了对应的 entry
,并且这个 entry
没有被标记为 expunged
,它会尝试使用 atomic.CompareAndSwapPointer
(CAS) 操作,原子地将 entry.p
指针从旧值替换为新值。加锁写入 dirty
(慢路径):
key
不在 read
中,或者 entry
已被删除,或者 CAS 竞争失败),就必须加锁。m.mu.Lock()
。read
: 和 Load
类似,加锁后再次检查 read
。因为 read
可能已被更新。
key
在 read
中,但被标记为 expunged
,说明这个 key
正在被删除或已经被删除。这时,必须确保它也在 dirty
中。key
在 read
中且有效,直接更新其 entry
的指针 p.Store(value)
。dirty
map:
dirty
map 为 nil
: 说明这是自上次数据搬迁以来的第一次写入。此时需要初始化 dirty
map,并将 read.m
中的所有未被删除的数据复制到新的 dirty
map 中。dirty
map 中添加或更新 key
的 entry
。m.mu.Unlock()
。核心点: Store
会优先尝试原子更新,失败后才会加锁操作 dirty
。如果 dirty
不存在,它会把 read
的内容完整地 "拷贝" 过来作为基础,再进行写入。
missLocked
方法)数据搬迁是 sync.Map
性能调优的精髓,它发生在 Load
的慢路径中,当 misses >= len(m.dirty)
时被触发。这个过程必须在持有锁的情况下进行。
提升 dirty
为 read
:
sync.Map
将当前的 dirty
map 直接提升为新的 read
map。readOnly
结构,其 m
字段就是当前的 m.dirty
。m.read.Store()
原子地将 Map.read
指针指向这个新的 readOnly
结构。清空 dirty
:
m.dirty
设置为 nil
。m.misses
计数器重置为 0。完成:
搬迁过程的意义:
read
的切换是一瞬间完成的原子操作 (m.read.Store
)。正在并发读取的 goroutine 要么读到旧的 read
,要么读到新的 read
,不会读到中间状态。readOnly
结构在没有任何 goroutine 引用它之后,会被 Go 的垃圾回收器自动回收。这同时清理了旧 read
中所有被标记为 expunged
的 "垃圾" 数据。dirty
中的新数据现在都存在于 read
中了,后续对这些数据的读取将直接在 read
中命中,回到快速无锁路径。sync.Map
使用 read
和 dirty
两个 map 实现了读写分离。read
用于无锁并发读取,dirty
用于加锁写入。通过 misses
计数器和数据搬迁机制,动态地将 dirty
中的新数据同步到 read
中,从而在“读多写少”的场景下,最大化地减少锁竞争,提升读取性能。Delete
操作: 删除操作是通过将 entry
的指针 p
原子地替换为 expunged
标记来实现的(逻辑删除)。这个被标记的 entry
会在下一次数据搬迁时被物理地清除。key
只会被写入一次,但会被读取很多次时(如缓存场景)。key
集合,冲突很少时。map + sync.RWMutex
。sync.Map
的 Range
方法为了保证一致性,可能会遍历 read
和 dirty
两个 map,且在遍历期间可能需要加锁,性能不稳定。如果需要频繁遍历,map + RWMutex
更可控。
如果您喜欢我的文章,请点击下面按钮随意打赏,您的支持是我最大的动力。
最新评论