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 更可控。
如果您喜欢我的文章,请点击下面按钮随意打赏,您的支持是我最大的动力。
最新评论