sync.Map 源码大变天:一棵 16 叉树如何干掉 read/dirty 双 Map

sync.Map 源码大变天:一棵 16 叉树如何干掉 read/dirty 双 Map

文章内容摘要

Go 1.24 静默重写了 sync.Map 底层------从 read/dirty 双 Map 替换为 HashTrieMap(16 叉并发前缀树)。全局 Mutex、O(N) dirty 提升全部消失。新方案读路径纯原子操作零锁,写路径只锁单个节点,不同子树并发写互不干扰。本文从源码拆解新数据结构、读写流程、冲突处理及 atomic 内存序如何支撑无锁读。还在背旧版八股的,该更新了。

sync.Map的定位和适用场景

很多人好奇,我使用一个sync.RWMutex + (normal)map不就可以解决并发问题了吗,为什么官方还要自己设计一个sync.Map?

其实sync.Map在以下两种场景有显著优势

  • 读多写少(读取无锁)

  • key分片明显,即不同goroutine操作的key集合基本不重叠(操作不同key时CAS原子操作,避免加锁大开销)

    Go 1.24 对 sync.Map 做了一次彻底的底层重写,但这个变化非常低调------没有专门的 Proposal 讨论,Release Note

    也没有高亮。如果你还在用旧版本的 read/dirty 模型去理解它(或者去面试),是时候更新了。

简单回顾旧版本实现

Go 1.23 以及之前,sync.Map的数据结构如下

go 复制代码
  type Map struct {
      mu     Mutex                     // 删除或者增加key会使用到的Mutex
      read   atomic.Pointer[readOnly]  // 无锁读的只读 map
      dirty  map[any]*entry            // 需要加锁的可写 map
      misses int                       // read 未命中计数,达到一定限度会更新dirty为read
  }

  type readOnly struct {
      m       map[any]*entry
      amended bool  // dirty 中是否有 read 没有的 key
  }

  type entry struct {
      p atomic.Pointer[any]  // nil / expunged / 实际值
  }

读写流程

  • 读:先查 read(无锁),命中直接返回;未命中且 amended=true 时加锁查 dirty,misses++
  • 写:加锁写 dirty;如果 dirty 为空,先把 read 中未删除的 entry 全量复制到 dirty
  • 提升:当 misses >= len(dirty) 时,dirty 整体提升为新的 read,dirty 置空

一些痛点

  • dirty提升O(n),dirty重建O(n)
  • 状态机转化复杂,写操作锁竞争问题严重

新版本sync.Map的实现

结构介绍

先来看看核心数据结构 Go1.24版本的sync.Map变成了一个薄壳

go 复制代码
  type Map struct {
      _ noCopy
      m isync.HashTrieMap[any, any] //所有方法委托给一个哈希前缀树
  }

哈希前缀树结构详解

顶层结构

go 复制代码
  type HashTrieMap[K comparable, V any] struct {
      inited   atomic.Uint32
      initMu   Mutex
      root     atomic.Pointer[indirect[K, V]]   // 树根  ->K本质上是key值到哈希值的映射
      keyHash  hashFunc                         // 从 runtime 提取的哈希函数
      valEqual equalFunc                        // value 比较函数
      seed     uintptr                          // 随机种子
  }

两种节点

go 复制代码
  // 内部节点(中间节点)
  type indirect[K comparable, V any] struct {
      node[K, V]
      dead     atomic.Bool                          // 是否已被删除
      mu       Mutex                                // 保护 children 的写入
      parent   *indirect[K, V]                      // 父指针,删除时向上收缩用
      children [16]atomic.Pointer[node[K, V]]       // 16 个子槽位
  }

  // 叶子节点(存储实际 KV)
  type entry[K comparable, V any] struct {
      node[K, V]
      overflow atomic.Pointer[entry[K, V]]  // 完全哈希碰撞链表
      key      K
      value    V
  }

