Golang sync.Map 实现原理

前言

Go 语言原生 map 并不是线程安全的,要对它进行并发读写操作时,一般有两种选择:

  1. 原生map搭配Mutex或RWMutex
  2. 使用sync.Map

本文将介绍sync.map的整体结构,查,增,删,改的实现原理,以及适用场景

数据结构

go 复制代码
type Map struct {
    // 互斥锁,保护对 dirty 的访问
    mu Mutex

    // 无锁化的只读 map    
    read atomic.Pointer[readOnly]

    // 加锁处理的读写 map
    dirty map[any]*entry

    // 记录访问 read 的失效次数,累计达到阈值时,达到阈值后重建 dirty
    misses int
}

type readOnly struct {
    // 实现从 key 到 entry 的映射
    m       map[any]*entry
    // 标识 read map 中的 key-entry 对是否存在缺失,需要通过 dirty map 兜底
    amended bool
}

// kv 对中的 value
type entry struct {
    p atomic.Pointer[any]
}

read和dirty中,相同key底层引用了同一个entry,因此对read中的entry修改,也会影响到dirty

entry.p 的指向分为三种情况:

状态 含义
nil entry 被删除(但key仍在 readOnly和dirty中)
expunged entry 被删除(key仍在 readOnly中,但不在dirty中)。后续如果Store该key时,才知道要不要往dirty插入该KV
其他(*interface{}) 正常值,可通过 *p 解引用获取

expunged为一个全局变量

go 复制代码
var expunged = new(any)

go 复制代码
func (m *Map) Load(key any) (value any, ok bool) {
    read := m.loadReadOnly()
    e, ok := read.m[key]
    if !ok && read.amended {
       m.mu.Lock()
       
       read = m.loadReadOnly()
       e, ok = read.m[key]
       if !ok && read.amended {
          e, ok = m.dirty[key]
          
          m.missLocked()
       }
       m.mu.Unlock()
    }
    if !ok {
       return nil, false
    }
    return e.load()
}
  1. 从read中尝试获取,如果存在直接返回
  2. 否则加锁,再次从read中获取一次
    1. 这里是经典的双重检查做法,在sync.Map中大量使用。因为在从read读和加锁期间,可能有其他线程对map进行了操作,使read中有该键值对了
  3. 如果还是没有,就从dirty中获取,并执行missLocked方法

由于 readonly 是只读模式,所以新增KV只会插入到dirty 中,导致readonly数据是 dirty 的子集

  • sync.Map 中通过 misses 计数器记录 readonly 被读操作击穿的次数
    • missLocked方法中,不管是否获取成功都对m.misses++
  • 当该次数达到阈值时,会将 dirty 中的全量数据覆盖到 readonly
    • 目的:将全量的数据提升到read中,使得后续的操作能直接在read中完成,无需加锁访问dirty
go 复制代码
func (m *Map) missLocked() {
    m.misses++
    if m.misses < len(m.dirty) {
       return
    }
    m.read.Store(&readOnly{m: m.dirty})
    m.dirty = nil
    m.misses = 0
}

entry.load:

go 复制代码
func (e *entry) load() (value any, ok bool) {
    p := e.p.Load()
    if p == nil || p == expunged {
       return nil, false
    }
    return *p, true
}
  • 如果 entry 的指针状态为 nil 或者 expunged,说明 key-entry 对已被删除,则返回 nil;
  • 如果 entry 未被删除,则读取指针内容,并且转为 any 的形式进行返回

go 复制代码
func (m *Map) Store(key, value any) {
    _, _ = m.Swap(key, value)
}
  1. 如果read中存在该键值对,CAS更新其value
  2. 若不存在,加锁,执行后面的逻辑:
    1. 如果加锁后发现read中有了,该e是expunged,将其更新为nil,并且给dirty中增加该键值对,因为此时dirty中没有。然后更新e的值
    2. 如果read没有,但dirty有,更新dirty中该entry的值,返回
    3. 如果dirty,read都没有
      1. 如果read.amended是false,需要将read全量拷贝到dirty中
      2. 如果不是,则只在dirty中增加该键值对
