sync.Map 是 Go 语言中的一个并发安全的数据结构,设计用来在多个 goroutine 中安全地进行读写操作。与标准的 map 不同,sync. Map 采用了一些特殊的策略来优化并发性能,并适合在读多写少的场景中使用。
标准的 map 不是线程安全的,为了实现并发安全的 map 设计了 sync. Map,它内部实现支持并发操作,多个 goroutine 可以安全地读写同一个 sync.Map 实例,而无须显式锁定。
看源码的时候发现新版的 sync.Map 不是老生常谈的双 Map 了,而是换成了 HashTrieMap 的实现
相关 issue:sync: replace Map implementation with HashTrieMap · Issue #70683 · golang/go
先讲讲旧版 sync.Map 如何实现的,再讲讲新版的
go 1.24 前基于双 Map 实现的 sync. Map
源码版本:golang 1.23 - src/sync/map.go
旧版 sync.Map 的底层实现基于分为只读部分和可写部分(称为 read 和 dirty)的结构,其中使用原子操作来维护并发安全,确保高效的读多写少场景下的键值对存取。
实现思路
旧版的设计思路是:读写分离。它内部其实维护了两份菜单。
Read Map(只读菜单):放在每张桌子上。
- 特点:客人看这个最快,不需要叫服务员(不用加锁),直接看。
- 限制:这里面只有“老菜品”。
Dirty Map(脏菜单/厨房菜单):放在厨房里,由大厨(互斥锁 Mu)管着。
- 特点:这里包含所有菜品(包括 Read 里的老菜品 + 最近刚研发的新菜品)。
- 限制:想看这个菜单,必须排队找大厨申请(加锁,慢)。
读取工作,假设客人要点一个"宫保鸡丁":
- 先看桌上的菜单 (Read):如果有,直接点单。速度极快!
- 桌上没有? 客人会犹豫一下(检查 amended 标记,看厨房是不是有新菜)。
- 去厨房找大厨 (Lock):如果没有,只能排队进厨房,查 Dirty Map。
- 记小本本 (Misses):如果每次都要进厨房查,大厨会觉得烦。他会在小本子上记一笔:“又有人因为这道菜来烦我了”。
- 升级菜单:当小本子上的记录(Misses 次数)太多了,大厨就会发火:“这道新菜太火了,把它印到桌子的菜单上去!”(Dirty 覆盖 Read,清空 Dirty)。这就是“数据提升”。
核心数据结构
Map
type Map struct {
// 只有操作 dirty 的时候才使用
mu Mutex
// 存储当前可安全并发访问的 map 部分,通过原子指针保存。
read atomic.Pointer[readOnly]
// 是一个非线程安全的map,不仅包含新写入的key还包含read当中还没有删除掉的key。
// 保存需要加锁的 map 部分,存储频繁修改的项。
dirty map[any]*entry
// 计数那些在 read map 中未找到的读取次数
misses int
}
readOnly
// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
// 内部的只读 map, 不可变
m map[any]*entry
// 如果 dirty 里 [有] readOnly.m 没有的数据, 它就是true
amended bool
}
entry
// 代表map中的一个key
type entry struct {
// p 指向具体的值,有3种状态
// 存活状态: 指向具体的值
// nil: 说明被 Delete 删除了, 但还在 map 结构里
// expunged(特殊标记): 说明被彻底删除了, 并且不在当前的 dirty map 里。
p atomic.Pointer[any]
}
源码:插入-Store / Swap
实际上, Store 方法是通过另一个方法 Swap 来实现的。 Swap 方法不仅可以设置值,还可以返回与该键相关联的旧值。
Swap 的实现遵循 "Fast Path (无锁)" -> "Slow Path (加锁)" 的经典模式
// Store sets the value for a key.
func (m *Map) Store(key, value any) {
_, _ = m.Swap(key, value)
}
func (e *entry) trySwap(i *any) (*any, bool) {
for {
// p有3种状态, nil, 存活->实际值, expunged
p := e.p.Load()
// 【重点】如果是 expunged,拒绝操作,必须去慢路径处理
if p == expunged {
return nil, false
}
// CAS 尝试把旧值 p 换成新值 i
if e.p.CompareAndSwap(p, i) {
return p, true
}
}
}
// Swap swaps the value for a key and returns the previous value if any.
// The loaded result reports whether the key was present.
func (m *Map) Swap(key, value any) (previous any, loaded bool) {
// 快路径: 加载只读部分的当前状态 - readmap
read := m.loadReadOnly()
if e, ok := read.m[key]; ok {
// 如果在只读 map 中找到了条目, 就尝试更新value
if v, ok := e.trySwap(&value); ok {
if v == nil {
return nil, false
}
// 旧value
return *v, true
}
}
// 如果 key 不在 readmap 里, 或者被标记成 expunged, 就会进入慢路径
// 慢路径: 加锁
m.mu.Lock()
// double-check: 在加锁后重新读一次 read,防止在等待锁的过程中 dirty 被提升为了 read
read = m.loadReadOnly()
if e, ok := read.m[key]; ok {
// ------------------------------------------------------
// 分支 A:Key 在 Read Map 中
// (这种情况通常是因为 Fast Path 遇到了 expunged 状态)
// ------------------------------------------------------
// 解除 expunged 状态 (unexpungeLocked)
if e.unexpungeLocked() {
// 这个 entry 被特殊标记了(expunged)
// 如果之前是 expunged,意味着 dirty map 中没有这个 key。
// 既然我们要复活这个 key,必须把它重新塞回 dirty map,保证数据一致性。
m.dirty[key] = e
}
// 执行交换 (swapLocked)
if v := e.swapLocked(&value); v != nil {
loaded = true
previous = *v
}
} else if e, ok := m.dirty[key]; ok {
// ------------------------------------------------------
// 分支 B:Key 仅在 Dirty Map 中
// (Read 里没有,说明是之前新加的,还没提升)
// ------------------------------------------------------
// 直接交换
if v := e.swapLocked(&value); v != nil {
loaded = true
previous = *v
}
} else {
// ------------------------------------------------------
// 分支 C:Key 完全不存在 (是全新的 Key)
// ------------------------------------------------------
// amended: Dirty Map 里是否有 Read Map 不知道的新数据?
// amended = false: Read Map 包含了所有数据(或者 Dirty 是空的)
// “放心大胆地查 Read 吧!如果 Read 里没找到,那就肯定没有,不用去 Dirty 找了,也不用加锁。”
// amended = true: Read Map 已经过时了,Dirty Map 里藏着 Read 没有的新 Key
// “Read 里没找到?别急着放弃!Dirty 里可能有,去加把锁查查 Dirty 吧。”
if !read.amended {
//
// 如果 amended 为 false,说明 dirty map 为空, 或者 read map包含了所有数据
// 此时初始化 dirty map (把 read 里的数据拷过来)
// 这是整个过程中最[重]的操作: 把 Read 里的数据拷贝到 Dirty 里
m.dirtyLocked()
// 拷贝完之后, 标记 read map 已经过时了
m.read.Store(&readOnly{m: read.m, amended: true})
}
// 新增key
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()
return previous, loaded
}
// 当dirty map为空的时候
// 将 readmap 所有数据拷贝道 dirty map
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 {
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}
源码:读取 Load
Load 是 sync.Map 中最高频调用的方法。它的设计目标是:尽一切可能不加锁。
Load 的核心精髓是 "Amended 标记" 和 "Double Check"。它极力避免加锁,只有在万不得已(Read 没有且 Dirty 可能有)时才加锁,并且加锁后还极其谨慎地防范并发导致的状态变更。
Load 可以分成三个阶段:
阶段 1: 无锁快查
- 绝大多数情况下,Key 都在 Read Map 里,这里直接返回,速度极快。
- 或者,Key 既不在 Read 也不在 Dirty (amended=false),直接返回 nil,也极快。
阶段 2: 加锁查 dirty map, 可能触发状态提升
完整源码:
func (m *Map) Load(key any) (value any, ok bool) {
read := m.loadReadOnly()
e, ok := read.m[key]
// 如果 Read 里没找到
// amended = false: Read Map 包含了所有数据(或者 Dirty 是空的)
// amended = true: Read Map 已经过时了,Dirty Map 里藏着 Read 没有的新 Key
// 注意:如果 ok=false 但 amended=false,直接跳过下面的 if,返回 nil
if !ok && read.amended {
m.mu.Lock()
// 阶段2: 加锁与双重检查 (Slow Path)
// 关键】Double Check (双重检查)
// 在我们排队等锁的时候,可能别人已经触发了“提升”,把 Dirty 变 Read 了。
// 所以拿到锁之后,必须再获取一个 read
read = m.loadReadOnly()
e, ok = read.m[key]
// 如果 Read 里还是没有,且 amended 依然是 true
if !ok && read.amended {
// 查 dirty map
e, ok = m.dirty[key]
// 标记miss的次数
// 不管 Dirty 里有没有这个 Key,只要我们被迫锁了 mu 进来查 Dirty就算一次 Miss
// 因为这代表 Read Map 已经覆盖不全了.
m.missLocked()
}
m.mu.Unlock()
}
// 这是快路径
if !ok {
return nil, false
}
// 返回
return e.load()
}
数据提升机制
sync.Map 有一个“数据提升”机制:当读操作穿透到 dirty 的次数太多( misses 计数达标)时,系统会直接把 Dirty Map 提升为 Read Map(read = dirty),然后把 Dirty 清空(dirty = nil)。
提升 (Promotion) 是为了止损。当查 Dirty 的开销(累积的锁竞争)超过了重建 Dirty 的开销时,系统就会把 Dirty 转正。
什么时候触发数据提升机制?
触发点:提升的逻辑在 missLocked() 函数中。每当你在 Load 操作中没在 Read 里找到 Key,不得不去 Dirty 里找时,就会调用这个函数记一次 miss
触发阈值:
func (m *Map) missLocked() {
m.misses++ // 1. 记过次数 +1
// 2. 判断是否达到阈值
if m.misses < len(m.dirty) {
return // 还没到,忍了
}
// 3. 忍无可忍,触发提升!
m.read.Store(&readOnly{m: m.dirty}) // Dirty 变 Read (默认 amended=false)
m.dirty = nil // Dirty 清空
m.misses = 0 // 计数器归零
}
为什么要换成 HashTrieMap
旧版痛点
- 只有 Read/Dirty 两层,写入新 Key 必加全局锁
- Dirty 晋升为 Read 时有性能抖动
新版方案
- HashTrieMap (并发哈希前缀树)
- 不再维护两份全量数据
- 数据结构类似于 "树",逐层路由
相关 issue:sync: replace Map implementation with HashTrieMap · Issue #70683 · golang/go