sync.Map 底层实现
sync.Map 的底层核心是空间换时间,通过两个 Map(read 和 dirty)的冗余结构,实现读写分离,最终达到针对特定场景的读操作无锁优化。它的 read 是一个只读的 map,提供无锁的并发读取,速度极快。写操作则会先操作一个加了锁的、可读写的 dirty map。
当 dirty map 的数据积累到一定程度,或者 read map 中没有某个 key 时,sync.Map 会将 dirty map里的数据提升并覆盖掉旧的 read map,完成一次数据同步。
go
type Map struct {
mu Mutex
read atomic.Pointer[readOnly] // 只读层,原子操作,无锁
dirty map[any]*entry // 脏数据层,受 mu 保护
misses int // 统计 read 未命中次数
}
type readOnly struct {
m map[any]*entry
amended bool // 如果 dirty 中包含 read 中没有的 key,则为 true
}
type entry struct {
p unsafe.Pointer // 指向实际的数据,可能是 nil 或 expunged
}
read:主要负责高性能读取。由于它是原子指针,多个 Goroutine 并发读取不需要加锁。dirty:负责写入新 Key。它是一个原生的 Go Map,所以操作它必须加mu锁。entry:这是read和dirty共享的。这意味着虽然有两张表,但同一个 Key 指向的是同一份数据的内存地址。
go
func (m *Map) Load(key any) (value any, ok bool) {
read := m.loadReadOnly() // 原子读取
e, ok := read.m[key]
// Fast Path: 如果在 read 里找到了,直接返回
if !ok && read.amended {
m.mu.Lock() // 没找到且 dirty 有新数据,加锁
// 双重检查
read = m.loadReadOnly()
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key] // 从 dirty 里找
m.missLocked() // 记一次未命中次数
}
m.mu.Unlock()
}
if !ok {
return nil, false
}
return e.load() // 返回 entry 指向的值
}
当 misses 计数器等于 len(dirty) 时,会触发 Promotion(提升) :将 dirty 赋给 read,并将 dirty 置为 nil。这保证了高频访问的 Key 最终都会沉淀到无锁的 read 层。
go
func (m *Map) Store(key, value any) {
// 如果 read 中已存在该 Key,尝试 atomic.CompareAndSwapPointer 原子更新
read := m.loadReadOnly()
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}
m.mu.Lock()
read = m.loadReadOnly()
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() {
// 如果之前被标记为 expunged (彻底删除),需要同步到 dirty
m.dirty[key] = e
}
e.storeLocked(&value) // 更新值
} else if e, ok := m.dirty[key]; ok {
e.storeLocked(&value) // Key 只在 dirty 中
} else {
// 这是一个全新的 Key
if !read.amended {
m.dirtyLocked() // 如果此时 dirty 为空,从 read 复制一份出来
m.read.Store(&readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value) // 写入 dirty
}
m.mu.Unlock()
}
- 当
dirty为nil(刚发生过提升)并需要写入新 Key 时,dirtyLocked()会遍历read。 - 如果
read中的某个值是nil(逻辑删除),它会被标记为expunged。 - 标记为
expunged的 Key 不会被复制到新的dirty中。这样可以清理掉陈旧的 Key,防止内存泄漏。
go
func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
read := m.loadReadOnly()
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
// ... 双重检查逻辑同 Load ...
e, ok = m.dirty[key]
delete(m.dirty, key) // 物理删除 dirty 里的 Key
m.mu.Unlock()
}
if ok {
return e.delete() // 逻辑删除:将 entry.p 设为 nil
}
return nil, false
}
为了性能,read 层的删除仅仅是将 entry.p 置为 nil(软删除)。真正的物理清理要等到下一次 dirtyLocked() 复制数据时,跳过那些 nil 的 Key。
p == nil: 逻辑删除,Key 还在read里。p == expunged: 逻辑删除,且 Key 不在dirty里。p == 正常地址: 数据存活。
为什么要设计
nil和expunged两种删除状态?
为了解决在 sync.Map 的读写分离架构下,高效、无锁地处理删除操作。
因为 read map 本身是只读的,我们不能直接从中删除一个 key。所以,当用户调用 Delete 时,如果这个 key 只存在于 read map 中,系统并不会真的删除它,而是将它的值标记为一个特殊的已删除状态,即 expunged。后续的读操作如果看到 expunged 标记,就知道这个 key 其实已经不存在了,直接返回 nil, false。而 nil 则是一个中间状态,主要用于 dirty map 和 read map 的同步过程,表示这个 key 正在被删除或迁移。
简单来说,这两个状态就像是在只读的 read map 上打的逻辑删除补丁。它避免了因为一次 Delete 操作就引发加锁和 map 的整体复制,把真正的物理删除延迟到了dirty map 晋升为 read map 的那一刻,是典型的用状态标记来换取无锁性能的设计。
我们可以把 entry.p 的生命周期看作一个状态机:
- 正常状态:Key 存在,
read和dirty可能都指向它。 - 软删除 (nil) :当你调用
Delete(key)时,如果 Key 在read中,系统只是原子地把p设为nil。此时它还不是expunged,因为它依然存在于dirty中(如果有dirty的话)。 - 彻底抹除 (expunged) :当
dirty提升(被重置)并从read重新同步时,系统发现这个 Key 是nil。- 为了保持
dirty的洁净,系统用atomic.CompareAndSwapPointer(CAS) 把p从nil改为expunged。 - 关键点:被标记为
expunged的 Key 不会被复制到新创建的dirtymap 中。
- 为了保持
sync.Map 适用的场景?
sync.Map 适合读多写少的场景,而不是和写多读少的场景。
因为我们期望将更多的流量在 read map 这一层进行拦截,从而避免加锁访问 dirty map 对于更新,删除,读取,read map 可以尽量通过一些原子操作,让整个操作变得无锁化,这样就可以避免进一步加锁访问 dirty map。倘若写操作过多,sync.Map 基本等价于一把互斥锁 + map,其读写效率会大大下降。