关键特性

  • 每个indirect对应有16个孩子节点,因为对于一个64位hash值,每层取4位,对应16个槽位,结合前缀树原理很好理解(参考我的前缀树原理博客--https://blog.csdn.net/hhhhhh66654/article/details/161560953?sharetype=blogdetail&sharerId=161560953&sharerefer=PC&sharesource=hhhhhh66654&sharefrom=mp_from_link)
  • 按需生长:如果某子树某一层只有一个entry那个key直接挂载在那里即可,无需完全延申开来---->我称之为懒延申

流程讲解

无锁读
go 复制代码
  func (ht *HashTrieMap[K, V]) Load(key K) (value V, ok bool) {
      hash := ht.keyHash(&key, ht.seed)
      i := ht.root.Load()
      hashShift := 64

      for hashShift != 0 {
          hashShift -= 4
          n := i.children[(hash>>hashShift) & 0xF].Load()

          if n == nil {
              return zero, false       // 空槽位,key 不存在
          }
          if n.isEntry {
              return n.entry().lookup(key)  // 到达叶子,遍历 overflow 链,处理哈希冲突的结构
          }
          i = n.indirect()  // 中间节点,继续下一层
      }
  }

整个过程:

  1. 计算 key 的 64 位 hash
  2. 从 root 开始,每层取 hash 的 4 bit 作为 0~15 的索引
  3. atomic.Load 读子指针(保证不对读取到修改到一半的指针),三种情况:nil(不存在)、entry(到达叶子)、indirect(继续)
  4. 到达叶子后遍历 overflow 链表逐个比较 key

全程没有任何锁、没有 CAS,只有一串 atomic load。 这就是高并发读性能的来源。

乐观查找+悲观确认

无锁查找插入点

go 复制代码
  for {
      i = ht.root.Load()
      hashShift = 64
      for hashShift != 0 {
          hashShift -= 4
          slot = &i.children[(hash>>hashShift) & 0xF]
          n = slot.Load()
          if n == nil || n.isEntry {
              break  // 找到插入点
          }
          i = n.indirect()
      }

      i.mu.Lock()
      n = slot.Load()  // double-check
      if (n == nil || n.isEntry) && !i.dead.Load() {
          break  // 状态没变,可以操作
      }
      i.mu.Unlock()  // 状态变了,重试
  }
  defer i.mu.Unlock()

带锁执行插入

go 复制代码
  // 情况 1:空槽位,直接插入
  if n == nil {
      slot.Store(&newEntry.node)
  }

  // 情况 2:已有 entry 且 key 相同,替换值
  if swapped := oldEntry.swap(key, new); swapped {
      slot.Store(&newEntry.node)
  }

  // 情况 3:已有 entry 但 key 不同,需要扩展树-->哈希冲突
  slot.Store(ht.expand(oldEntry, newEntry, hash, hashShift, i))

注意写入的关键模式:先构造完整的新节点/子树,最后一步 slot.Store()

原子发布。读端要么看到旧状态,要么看到完整的新状态,不存在中间态。

怎么处理哈希冲突

当两个不同 key 落在同一个槽位时:

go 复制代码
 func expand(oldEntry, newEntry, newHash, hashShift, parent) *node {
      oldHash := hash(oldEntry.key)
      // 完全碰撞:64 位 hash 完全相同,用链表
  if oldHash == newHash {
      newEntry.overflow.Store(oldEntry)
      return &newEntry.node
  }

  // 部分碰撞:创建中间节点,继续用后续 bit 区分
  top := newIndirectNode(parent)
  current := top
  for {
      hashShift -= 4
      oi := (oldHash >> hashShift) & 0xF
      ni := (newHash >> hashShift) & 0xF
      if oi != ni {
          current.children[oi].Store(oldEntry)
          current.children[ni].Store(newEntry)
          break
      }
      next := newIndirectNode(current)
      current.children[oi].Store(next)
      current = next
  }
  return &top.node
}

举个例子:

key A: hash = 0x3A7F...

key B: hash = 0x3B2C...

第一层 0x3 相同 → 冲突

第二层 A=0xA, B=0xB → 不同,分开

只需要 2 层 indirect 就区分开了。

删除节点+向上收缩
go 复制代码
  // 删除叶子
  slot.Store(nil)

  // 向上收缩空节点
  for i.parent != nil && i.empty() {
      hashShift += 4
      parent := i.parent
      parent.mu.Lock()
      i.dead.Store(true)           // 标记死亡
      parent.children[idx].Store(nil)  // 从父节点摘除
      i.mu.Unlock()
      i = parent
  }
 //  dead 标记的作用:当一个 indirect 被标记为 dead,任何正在它上面做 double-check 的写入者会发现状态不对,释放锁从 root
 //  重新开始。这避免了在一棵正在被拆除的子树上做无效操作。

并发安全的设计哲学

这一节回答三个问题:为什么锁在父节点上?atomic.Pointer 到底保证了什么?两者怎么配合让"读无锁"成立?

为什么锁在父节点上

先明确一个事实:entry(叶子)上没有锁,锁在 indirect(中间节点)上。

原因很简单------写操作修改的不是 entry 本身,而是父节点 children 数组里的指针。来看一个具体的竞争场景:

复制代码
indirect (parent)
  └─ children[5] → nil

G1 想插入 keyA,hash 索引到 [5]
G2 想插入 keyB,hash 也索引到 [5]

如果不加锁:

  1. G1 看到 children[5] == nil,构造 entryA
  2. G2 也看到 children[5] == nil,构造 entryB
  3. G1 执行 children[5].Store(entryA)
  4. G2 执行 children[5].Store(entryB) ------ entryA 被覆盖,数据丢失

加了父节点的锁之后:

  1. G1 锁住 parent,确认 children[5] 仍是 nil,写入 entryA,解锁
  2. G2 锁住 parent,发现 children[5] 已经是 entryA,走 expand 逻辑把 A 和 B 分到不同子槽位

锁保护的是"判断槽位状态 + 写入"这个组合操作的原子性,防止两个写入者基于同一个旧状态做出冲突决策。

而不同 indirect 节点的锁互不干扰------两个 goroutine 写入不同子树时,各锁各的,完全并行。

atomic.Pointer 到底保证了什么

很多人以为 atomic 是"让改指针和改数据变成一个原子操作"。这是错的------它们是两块不同的内存,物理上不可能合成一步。

atomic.Pointer 真正保证两件事:

第一:指针读写不撕裂

64 位指针的赋值是完整的一次操作,读端不会读到"高 32 位是新地址、低 32 位是旧地址"的拼接值。

第二:内存顺序(这才是关键)

现代 CPU 和编译器会重排指令。没有 atomic 时,下面的代码可能出问题:

go 复制代码
// 写端(构造新节点)
newEntry.key = "foo"       // ① 写数据
newEntry.value = 42        // ② 写数据
slot = &newEntry           // ③ 发布指针(普通赋值)

编译器/CPU 可能把 ③ 重排到 ① ② 前面执行。读端就会看到:指针已经指向 newEntry 了,但 key 和 value 还是零值。

atomic.Storerelease 语义 :保证 ①② 不会被重排到 ③ 后面。

atomic.Loadacquire 语义:保证读到新指针后,后续读取数据时一定能看到写端在 Store 之前写入的所有内容。

用一张图表示:

复制代码
写端                                    读端
─────                                   ─────
newEntry.key = "foo"    ①
newEntry.value = 42     ②
    ┃ (release barrier)
slot.Store(&newEntry)   ③ ─────→  p := slot.Load()    ④
                                      ┃ (acquire barrier)
                                  print(p.key)         ⑤ 保证看到 "foo"

一句话总结:不是"改指针和改数据同时发生",而是"你看到新指针的那一刻,数据一定已经准备好了"。

两者如何配合:读无锁的完整逻辑

现在把 Mutex 和 atomic.Pointer 放在一起看:

复制代码
写端流程:
1. 构造新节点(普通内存写入,此时没人能看到这个节点)
2. i.mu.Lock()                    ← 防止其他写入者竞争同一个槽位
3. double-check 槽位状态
4. slot.Store(&newNode)           ← release:发布新节点,之前的写入全部可见
5. i.mu.Unlock()

读端流程:
1. n := slot.Load()               ← acquire:看到新指针后,数据保证就绪
2. 读取 n.key, n.value            ← 安全,因为 entry 创建后不可变

三个机制各司其职:

机制 解决什么问题
indirect.mu (Mutex) 多个写入者不会互相覆盖同一个槽位(写-写互斥)
atomic.Pointer (Store/Load) 读端无需加锁就能安全看到写端发布的完整数据(读-写可见性)
entry 不可变 + 替换而非修改 读端拿到指针后可以放心读,不会有人在背后改字段(读端安全)

如果缺少任何一个:

  • 没有 Mutex → 两个写入者可能互相覆盖,丢数据
  • 没有 atomic → 读端可能看到指针更新了但数据还没写完
  • entry 可变 → 读端读到一半时写端改了字段,数据不一致
dead 标记:处理结构变更的通知机制

删除操作会向上收缩空节点。如果一个 indirect 被删除了,但此时另一个 goroutine 正好锁住了它准备写入,怎么办?

答案就是 dead 标记。写入者在 double-check 阶段会检查 !i.dead.Load()

go 复制代码
i.mu.Lock()
n = slot.Load()
if (n == nil || n.isEntry) && !i.dead.Load() {
    break  // 节点还活着,可以操作
}
i.mu.Unlock()  // 节点已死,释放锁,从 root 重新开始

这是一种轻量级的"失效通知"------不需要广播,每个写入者自己检查即可。

新旧方案对比

维度 旧(read/dirty) 新(HashTrieMap)
读路径 atomic 读 read map,miss 时加锁查 dirty atomic 沿树查找,纯无锁
写路径 全局 Mutex 细粒度节点 Mutex
写并行度 零(单锁) 不同子树完全并行
批量开销 dirty 提升/重建 O(N) 无批量操作
删除 标记 expunged,延迟清理 立即删除 + 向上收缩
Clear 加锁清空两个 map 原子替换 root,一条指令
代码复杂度 expunged 三态状态机 节点类型只有两种,逻辑清晰

使用建议

新实现让 sync.Map 在更多场景下成为合理选择,但核心原则没变:

适合 sync.Map 的场景:

  • 读写比 9:1 以上的缓存
  • 不同 goroutine 天然操作不同 key
  • 需要 LoadOrStore、CompareAndSwap 等原子语义

适合 map + RWMutex 的场景:

  • 写入密集且 key 高度重叠
  • 需要批量操作或快照语义

如果你不确定该用哪个,先用 map + RWMutex。 sync.Map 是特化方案,不是通用替代品。

标记 expunged,延迟清理 | 立即删除 + 向上收缩 |

| Clear | 加锁清空两个 map | 原子替换 root,一条指令 |

| 代码复杂度 | expunged 三态状态机 | 节点类型只有两种,逻辑清晰 |

使用建议

新实现让 sync.Map 在更多场景下成为合理选择,但核心原则没变:

适合 sync.Map 的场景:

  • 读写比 9:1 以上的缓存
  • 不同 goroutine 天然操作不同 key
  • 需要 LoadOrStore、CompareAndSwap 等原子语义

适合 map + RWMutex 的场景:

  • 写入密集且 key 高度重叠
  • 需要批量操作或快照语义

如果你不确定该用哪个,先用 map + RWMutex。 sync.Map 是特化方案,不是通用替代品。

相关推荐
jieyucx1 小时前
Go 语言零基础入门:标准库 log 包完全教程
golang·日志·log
会编程的土豆1 小时前
Go 语言匿名函数详解
c++·golang·xcode
会编程的土豆1 小时前
Go 语言闭包(Closure)详解
c++·golang·xcode
右耳朵猫AI1 小时前
Golang技术周刊 2026年第20周
开发语言·后端·golang
会编程的土豆2 小时前
Redis 常用操作笔记(Go 开发实战)
redis·笔记·golang
喵了几个咪2 小时前
Headless 后端实践:基于Go的企业级多栈管理系统脚手架
开发语言·vue.js·后端·golang·reactjs·gowind
小小龙学IT2 小时前
Go 并发模式深度解析:Fan-out/Fan-in 高效处理大规模数据流
开发语言·后端·golang
OxyTheCrack14 小时前
【Golang】简述make与new内置函数以及两者的区别
开发语言·golang
会编程的土豆16 小时前
Go 方法接收者超清晰笔记(类型名 vs 变量名)
开发语言·笔记·golang