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

Loadsync.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

go1.25 后的 sync. Map

源码位置:golang1.25 - sync/map