登录
登录 注册新账号
注册
已有账号登录
Node.js工程师养成计划完整无密
王之 阅读 239 次
6月13日发布

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.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 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/experiments/tree/master/go19-examples/benchmark-for-map下面

$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的行为特征。

  1. 初始状态
    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{}))

复制代码

  1. 写入数据(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。

  1. dirty提升(promoted)为read
    此时,假如我们调用一次sync.Map的Load办法,无论传给Load的key值能否为”key1″还是其他,sync.Map内部都会发作较大变化,我们来看一下:
    // github.com/bigwhite/experiments/tree/master/inside-syncmap/syncmap/main.go

    m.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状态没有任何影响。