download:Node.js工程师养成打算残缺无密
通过实例深入理解sync.Map的工作原理
一. 原生map的“先天不足”
对于已经初始化了的原生map,咱们可能尽情地对其进行并发读:
// github.com/bigwhite/experiments/inside-syncmap/concurrent_builtin_map_read.go
package main
import (
"fmt""math/rand""sync"
)
func main() {
var wg sync.WaitGroupvar m = make(map[int]int, 100)for i := 0; i < 100; i++ { m[i] = i}wg.Add(10)for i := 0; i < 10; i++ { // 并发读 go func(i int) { for j := 0; j < 100; j++ { n := rand.Intn(100) fmt.Printf("goroutine[%d] read m[%d]: %d\n", i, n, m[n]) } wg.Done() }(i)}wg.Wait()
}
复制代码
但原生map一个最大的问题就是不反对多goroutine并发写。Go runtime内置对原生map并发写的检测,一旦检测到就会以panic的形式阻止程序持续运行,比如上面这个例子:
// github.com/bigwhite/experiments/inside-syncmap/concurrent_builtin_map_write.go
package main
import (
"math/rand" "sync"
)
func main() {
var wg sync.WaitGroup var m = make(map[int]int, 100) for i := 0; i < 100; i++ { m[i] = i } wg.Add(10) for i := 0; i < 10; i++ { // 并发写 go func(i int) { for n := 0; n < 100; n++ { n := rand.Intn(100) m[n] = n } wg.Done() }(i) } wg.Wait()
}
复制代码
运行下面这个并发写的例子,咱们很大可能会失去上面panic:
$go run concurrent_builtin_map_write.go
fatal error: concurrent map writes
... ...
复制代码
原生map的“先天不足”让其无奈间接胜任某些场合的申请,于是gopher们便寻求其余路径。一种路径无非是基于原生map包装出一个反对并发读写的自定义map类型,比如,最简略的形式就是用一把互斥锁(sync.Mutex)同步各个goroutine对map内数据的拜访;如果读多写少,还可能利用读写锁(sync.RWMutex)来保护map内数据,缩小锁竞争,提高并发读的性能。很多第三方map的实现原理也大抵如此。
另外一种路径就是使用sync.Map。
二. sync.Map的原理简述
按照官网文档,sync.Map是goroutine-safe的,即多个goroutine同时对其读写都是ok的。和第一种路径的最大区别在于,sync.Map对特定场景做了性能优化,一种是读多写少的场景,另外一种多个goroutine读/写/修改的key会合没有交加。
上面是两种技术路径的性能基准测试后果对比(macOS(4核8线程) go 1.14):
// 对应的源码在https://github.com/bigwhite/e...上面
$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/experiments/go19-examples/benchmark-for-map
BenchmarkBuiltinMapStoreParalell-8 7945152 179 ns/op
BenchmarkSyncMapStoreParalell-8 3523468 387 ns/op
BenchmarkBuiltinRwMapStoreParalell-8 7622342 190 ns/op
BenchmarkBuiltinMapLookupParalell-8 7319148 163 ns/op
BenchmarkBuiltinRwMapLookupParalell-8 21800383 55.2 ns/op
BenchmarkSyncMapLookupParalell-8 70512406 18.5 ns/op
BenchmarkBuiltinMapDeleteParalell-8 8773206 174 ns/op
BenchmarkBuiltinRwMapDeleteParalell-8 5424912 214 ns/op
BenchmarkSyncMapDeleteParalell-8 49899008 23.7 ns/op
PASS
ok github.com/bigwhite/experiments/go19-examples/benchmark-for-map 15.727s
复制代码
咱们看到:sync.Map在读和删除两项性能基准测试上的数据都大幅领先使用sync.Mutex或RWMutex包装的原生map,仅在写入一项上存在一倍的差距。sync.Map是如何实现如此高的读取性能的呢?简略说:空间换工夫+读写分离+原子操作(快路径)。
sync.Map底层使用了两个原生map,一个叫read,仅用于读;一个叫dirty,用于在特定情况下存储最新写入的key-value数据:
图:sync.Map内置两个原生map
read(这个map)好比整个sync.Map的一个“高速缓存”,当goroutine从sync.Map中读取数据时,sync.Map会首先查看read这个缓存层是否有用户需要的数据(key是否命中),如果有(命中),则通过原子操作将数据读取并返回,这是sync.Map推荐的快路径(fast path),也是为何下面基准测试后果中读操作性能极高的原因。
三. 通过实例深入理解sync.Map的原理
sync.Map源码(Go 1.14版本)不到400行,应该算是比较简略的了。但对于那些有着“浏览源码恐惧症”的gopher来说,咱们可能通过另外一种研究方法:实例法,并拆散些许源码来从“黑盒”角度理解sync.Map的工作原理。这种方法十分适合那些绝对独立、可能从标准库中“独自”取出来的包,而sync.Map就是这样的包。
首先,咱们将sync.Map从标准库源码目录中拷贝一份,放入本地/go/src/github.com/bigwhite/experiments/inside-syncmap/syncmap/sync上面,得益于go module的引入,咱们在/go/src/github.com/bigwhite/experiments/inside-syncmap/syncmap目录上面建立go.mod文件:
module github.com/bigwhite/go
go 1.14
复制代码
这样咱们就可能通过github.com/bigwhite/go/sync包路径导入module:github.com/bigwhite/go上面的sync包了。
接下来,咱们给位于~/go/src/github.com/bigwhite/experiments/inside-syncmap/syncmap/sync上面的map.go中(sync.Map包的正本)增加一个Map类型的新方法Dump:
// github.com/bigwhite/experiments/tree/master/inside-syncmap/syncmap/sync/map.go
func (m *Map) Dump() {
fmt.Printf("=====> sync.Map:\n") // dump read read, ok := m.read.Load().(readOnly) fmt.Printf("\t read(amended=%v):\n", read.amended) if ok { // dump readOnly's map for k, v := range read.m { fmt.Printf("\t\t %#v:%#v\n", k, v) } } // dump dirty fmt.Printf("\t dirty:\n") for k, v := range m.dirty { fmt.Printf("\t\t %#v:%#v\n", k, v) } // dump miss fmt.Printf("\t misses:%d\n", m.misses) // dump expunged fmt.Printf("\t expunged:%#v\n", expunged) fmt.Printf("<===== sync.Map\n")
}
复制代码
这个方法将打印Map的外部状态以及read、dirty两个原生map中的所有key-value对,这样咱们在初始状态、store key-value后、load key以及delete key后通过Dump方法输入sync.Map状态便可能看到不同操作后sync.Map外部的状态变动,从而间接理解sync.Map的工作原理。上面咱们就分情况剖析sync.Map的行为特色。
- 初始状态
sync.Map是零值可用的,咱们可能像上面这样定义一个sync.Map类型变量,并无需做显式初始化(对于零值可用,在我的Go专栏《改善Go语言编程品质的50个无效实践》中有顺便的一节详述,有兴趣的gopher可能订阅学习^_^)。
// github.com/bigwhite/experiments/tree/master/inside-syncmap/syncmap/main.go
var m sync.Map
复制代码
咱们通过Dump输入初始状态下的sync.Map的外部状态:
// github.com/bigwhite/experiments/tree/master/inside-syncmap/syncmap/main.go
func main() {
var m sync.Map fmt.Println("sync.Map init status:") m.Dump() ... ...
}
复制代码
运行后,输入如下:
sync.Map init status:
=====> sync.Map:
read(amended=false): dirty: misses:0 expunged:(unsafe.Pointer)(0xc0001101e0)
<===== sync.Map
复制代码
在初始状态下,dirty和read两个内置map内都无数据。expunged是一个哨兵变量(也是一个包内的非导出变量),它在sync.Map包初始化时就有了一个固定的值。该变量在后续用于元素删除场景(删除的key并不立即从map中删除,而是将其value置为expunged)以及load场景。如果哪个key值对应的value值与explunged一致,说明该key已经被map删除了(即便该key所占用的内存资源尚未开释)。
// map.go
var expunged = unsafe.Pointer(new(interface{}))
复制代码
- 写入数据(store)
上面,咱们向Map写入一条数据:
// github.com/bigwhite/experiments/tree/master/inside-syncmap/syncmap/main.go
type val struct {
s string
}
func main() {
... ... val1 := &val{"val1"} m.Store("key1", val1) fmt.Println("\nafter store key1:") m.Dump() ... ...
}
复制代码
咱们看一下存入新数据后,Map外部的状态:
after store key1:
=====> sync.Map:
read(amended=true): dirty: "key1":&smap.entry{p:(unsafe.Pointer)(0xc000108080)} misses:0 expunged:(unsafe.Pointer)(0xc000108040)
<===== sync.Map
复制代码
咱们看到写入(key1,value1)后,Map中有两处变动,一处是dirty map,新写入的数据存储在dirty map中;第二处是read中的amended值由false变为了true,示意dirty map中存在某些read map还没有的key。
dirty晋升(promoted)为read
此时,如果咱们调用一次sync.Map的Load方法,无论传给Load的key值是否为”key1″还是其余,sync.Map外部都会发生较大变动,咱们来看一下:
// github.com/bigwhite/experiments/tree/master/inside-syncmap/syncmap/main.gom.Load("key2") //这里咱们尝试load key="key2" fmt.Println("\nafter load key2:") m.Dump()
复制代码
上面是Load方法调用后Dump方法输入的内容:
after load key2:
=====> sync.Map:
read(amended=false): "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} dirty: misses:0 expunged:(unsafe.Pointer)(0xc000010200)
<===== sync.Map
复制代码
咱们看到:原dirty map中的数据被晋升(promoted)到read map中了,晋升后amended值从新变回false。
拆散sync.Map中Load方法的源码,咱们得出如下sync.Map的工作原理:当Load方法在read map中没有命中(miss)传入的key时,该方法会再次尝试在dirty中持续匹配key;无论是否匹配到,Load方法都会在锁保护下调用missLocked方法减少misses的计数(+1);如果减少完计数的misses值大于等于dirty map中的元素个数,则会将dirty中的元素整体晋升到read:
// $GOROOT/src/sync/map.go
func (m *Map) missLocked() {
m.misses++ //计数+1 if m.misses < len(m.dirty) { return } m.read.Store(readOnly{m: m.dirty}) // dirty晋升到read m.dirty = nil // dirty置为nil m.misses = 0 // misses计数器清零
}
复制代码
为了考据上述promoted的条件,咱们再来做一组试验:
val2 := &val{"val2"} m.Store("key2", val2) fmt.Println("\nafter store key2:") m.Dump() val3 := &val{"val3"} m.Store("key3", val3) fmt.Println("\nafter store key3:") m.Dump() m.Load("key1") fmt.Println("\nafter load key1:") m.Dump() m.Load("key2") fmt.Println("\nafter load key2:") m.Dump() m.Load("key2") fmt.Println("\nafter load key2 2nd:") m.Dump() m.Load("key2") fmt.Println("\nafter load key2 3rd:") m.Dump()
复制代码
在实现一次promoted动作之后,咱们又向sync.Map中写入两个key:key2和key3,并在后续Load一次key1并间断三次Load key2,上面是Dump方法的输入后果:
after store key2:
=====> sync.Map:
read(amended=true): "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} dirty: "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} "key2":&smap.entry{p:(unsafe.Pointer)(0xc000010290)} misses:0 expunged:(unsafe.Pointer)(0xc000010200)
<===== sync.Map
after store key3:
=====> sync.Map:
read(amended=true): "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} dirty: "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} "key2":&smap.entry{p:(unsafe.Pointer)(0xc000010290)} "key3":&smap.entry{p:(unsafe.Pointer)(0xc0000102c0)} misses:0 expunged:(unsafe.Pointer)(0xc000010200)
<===== sync.Map
after load key1:
=====> sync.Map:
read(amended=true): "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} dirty: "key3":&smap.entry{p:(unsafe.Pointer)(0xc0000102c0)} "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} "key2":&smap.entry{p:(unsafe.Pointer)(0xc000010290)} misses:0 expunged:(unsafe.Pointer)(0xc000010200)
<===== sync.Map
after load key2:
=====> sync.Map:
read(amended=true): "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} dirty: "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} "key2":&smap.entry{p:(unsafe.Pointer)(0xc000010290)} "key3":&smap.entry{p:(unsafe.Pointer)(0xc0000102c0)} misses:1 expunged:(unsafe.Pointer)(0xc000010200)
<===== sync.Map
after load key2 2nd:
=====> sync.Map:
read(amended=true): "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} dirty: "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} "key2":&smap.entry{p:(unsafe.Pointer)(0xc000010290)} "key3":&smap.entry{p:(unsafe.Pointer)(0xc0000102c0)} misses:2 expunged:(unsafe.Pointer)(0xc000010200)
<===== sync.Map
after load key2 3rd:
=====> sync.Map:
read(amended=false): "key1":&smap.entry{p:(unsafe.Pointer)(0xc000010240)} "key2":&smap.entry{p:(unsafe.Pointer)(0xc000010290)} "key3":&smap.entry{p:(unsafe.Pointer)(0xc0000102c0)} dirty: misses:0 expunged:(unsafe.Pointer)(0xc000010200)
<===== sync.Map
复制代码
咱们看到在写入key2这条数据后,dirty中不只存储了key2这条数据,原read中的key1数据也被复制了一份存入到dirty中。这个操作是由sync.Map的dirtyLocked方法实现的:
// $GOROOT/src/sync/map.go
func (m *Map) dirtyLocked() {
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 { if !e.tryExpungeLocked() { m.dirty[k] = e } }
}
复制代码
后面咱们提到过,promoted(dirty -> read)是一个整体的指针交换操作,promoted时,sync.Map间接将原dirty指针store给read并将自身置为nil,因此sync.Map要保障amended=true时,dirty中具备整个Map的全量数据,这样在下一次promoted(dirty -> read)时才不会丢失数据。不过dirtyLocked是通过一个迭代实现的元素从read到dirty的复制,如果Map中元素范畴很大,这个过程付出的损耗将很大,并且这个过程是在锁保护下的。
在存入key3后,咱们调用Load方法先load了key1,因为key1在read中有记录,因此此次load命中了,走的是快路径,对Map状态没有任何影响。