go 复制代码
func (m *Map) Swap(key, value any) (previous any, loaded bool) {
    read := m.loadReadOnly()
    // 尝试在 read.m 中查找 key,找到了
    if e, ok := read.m[key]; ok {
       // 调用 e.trySwap(&value) 尝试无锁交换值
       if v, ok := e.trySwap(&value); ok {
          if v == nil {
             return nil, false
          }
          return *v, true
       }
    }

    m.mu.Lock()
    // 再次读取 read,因为可能在加锁前已被其他 goroutine 更新
    read = m.loadReadOnly()
    if e, ok := read.m[key]; ok {
       // 若 entry.p == expunged,则将其恢复为 nil(表示存在但无值),并返回 true。
       if e.unexpungeLocked() {
          // 并且给dirty中增加该键值对,因为此时dirty中没有
          m.dirty[key] = e
       }
       
       // 更新value
       if v := e.swapLocked(&value); v != nil {
          loaded = true
          previous = *v
       }
    // read没有,但dirty有,更新dirty中该entry的值   
    } else if e, ok := m.dirty[key]; ok {
       if v := e.swapLocked(&value); v != nil {
          loaded = true
          previous = *v
       }
    // dirty,read都没有 
    } else {
       if !read.amended {
          // 将read全量拷贝到dirty中
          m.dirtyLocked()
          m.read.Store(&readOnly{m: read.m, amended: true})
       }
       // 只将键值对加到dirty中
       m.dirty[key] = newEntry(value)
    }
    m.mu.Unlock()
    return previous, loaded
}

entry.trySwap:通过CAS修改entry的值

go 复制代码
func (e *entry) trySwap(i *any) (*any, bool) {
    for {
       p := e.p.Load()
       // 判断当前 entry 是否已被"驱逐"
       if p == expunged {
          return nil, false
       }
       if e.p.CompareAndSwap(p, i) {
          return p, true
       }
    }
}

entry.unexpungeLocked:将entry的值从expunged改为nil

go 复制代码
func (e *entry) unexpungeLocked() (wasExpunged bool) {
    return e.p.CompareAndSwap(expunged, nil)
}

dirtyLocked:将所有KV从readOnly拷贝到dirty

amended 状态 含义
FALSE read 是完整的,dirty 要么为空,要么未初始化
TRUE dirty 中有 read 中没有的 key(即新插入的 key)

为啥需要拷贝?如果!read.amended ,表示这是第一次向 sync.Map 写入一个新 key,此时必须初始化 dirty 并从 read 拷贝所有有效 entry,以保证 dirty 成为一个完整的可写映射 ,从而支持后续写操作和未来可能的 read 升级

go 复制代码
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 {
       // 如果e之前存储的不是nil,也就是没被删除,才把该KV放到dirty
       if !e.tryExpungeLocked() {
          m.dirty[k] = e
       }
    }
}

在拷贝的过程中,对每个entry要先调用tryExpungeLocked方法:

如果entry.P存储nil,将其设置为expunged。表示这个KV在dirty中没有,只在readOnly中有。并返回该entry之前存储的是不是nil

go 复制代码
func (e *entry) tryExpungeLocked() (isExpunged bool) {
    p := e.p.Load()
    for p == nil {
       if e.p.CompareAndSwap(nil, expunged) {
          return true
       }
       p = e.p.Load()
    }
    return p == expunged
}

为啥被删除的KV,不会被复制到dirty中?

  • 如果复制过去,但后续没有再对这个被删除的键值对进行操作 ,就会浪费内存空间
    • 这也是彻底删除该Key的一个环节
  • 这样复制过去后,会出现这个key只在readOnly中存在,但在dirty中不存在的情况。此时需要将entry用一个特殊标识expunged标记,表示这种情况。后面对该key进行Store操作时,才知道:
    • 是否需要往dirty中插入该KV
      • entry为expunged
      • 需要加锁
    • 还是修改entry即可
      • entry为nil
      • 无需加锁

