一、前言
上一期中,我们介绍了 Go 语言对条件变量的实现 sync.Cond。本期文章我们介绍 package sync 下的另一个工具类:sync.Map。我们先看一个故事:
面试官:看你用过 Go,大概用过多久,感觉到哪个段位?
小明:用了一年半左右,算是精通
面试官:用过 sync.Map 吗?
小明:没用过,大致了解过
面试官:Go 内置的 map 和 sync.Map 有什么区别?
小明(假装会):内置的map 不支持并发访问, sync.Map 支持并发访问
面试官:给内置的 map加上读写锁不一样能实现吗?两个有啥区别?
小明:…
小明卒,享年28岁
提到 sync.Map,我们首先想到的是 go 内置的 map[KeyType]ValueType(简称 map)。内置 map 基于 hashtable 实现,具有以下几个特点:
- Get/Set 的时间复杂度都是 O(1)
- 支持范型,使用起来非常方便
- 不支持并发访问
如果要支持并发访问,通常通过加锁实现,示例代码如下:
type Any interface{}
type ConcurrentMap struct {
sync.RWMutex
data map[Any]Any
}
func NewConcurrentMap(capacity int) *ConcurrentMap {
return &ConcurrentMap{
data: make(map[Any]Any, capacity),
}
}
func (m *ConcurrentMap) Get(key Any) (Any, bool) {
m.RLock()
defer m.RUnlock()
val, ok := m.data[key]
return val, ok
}
func (m *ConcurrentMap) Set(key, val Any) {
m.Lock()
defer m.Unlock()
m.data[key] = val
}
如果把加锁的map做成通用类,由于Go不支持范型,要引入interface,可读性变差。所以通常情况下,我们在 package 级别声明两个变量,直接拿来用:
// 假设需要一个 string-> string 的map
var (
rwlock sync.RWMutex
data map[string]string
)
那么问题来了:既然自己给 map 加锁很简单,为什么还要造轮子,搞出来一个 sync.Map?
二、sync.Map 有啥优势?
我们扒一扒 godoc,原文是英文,下面是我的翻译:
Map 类似于 Go内置的 map[interface{}]interface{},但是支持多协程的并发访问,而不需要额外的锁或同步机制。它的三个成员函数 Load/Store/Delete 的时间复杂度(均摊时间)都是 O(1)。
Map 类型有特定的应用场景。由于内置 map 在类型安全上表现更好,也更方便对map里的数据进行定制化处理,加一把锁就能满足大多数需求了。
Map 类型针对下面两个应用场景做了优化:
1)特定的 entry<key, value> 只被写入一次,但是会被读很多次,比如只增不减的 cache ;
2)多个 goroutine 的并发读、写、覆盖三种操作的 key 没有交集;
在上面两个应用场景下,相对于内置map+锁方案,Map 里抢占锁的行为会少很多。
Map 类型的默认值是空,可以被立即使用。Map实例在被第一次使用以后,不能被拷贝。
#Map
前两段话意思是 sync.Map 功能上是一个支持并发访问的 map,但是有特定的应用场景,大多数情况下你用不到。第三段意思是:sync.Map 针对map粒度有并发读写但key粒度极少并发读写的场景做了专门优化。
具体点说,在上面的 ConcurrentMap 类里,每次读写都会锁住整个map对象,而 sync.Map 的读写在 cache hit 时 lock-free,cache miss 时才需要锁住整个map对象。下面是一个例子来说明 cache hit 和 cache miss:
package main
import (
"fmt"
"sync"
)
func main() {
var cache sync.Map
cache.Load("key") // cache miss
cache.Store("key", "value") // cache miss
cache.Load("key") // cache hit
cache.Store("key", "value_v1") // cache hit
// 删除key,先软删除
// 触发特定条件后会硬删除
cache.Delete("key")
cache.Load("key") // cache hit,但读不到值
cache.Store("key", "value_v2") // cache hit,但仍需上锁
}
需要注意的是:虽然 godoc 给的优化场景是写一次,读多次,但并不是绝对的。在对一个 key 写多次,读多次时,sync.Map 也能保证结果的正确性。
准确来说,一个key的写入是 lock-free的,对它的高频read/update也是lock-free的;一旦触发 delete,就没法利用lock-free的性能优势了。
顺便提一句 lock-free。多个线程访问同一块内存时,数据同步是必须的,处理的方式无非是管道、锁、原子操作。提到lock-free,通常想到 Compare-And-Swap,CAS依赖于原子操作 。
锁在编程语言层面上实现,不过依赖操作系统的信号量,可能导致线程被休眠和唤醒,而且它包含加锁和解锁两个动作;原子操作是基于硬件指令,不会导致线程休眠,一步到位。
当我们说 lock-free 时,是指实现一个数据结构时,用原子操作代替锁保证同步机制。
回顾下刚才的问题:既然自己给 map 加锁很方便,为什么还要造轮子,搞出来一个 sync.Map?
大家心里应该有一些概念了,但重要的事情值得变着法子重复,sync.Map 的应用场景是:
- 低频 create
- 高频 read/update
- 很少/不 delete
注:create/read/update/delete 是从逻辑上对数据操作的定义。
三、sync.Map 怎么用
这个环节介绍下 sync.Map 的使用方法,首先看它的成员函数:
// Load 等价于 m[key]
func (m *Map) Load(key interface{}) (value interface{}, ok bool)
// Store 等价于 m[key]=value
func (m *Map) Store(key, value interface{})
// LoadOrStore 读不到key时,会写入 <key,value>;然后返回对应的value
func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)
// Delete 等价于 delete(m, key)
func (m *Map) Delete(key interface{})
// Range 等价于 for k, v := range m
func (m *Map) Range(f func(key, value interface{}) bool)
Map 的用法非常符合直觉,唯二需要说的是 Load 和 Range。
由于 Load 返回的是 interface{},我们需要进行显式类型转换。如果 key 不存在,Load 会返回 nil,类型转换会 panic。比如,下面这段代码中 val2.(string) 会导致panic:
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
m.Store("key", "value")
val1, ok1 := m.Load("key")
fmt.Printf("val1=%v, ok1=%v\n", val1, ok1)
fmt.Printf("type safe val1 = %v\n", val1.(string))
val2, ok2 := m.Load("key_not_exist")
fmt.Printf("val2=%v, ok2=%v\n", val2, ok2)
fmt.Printf("type safe val2 = %v\n", val2.(string))
}
关于 Range,考虑这个场景:在 Range 执行过程中,如果另一个线程执行了 Store/Delete,执行 Range 的线程能立即感知到变化吗?
要回答这个问题,我们复用下上面的 create/read/update/delete 概念,而不是粗粒度的 Load/Store/Delete 等成员函数,结论是:
- create:Range 不能感知到新增的 key
- read: read 操作不会修改 Map,不讨论
- update:Range 能感知到已有 key 对应的 value 的变化
- delete:Range 能感知到对应 key 的 value 的标记删除,遍历时会跳过
由于Range 的执行过程是 1) 加载索引表 2) 遍历索引表,所以 key 集合在第一步就确定下来了,开始遍历后无法感知到新增的key;对于已有的key,遍历过程中通过原子操作取出对应的value,所以能够感知<遍历开始,当前时刻>这段时间内发生的update和delete。
四、sync.Map 怎么实现
小明买了张复活卡,网上搜到了一篇标题叫 “Golang package sync 剖析(四):sync.Map” 的文章,回顾了下面试官的问题,把前三节看完了。看到 lock-free 数据结构,十分好奇,但是急于通关,第四小节没看就去面试了。
面试官:看你用过 Go,大概用过多久,感觉到哪个段位?
小明:用了一年半左右,比较熟悉,算不上精通
面试官:用过 sync.Map 吗?
小明:用过,它是基于 lock-free hashtable 实现的,比<内置map, 读写锁>快。对读操作做了很多优化,&%..#$#@..%#%%$@*
面试官:嗯,还不错。实现一个简单的 sync.Map,把 Load/Store 两个函数的核心逻辑写出来就行,可以不支持 Delete
小明:…
小明卒,享年28岁
sync.Map 和 sync.WaitGroup 类似,是典型的用起来简单,实现起来超级复杂的数据结构。事实上,sync.Map 的实现要更复杂一些。
我们先看 Map 的内部结构:
type Map struct {
// read字段内部是一个 readOnly 对象
// Load和Store都首先从这里加载数据
// 如果没有,才去访问 Map.dirty
read atomic.Value
// mu 用于控制 dirty 变量的同步
// 调用Store时,对于新key或已删除的key,都要写到dirty
// 调用Load时,如果 miss 达到 len(dirty),把dirty浅拷贝到 Map.read.m
mu Mutex
dirty map[interface{}]*entry
// misses 用来记录Load里读 Map.read字段时的cache miss数
misses int
}
// readOnly 是一个只读结构
// m 是一个只读索引表,从 dirty 浅拷贝过来
// m 和 Map.dirty 里 的 *entry 指向同样的内存位置
type readOnly struct {
m map[interface{}]*entry
amended bool // 是否需要同步
}
// entry 本身不存数据
// 而是把数据放到指针 p 里
type entry struct {
p unsafe.Pointer // *interface{}
}
Map 之所以被称为 lock-free,是因为它的两级缓存。
一级缓存
一级缓存是 read 字段,每次进行 Load/Store/Delete 时,在命中一级缓存时,执行的操作是:
- 加载索引表 readOnly.m,这一步是原子操作
- 通过 key 读取对应的 *entry
- 读取/更新 entry.p,也是原子操作
注:调用 Delete 时,如果能命中一级缓存,entry.p 被置为 nil。
二级缓存
二级缓存是 dirty 字段,依赖锁保证同步。只有在一级缓存cache miss 时,key才会穿透到二级缓存。
对于 Load,cache miss 且两级缓存不同步时执行的操作是:
- 加锁
- 取数
- 记录cache miss 如果 misses == len(dirty) 则刷新一级缓存,清空二级缓存
- 返回结果,解锁
对于 Store,cache miss时执行的操作是:
- 加锁
- 如果二级缓存cache hit,则写数据
- 如果二级缓存 cache miss,
- 如果两级缓存“已同步”(amended==false),则需要将一级缓存刷到二级缓存上,并将一级缓存置为“不同步”(amended=true),然后把新 entry 写入二级缓存
- 如果两级缓存不同步(amended==true),就把新 entry 写入二级缓存
- 解锁
对于 Delete,cache miss时执行的操作是:
- 加锁
- double check 一级缓存 3.1. 如果两级缓存已经同步(amended==false),则直接将key 从 dirty 删除 3.2. 如果两级缓存不同步(amended==false),则执行软删除(将entry.p 置为nil)
- 解锁
一级缓存的读写是原子操作,二级缓存的读写通过锁保证同步。在使用过程中,应当尽量避免一级缓存被穿透。说完了 sync.Map 的实现机制,我们看看源码。
成员函数 Load
Load 的操作相对简单:
- 从一级缓存 m.read 取数
- 一级缓存cache miss,从二级缓存 m.dirty 取数
- 如果 cache miss 积累到阈值,触发一级缓存的更新
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
// 从一级缓存 m.read 取数
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
if !ok && read.amended {
// 一级缓存被击穿,且两级缓存不同步
m.mu.Lock()
// 加锁后 double check 一级缓存
// 保证<上次读取read, 加锁>期间新增的key能被感知到
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok && read.amended {
// 一级缓存未命中&&两级缓存不同步时
// 从二级缓存m.dirty取数
e, ok = m.dirty[key]
// missLocked 记录 cache miss
// 如果 misses == len(m.dirty)
// 则刷新一级缓存 m.read, 并清空二级缓存
m.missLocked()
}
m.mu.Unlock()
}
// 两级缓存都没有这个数
if !ok {
return nil, false
}
return e.load()
}
从代码我们可以看到,如果两级缓存不同步且都被击穿,Load 的性能会比较差。
成员函数 Store
func (m *Map) Store(key, value interface{}) {
// 检查一级缓存 m.read ,并更新
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}
// 一级缓存未命中,或命中已删除的key时
// fallback 到二级缓存 m.dirty
m.mu.Lock()
// 加锁后 double check 一级缓存
// 保证<上次读取read, 加锁>期间新增的key能被感知到
read, _ = m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() {
// 如果 key 被软删除,更新到二级缓存 m.dirty
m.dirty[key] = e
}
e.storeLocked(&value)
} else if e, ok := m.dirty[key]; ok {
// 一级缓存 m.read 被穿透
// 二级缓存 m.dirty 被命中
e.storeLocked(&value)
} else {
// 一级缓存 m.read 被穿透
// 二级缓存 m.dirty 也被穿透
if !read.amended {
// 两级缓存已同步,说明二级缓存 m.dirty 是 nil
// 将一级缓存 m.read 的key 全部刷到二级缓存 m.dirty 里
// 并将缓存同步状态置为"不同步"(amended=true)
m.dirtyLocked()
m.read.Store(readOnly{m: read.m, amended: true})
}
// 将新的 entry 写入二级缓存
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()
}
Store 一个 entry<key, value> 时,如果命中一级缓存,则更新一级缓存,原子操作;否则只更新二级缓存,此时新增的 key 只存在于二级缓存里,只有 Load 里一级缓存被击穿的次数足够多时,该key 才会被刷到一级缓存里。
成员函数 Delete
func (m *Map) Delete(key interface{}) {
// 检查一级缓存 m.read ,并更新
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
if !ok && read.amended {
// 一级缓存未命中,且两级缓存不同步
// fallback 到二级缓存 m.dirty
m.mu.Lock()
// 加锁后 double check 一级缓存
// 保证<上次读取read, 加锁>期间新增的key能被感知到
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok && read.amended {
// 一级缓存确实没有,直接从二级缓存删
delete(m.dirty, key)
}
m.mu.Unlock()
}
if ok {
// 命中一级缓存,执行软删除
e.delete()
}
}
Delete 一个 key 时,如果命中一级缓存,则更新一级缓存,将 entry.p 置为 nil,是原子操作;如果命中二级缓存,那么直接从索引 m.dirty 删除。
我们稍微细品一品,如果只命中二级缓存,逻辑非常简单。但是如果命中一级缓存,会有两个问题:
- Load 和 Range 时,如何知道一个key被删除了?
- 如果被删除的 key 个数占比非常高,会非常浪费内存,如何进行清理?
先说问题1,key 命中一级缓存,进行 Delete 时,我们可以从一级缓存拿到该 key 对应的 *entry,方法是 m.read.Load().(readOnly).m[key],我们拿到 *entry 以后,通过原子操作更新 entry.p,代码如下:
// 为了便于理解
// readOnly 和 entry 也放在这儿
type readOnly struct {
m map[interface{}]*entry
amended bool // 是否需要同步
}
type entry struct {
p unsafe.Pointer // *interface{}
}
// expunged 是一个占位符指针,用来标记entry已删除
var expunged = unsafe.Pointer(new(interface{}))
// 执行软删除
func (e *entry) delete() (hadValue bool) {
for {
p := atomic.LoadPointer(&e.p)
if p == nil || p == expunged {
return false
}
if atomic.CompareAndSwapPointer(&e.p, p, nil) {
return true
}
}
}
执行软删除以后,由于二级缓存 m.dirty[key] 指向的内存地址和 m.read.Load().(readOnly).m[key] 一样,所以也被“偷偷”更新了。
说说问题2,一个 key 被删除以后,数据会删除,但是索引表里还记录着占位符,占位符的比例太高的话也会影响整体性能。上面讲 Store 函数时,我们提到:
如果两级缓存都cache miss,且两级缓存“已同步”,则需要将一级缓存刷到二级缓存上,并将一级缓存置为“不同步”(amended=true),然后把新 entry 写入二级缓存。
为什么需要将一级缓存刷到二级缓存呢?我们先看看刷的时候做了什么:
func (m *Map) dirtyLocked() {
// 两级缓存已同步,所以 m.dirty 肯定是nil
if m.dirty != nil {
return
}
read, _ := m.read.Load().(readOnly)
m.dirty = make(map[interface{}]*entry, len(read.m))
for k, e := range read.m {
// 读取entry的删除状态
// 未删除的entry 才拷贝到 m.dirty
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}
检查删除状态的函数 tryExpungeLocked 写得非常风骚,这篇文章的细节已经很多了,这里不再列出来,有兴趣的同学可以看源码。
五、小结一下
sync.Map 的底层是典型的 lock-free hash table,一级缓存通过原子操作提供性能保障,二级缓存借助锁保障逻辑的正确性和完备性。两级缓存的同步是 sync.Map 实现里最复杂的部分,本文尽可能从全局上说清楚,细节上肯定会有不少疏漏,希望大家能阅读源码,对 sync.Map 有比较透彻的理解。
小明又买了一张复活卡,…
六、References
- sync.Map #Map
- The World’s Simplest Lock-Free Hash Table