上图中通过一连串的分析,最终发现是由于cache contention才导致整个操作变慢。具体一点就是,由于RWMutex的RLock方法每次都会对成员变量readerCount做atomic.AddInt32
,如果在多个分别运行在不同CPU上的线程中,同时对同一个RWMutex加读锁,那么每次做完atomic.AddInt32
都会invalidate其他几个CPU 的local cache(比如L1、L2 cache)中的readerCount变量所在的cache line,并把自己的readerCount标识为up-to-date。其他几个CPU会重新拉取up-to-date的数据到自己的local cache中。这些并发的RLock调用最终因为cache contention变成了串行。
MESI协议中,一个CPU如果想更新一个共享的cache line,需要向其他CPU的local cache发送invalidate message,然后在自己的local cache中更改此cache line的状态为exclusive,再更新此cache line。一个CPU想要读取某个cache line,但是local cache中此cache line状态是invalid,那么会从其他CPU的local cache中拷贝此cache line的最新值,并把此cache line的状态设置为share。invalidate cache line和cache line的拷贝相对来说都是比较耗时的。
所以现在,reflect包中的sync.RWMutex+map组合都替换成了sync.Map。
这里提出一个问题:sync.Map是如何解决cache contention的?解决的代价是什么?
sync.Map提供了自己的一套API,与map的大概类比如下表所示。可以看出sync.Map的方法命名更倾向于atomic那一类,这也是作者的初衷——sync.Map ~= a map of atomic pointers
。
map | sync.Map | |
---|---|---|
获取某个value | map[key] | m.Load(key) |
添加key-value | map[key] = value | m.Store(key, value) / m.LoadOrStore(key, value) |
删除一个 key | delete(map, key) | m.Delete(key) / m.LoadAndDelete(key) |
遍历 | for…range | m.Range(func()) |
同时sync.Map缺少一些方法,比如len、swap(最新版本已经有了),据作者表示,可以加但是没必要,可以在github上看到详细讨论4。
后面所述,纯属个人理解,如有不对,敬请指正。
首要问题是解决cache contention,提高读取效率。
有了基本思路,可以进行如下简单设计: Load(key)
:
Store(key, value)
:
Delete(key)
:
上面这个愚蠢的设计,只适合极端读多写少的场景,其他场景下性能完全不如sync.RWMutex+map。但是别急,后面加一点点细节,就成了现在的sync.Map。
要点1——引入entry:
牢记名言——“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”。
type entry struct {
p unsafe.Pointer // *interface{}
}
使用此entry结构体来代替真正的value,有如下几个好处:
引入entry之后,可以进行如下设计:
要点2——替换read-only:
添加新key,会直接写入到read-write map,后续对这个新key的Load操作都会走slow path。为了解决这个问题,sync.Map会记录load和delete的穿透次数,如果达到一个阈值,会用read-write更新替换掉read-only。
阈值的选取?
如何生成sync.Map最新的状态并替换掉read-only?
为什么不记录Store的穿透次数? 其实最开始的实现中,只有Load发生穿透才会被记录下来,后来在这个commit5中,把Delete的穿透操作也记录下来了。至于为什么不记录Store的穿透次数,我估计是因为sync.Map的目标场景并不是这种写多的场景。
要点3——区分nil和expunged entry:
前面提到,通过引入entry,使得delete操作能够转换为store nil的操作,延迟删除,效率更高。那么延迟到什么时候删除呢?
答案已经很明显了——做COW的时候。那么又产生了一个问题——在从read-only拷贝出来一份作为read-write的时候是否拷贝nil entry(也就是被删除的那些key-value):
引入expunged就是为了解决这个问题——标识一个key是被真正删除了:
要点4——引入amended:
amended有两种理解方法:
我觉得第二种更好理解,因为read-write map是从read-only中COW出来的,所以read-write存在的话一定比read-only多了一些新key。
引入amended的好处是:
根据设计所述,就有了最后的实现,关键数据结构如下所示:
type Map struct {
mu Mutex
read atomic.Value // readOnly
dirty map[interface{}]*entry
misses int
}
type readOnly struct {
m map[interface{}]*entry
amended bool // true if the dirty map contains some key not in m.
}
type entry struct {
p unsafe.Pointer // *interface{}
}
详细的实现就不解释了,基本都是按照上面的设计要点做的。下面讲几个我比较感兴趣的实现细节:
Double-checked Locking:
Double-checked Locking是一种”优化小技巧“,可以在不需要获取锁的情况下不进入临界区,减少锁争抢带来的开销。在单例模式中非常常见:
public class DclSingleton {
private static volatile DclSingleton instance;
public static DclSingleton getInstance() {
if (instance == null) {
synchronized (DclSingleton .class) {
if (instance == null) {
instance = new DclSingleton();
}
}
}
return instance;
}
}
即使有很多线程通过了第一道检查,也只有一个线程能进入到临界区。关键是要将写入和第二次检查都放置在临界区内。
sync.Map中大量使用了这种模式,以Load为例:
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
检查read-only
mu.Lock()
再次检查read-only
读写dirty
替换read-only
mu.Unlock()
}
Compare-and-swap loop:
CAS loop是一个非常实用的无锁编程技巧6。常见的写法就是:
var i int32
for {
p := atomic.LoadInt32(&i)
if atomic.CompareAndSwapInt32(&i, p, p+1) {
break
}
}
众所周知,两个原子操作进行简单组合之后,整个过程就不是原子的。但是CompareAndSwapInt32操作是特殊的,它天然能避免写写冲突(虽然无法避免ABA的情况,但在sync.Map场景下可以接受),再加上atomic的所有操作都能避免读写冲突,因此LoadInt32和CompareAndSwapInt32组合起来是线程安全的。如果没有其他线程并发地对i写入,那么这个循环只需要跑一次就能退出。如果有其他线程并发地对i写入,并且是在LoadInt32和CompareAndSwapInt32之间,那么CompareAndSwapInt32会失败,循环重试,直至CompareAndSwapInt32成功。总体来说,这个+1
的操作在外界看来是原子的。
sync.Map源码中,对一个entry的修改分为两类:
entry.storeLocked
,entry.unexpungeLocked
entry.delete
, entry.tryStore
等等这里可以对比看一下:
func (e *entry) storeLocked(i *interface{}) {
atomic.StorePointer(&e.p, unsafe.Pointer(i))
}
func (e *entry) tryStore(i *interface{}) bool {
for {
p := atomic.LoadPointer(&e.p)
if p == expunged {
return false
}
if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
return true
}
}
}
storeLocked 方法调用的前提是entry必须是unexpunged,因为真正删除的entry不能直接store,前面设计要点中讲过原因。要确保entry必须要unexpunged,就必须要读一次entry,然后才能调用storeLocked方法。为了确保这个读写过程是线程安全的,这里采用了加锁。就是由storeLocked的调用者保证判断entry是unexpunged(读),然后调用entry的storeLocked(写)
这个过程是始终加锁的。
tryStore方法中会自己判断entry是否是expunged,如果不是会进行CAS loop。就是因为要判断entry是否是expunged,这里才会写成CAS loop,否则的话可以直接用atomic.StorePointer
。通过CAS loop,可以保证总是在entry是unexpunged(读)的情况下做store(写)
这一过程是原子的。
Range:
这里主要想说的是:sync.Map的Range方法是不会阻塞读写的,所以range过程中数据是会变的。下面具体说说怎么变。
Range方法在开头会使用read-write map替换掉read-only,然后会迭代读取这个read-only。read-only是不会变,它是一个key-entry的集合。但是这里需要注意的是entry是对value指针的封装,所以Range方法是能觉察到对现有key的store。但是无法看到新添加的key,而且如果后续read-only又被替换了,那么Range也是无感的,会一直读取那个read-only。
也就是说Range方法从始至终,看到的key集合是不变的,value集合是可能会变的。
就像map无法应对并发场景的问题,sync.Map也不可能做到完美。但是针对某些场景做到了更优,那么结合sync.Map的设计和实现来说,它的适用场景都有哪些呢?
上面三个是sync.Map作者认为的最适用sync.Map的场景。
比如标准库中的一个use case:Lazy construction,也是sync.Map的original motivating example。
这里可以回忆一下:sync.Map作者为什么认为具有上面三个特征的场景是最适合sync.Map?sync.Map分别做了哪些优化?
另外是作者的建议:if cache contention is not the problem, sync.Map is probably not the solution.
map_bench_test.go中提供了大量的benchmark测试。主要用来对比三种map的性能:
下面依次列出来在我电脑上跑这些benchmark的对比结果:
DeepCopyMap RWMutexMap sync.Map
BenchmarkLoadMostlyHits 5.879 ns/op 34.85 ns/op 6.267 ns/op
BenchmarkLoadMostlyMisses 4.510 ns/op 33.64 ns/op 4.681 ns/op
BenchmarkLoadOrStoreBalanced ~ 323.8 ns/op 332.9 ns/op
BenchmarkLoadOrStoreUnique ~ 513.2 ns/op 551.1 ns/op
BenchmarkLoadOrStoreCollision 2.434 ns/op 53.98 ns/op 2.779 ns/op
BenchmarkLoadAndDeleteBalanced ~ 55.01 ns/op 4.890 ns/op
BenchmarkLoadAndDeleteUnique ~ 58.43 ns/op 5.025 ns/op
BenchmarkLoadAndDeleteCollision 101.3 ns/op 39.08 ns/op 1.475 ns/op
BenchmarkRange 1627 ns/op 40243 ns/op 1649 ns/op
BenchmarkDeleteCollision 89.25 ns/op 37.41 ns/op 1.598 ns/op
可以看出来,除了在BenchmarkLoadOrStoreUnique和BenchmarkLoadOrStoreBalanced两个测试中,sync.Map稍微落后于RWMutexMap,其他测试中都优于RWMutexMap。
sync.Map通过使用read-only、entry中间层和原子操作等等优化,减少了很多场景下的cache contention和lock contention。在读负载较重的场景下,表现非常优异;在需要频繁添加新key的场景下,表现不如RWMutexMap。
这里强调一点,说sync.Map只适合读多写少的场景是错误的,应该说sync.Map不适合频繁写入新key的场景。
sync.Map的设计及优化真的无可挑剔、惊为天人。虽然初读时比较晦涩,但是弄清整个思路之后,就会发现有一种浑然天成的美感。更让人吃惊的是,带上注释、空行,代码竟然还不到四百行。
下面也说一下sync.Map的缺点:
https://github.com/golang/go/commits/master/src/sync/map.go ↩
https://www.youtube.com/watch?v=C1EtfDnsdDs ↩
https://github.com/gophercon/2017-talks/tree/master/lightningtalks/BryanCMills-AnOverviewOfSyncMap ↩
https://github.com/golang/go/issues/20680 ↩
https://github.com/golang/go/commit/2e8dbae85ce88d02f651e53338984288057f14cb ↩
https://preshing.com/20150402/you-can-do-any-kind-of-atomic-read-modify-write-operation/ ↩