Go语言在设计上对同步(Synchronization,数据同步和线程同步)提供大量的支持,比如 goroutine和channel同步原语,库层面有
1. sync:提供基本的同步原语(比如Mutex、RWMutex、Locker)和 工具类(Once、WaitGroup、Cond、Pool、Map)
2. sync/atomic:提供原子操作(基于硬件指令compare-and-swap)
Golang package sync 剖析(三):sync.Cond
上一期中,我们介绍了 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 实现,具有以下几个特点:
如果要支持并发访问,通常通过加锁实现,示例代码如下:
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 的mapvar ( rwlock sync.RWMutex data map[string]string)
那么问题来了:既然自己给 map 加锁很简单,为什么还要造轮子,搞出来一个 sync.Map?
我们扒一扒 godoc,原文是英文,下面是我的翻译:
Map 类似于 Go内置的 map[interface{}]interface{},但是支持多协程的并发访问,而不需要额外的锁或同步机制。它的三个成员函数 Load/Store/Delete 的时间复杂度(均摊时间)都是 O(1)。
Map 类型有特定的应用场景。由于内置 map 在类型安全上表现更好,也更方便对map里的数据进行定制化处理,加一把锁就能满足大多数需求了。
Map 类型针对下面两个应用场景做了优化:
1)特定的 entry 只被写入一次,但是会被读很多次,比如只增不减的cache;
2)多个 goroutine 的并发读、写、覆盖三种操作的 key 没有交集;
在上面两个应用场景下,相对于内置map+锁方案,Map 里抢占锁的行为会少很多。
Map 类型的默认值是空,可以被立即使用。Map实例在被第一次使用以后,不能被拷贝。
https://golang.org/pkg/sync/#Map
前两段话意思是 sync.Map 功能上是一个支持并发访问的 map,但是有特定的应用场景,大多数情况下你用不到。第三段意思是:sync.Map 针对map粒度有并发读写但key粒度极少并发读写的场景做了专门优化。
具体点说,在上面的 ConcurrentMap 类里,每次读写都会锁住整个map对象,而 sync.Map 的读写在 cache hit 时 lock-free,cache miss 时才需要锁住整个map对象。下面是一个例子来说明 cache hit 和 cache miss:
package mainimport ( "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 是从逻辑上对数据操作的定义。
这个环节介绍下 sync.Map 的使用方法,首先看它的成员函数:
// Load 等价于 m[key]func (m *Map) Load(key interface{}) (value interface{}, ok bool)// Store 等价于 m[key]=valuefunc (m *Map) Store(key, value interface{})// LoadOrStore 读不到key时,会写入 ;然后返回对应的valuefunc (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 mfunc (m *Map) Range(f func(key, value interface{}) bool)
Map 的用法非常符合直觉,唯二需要说的是 Load 和 Range。
由于 Load 返回的是 interface{},我们需要进行显式类型转换。如果 key 不存在,Load 会返回 nil,类型转换会 panic。比如,下面这段代码中 val2.(string) 会导致panic:
package mainimport ( "fmt" "sync")func main() { var m sync.Map m.Store("key", "value") val1, ok1 := m.Load("key") fmt.Printf("val1=%v, ok1=%v", val1, ok1) fmt.Printf("type safe val1 = %v", val1.(string)) val2, ok2 := m.Load("key_not_exist") fmt.Printf("val2=%v, ok2=%v", val2, ok2) fmt.Printf("type safe val2 = %v", val2.(string))}
关于 Range,考虑这个场景:在 Range 执行过程中,如果另一个线程执行了 Store/Delete,执行 Range 的线程能立即感知到变化吗?
要回答这个问题,我们复用下上面的 create/read/update/delete 概念,而不是粗粒度的 Load/Store/Delete 等成员函数,结论是:
由于Range 的执行过程是 1) 加载索引表 2) 遍历索引表,所以 key 集合在第一步就确定下来了,开始遍历后无法感知到新增的key;对于已有的key,遍历过程中通过原子操作取出对应的value,所以能够感知这段时间内发生的update和delete。
小明买了张复活卡,网上搜到了一篇标题叫 “Golang package sync 剖析(四):sync.Map” 的文章,回顾了下面试官的问题,把前三节看完了。看到 lock-free 数据结构,十分好奇,但是急于通关,第四小节没看就去面试了。
面试官:看你用过 Go,大概用过多久,感觉到哪个段位?
小明:用了一年半左右,比较熟悉,算不上精通
面试官:用过 sync.Map 吗?
小明:用过,它是基于 lock-free hashtable 实现的,比快。对读操作做了很多优化,&%..#$#@..%#%%$@*
面试官:嗯,还不错。实现一个简单的 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 时,在命中一级缓存时,执行的操作是:
注:调用 Delete 时,如果能命中一级缓存,entry.p 被置为 nil。
二级缓存是 dirty 字段,依赖锁保证同步。只有在一级缓存cache miss 时,key才会穿透到二级缓存。
对于 Load,cache miss 且两级缓存不同步时执行的操作是:
对于 Store,cache miss时执行的操作是:
对于 Delete,cache miss时执行的操作是:
一级缓存的读写是原子操作,二级缓存的读写通过锁保证同步。在使用过程中,应当尽量避免一级缓存被穿透。说完了 sync.Map 的实现机制,我们看看源码。
Load 的操作相对简单:
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 一级缓存 // 保证期间新增的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 的性能会比较差。
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 一级缓存 // 保证期间新增的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 只存在于二级缓存里,只有 Load 里一级缓存被击穿的次数足够多时,该key 才会被刷到一级缓存里。
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 一级缓存 // 保证期间新增的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 删除。
我们稍微细品一品,如果只命中二级缓存,逻辑非常简单。但是如果命中一级缓存,会有两个问题:
先说问题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 有比较透彻的理解。
小明又买了一张复活卡,…