近期在项目考虑在内存中保存从数据库加载的配置数据的方案,初步考虑采用map来保存。Go语言中有两个map,一个是Go语言原生的map类型,而另外一种则是在Go 1.9版本新增到标准库中的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, 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数据:
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就是这样的包。