写操作总的来说就是分各种情况处理:

  • read有 :无锁更新read中的数据
    • 如果read有,但entry是expunged时,需要加锁,然后给dirty加上该KV
  • read没有但dirty有:更新dirty中该entry的值
  • read没有dirty也没有 :将新的键值对添加到dirty中
    • 如果read.amended为false,需要将read中的数据拷贝到dirty中

删除

go 复制代码
func (m *Map) Delete(key any) {
    m.LoadAndDelete(key)
}
go 复制代码
func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
    read := m.loadReadOnly()
    e, ok := read.m[key]
    //  // 如果在只读视图中没有找到,并且amended标志为true(意味着有未同步到read map中的dirty数据)
    if !ok && read.amended {
       m.mu.Lock()
       read = m.loadReadOnly()
       e, ok = read.m[key]
       if !ok && read.amended {
          e, ok = m.dirty[key]
          // 如果该key不在read中,在dirty中,调用map原生的删除方法删除
          delete(m.dirty, key)
          
          m.missLocked()
       }
       m.mu.Unlock()
    }
    if ok {
       return e.delete()
    }
    return nil, false
}

entry.delete:将entry.P的值,CAS成nil

go 复制代码
func (e *entry) delete() (value any, ok bool) {
    for {
       p := e.p.Load()
       if p == nil || p == expunged {
          return nil, false
       }
       if e.p.CompareAndSwap(p, nil) {
          return *p, true
       }
    }
}
  • 为啥read的删除不像dirty一样,调用内置delete函数删除?
    • 因为read是只读 结构,不能对hash表的结构做修改,而只能做逻辑删除,即将entry.p设为nil
  • 那read中该被删除的key,啥时候真正删除
    • 触发从read拷贝到dirty时,不会拷贝entry为nil的键值对
    • 假设后续没有对该key进行操作,等后续misses达到阈值,将dirty提升为read时,就能真正的从sync.map中删除该键值对

适用场景

  • 适用场景:
    • 如果key在read中,那么读,更新,删除流程都能在read中快速完成,可以视为广义上的读操作。只有当key不在read时,才需要加锁操作dirty
    • 因此,sync.Map适用于 读多写少;更新写多,新增写少的场景
  • 不适用的场景:
    • 大量新增键值对操作:这种场景下,整个map退化为单线程操作
    • 频繁统计len的操作:sync.Map不能直接根据len(read)和len(dirty)统计KV数量,因为可能已经被删了,但key还在read中。因此需要遍历所有一遍才能知道KV对的数量
相关推荐
菜菜的后端私房菜几秒前
Protocol Buffers!高效数据通信协议
java·后端·protobuf
树獭叔叔5 分钟前
Python 锁机制详解:从原理到实践
后端·python
2025年一定要上岸12 分钟前
【Django】-10- 单元测试和集成测试(下)
数据库·后端·python·单元测试·django·集成测试
程序员海军24 分钟前
这才是Coding该有的样子!重新定义编程显示器
前端·后端
_風箏26 分钟前
Shell【脚本 05】交互式Shell脚本编写及问题处理([: ==: unary operator expected)[: ==: 期待一元表达式
后端
Cache技术分享26 分钟前
151. Java Lambda 表达式 - 使用 Consumer 接口处理对象
前端·后端
用户5769053080127 分钟前
Python实现一个类似MybatisPlus的简易SQL注解
后端·python
hello早上好30 分钟前
Spring AOP静态与动态通知的协作原理
后端·架构
MacroZheng1 小时前
狂揽9.3k star!号称终端版Postman项目,太炫酷了!
java·spring boot·后端
Lemon程序馆1 小时前
Mysql 常见的性能分析手段
数据库·后端·mysql