Golang sync.Map 实现原理

179 阅读7分钟

前言

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

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

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

数据结构

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

image.png

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

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

expunged为一个全局变量

var expunged = new(any)

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

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:

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 的形式进行返回

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中增加该键值对

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的值

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

func (e *entry) unexpungeLocked() (wasExpunged bool) {
    return e.p.CompareAndSwap(expunged, nil)
}

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

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

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

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

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中

删除

func (m *Map) Delete(key any) {
    m.LoadAndDelete(key)
}

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

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对的数量