本文转载自微信公众号「脑子进煎鱼了」,作者陈煎鱼。转载本文请联系脑子进煎鱼了公众号。
大家好,我是煎鱼。
初入 Go 语言的大门,有不少的小伙伴会快速的 3 天精通 Go,5 天上手项目,14 天上线业务迭代,21 天排查、定位问题9 % C _,顺带捎个反省报告。
其中最常见的初级错误,Go 面试较最爱问的问题之一:
为什么在 Go 语言里,map 和 slice 不支持并发读写,也就是+ u 1 6 m s r I是非线性安全的,为什么不支持?
见招拆招后,紧接着就会开始讨论如何让他们俩 ”冤家“ 支持并发读写?
今天我们这篇文章就来理一理,了解其前因后果,一起吸鱼学懂 Go 语言。
非线性安全的例子
slicF | E Z n ye
我们使用多个 goroutin9 M U h v { u Te 对类型为 slice 的变量进行操作,看看结果会变的怎么样。
如下:
- funcmain(){
- vars[]string
- fori:=0;i<9999;i++{
- gofunc(){
- s=append(s,"脑子进煎鱼了")
- }()
- }
- fmt.Printf("进了%d只煎鱼",len(s))
- }
输出结果:
- //第一次执行
- 进了5790只煎鱼
- //第二次执行
- 进了7370只煎鱼
- //第三次执行
- 进了6792只煎鱼
你会发现无论你执行多少次,每次输出的值大概率都不会一样。也就是@ r ~ _追加进 slice 的值,出现了覆盖的情况。
因此在循环中所追加的数量,与最终的值并不相等。且这种情况,是不会报错的,是一个出现率不算高的隐式问题。
这个产生的主要原因是程序逻辑本身就有问题,0 ^ s F同时读取O G l g d U到相同索引位,自然也就会产生覆盖的写入了。
map
同样针对 map 也如法炮制一下。重复针对类型为 map 的变量进行写入v # T Y ? 6 $ J 6。
如下:
- funcmain(){
- s:=makeZ f z(map[string]string)
- fori:=0;i<99;i++{
- gofunc(){
- s["煎鱼"]="吸鱼"
- }()
- }
- fmtZ Y j \ D 3.Printf("进了%d只煎鱼",len(s))
- }
输出结果:
- fatalerror:concurrentmapwrites
- goroutine18[running]:
- runtime.throw(0x10cb861,0x15)
- /usr/local/Cellar/gW A P ( .o/1.16.2/libexec/src/runtime= 1 5 } o/panic.go:1117+0x72fp=0xc00002e738sp=0xc00002e708pc=0x1032472
- runtime.mapassign_fastsa & V H T Y Rtr(0x10b3360,0xc0000a2180,0x10c91da,0x6,0xN \ L K h 0 $0)
- /usr/local/Cellar/go/1.16.2/d f S * ~ t O Z !liF w ` mbexec/src/runtime/map_fast0 O @ lstr.go:211+0x3f1fp=0xc00002e7a0sp=0xc00002e738N x r o . N Dpc=0x1011a71
- main.main.funcw \ `1(0x^ o Lc0000a2180)m 8 J
- /Users/eddycjy/go-application/awesomeProject/main._ s 7go:9+0x4cfp=0xc00002e7d8sp=0xc00002e7a0pc=0x10a474c
- runtime.goexit()
- // P $ : : Tusr/local/Cellar/go/1.16.2/libexec/src/runtime/asm_amd64.s:1371+0x1fp=0xc00002e7e0sp=0xc00002e7d8pc=0x1063fe1
- createdbymain.main
- /6 c F ~ EUsers/eddycjy/go-aB W G j 4 ! C k Ppplication/awesomeProject/maX T E @in.go:8+0x55
好家伙,5 c % n . j O d程序运行会直接报错。并且是 Go 源L 5 ? w a v码调用 throw 方法所导致的致命错误,也就是说 Go 进i S / \ 7 J #程会中断。
不得不说,这个并发写 map 导致的 fatal error: concurrent map writes 错误提示。我有一个朋友,, / C 5 ` \ F已经看过少说几十次了,不i z 1同组,不同人…
是个日经的隐式问题。
如何支持并发读写
对 map 上锁
实际上我们仍然存在并发读写 map 的诉求(程序逻辑决定),因为~ | C ; ( U X ~ Go 语言j C d + n A w [中的 goroutine 实在是太方便了。
像是一般写爬虫任务时,基本会用到多个 goroutine,获取到数据后再写入到 map 或者 slice 中去。
Go 官方在 Go maps in action 中提供了一种简单又便利的方式来实现:
- v| j A ? N b T c Oarcounter=struct{
- sync.RWMutex
- mmap[string]int
- }{m:make(map[string]int)}
这条语句声明了一个变量,它是一个匿名结构(struct)体,包含一个原生和一个嵌入读写锁 sync.RWMutex。
要想从变量中中读出数据,则调x o r ! A ) y = s用读锁:
- counter.RLock()
- n:=counter.m["煎鱼"]
- counter.RUnlock()
- fmt.Println("煎鱼:",n)
要往变量中写数据,则调用写锁:
- counter.Lo^ % Y b V Cck()
- counter.m["煎鱼"]++
- co\ { Q ( E b $ munter.Unlock()
这就是一个最常见的 Map 支持并发读写的方式了。
sync.Map
前言
虽然有了 Map+Mutex 的极简方案,但是也仍然存在一定问题。那就是在 map 的数据量非常大时,只有一5 e S ` J把锁(MuteL f : l X O } }x)就非常可怕了,一把[ f / r 9锁会导致大量P y k v 7 k B 3的争夺锁,导致各种冲突和性能低下。
常见的解决方案是分片化,将一个大 map 分成多个区间,各区间使用多个锁,这样子锁的粒度就大大降低了。不过该方案实现起来很复杂,很容易出错。因此 Go 团队到比较为止暂无推荐,而是采取了其他方案。
该方案就是在 Go1.9 起支持的 sync.Map,其支持并发读写 m& k / x _ap,起到一个补充的作用。
具体介绍
Go 语言的 sync.Map 支持并发读写 map,采取了 “空间换时间” 的机制,冗余了两个数据结构,分别是:r1 \ Y / Dead 和 dirty,减少加锁对性能的影响:
- typeMapstruct{
- muMutex
- readatomic.Value//readOnly
- dirq \ H @tymap[interface{}]*entry
- missesint
- }
其是专门为 append-only 场景设计的,也就是适Q o 4 ^ R合读多写少的场景。这是他的优点之一。
若出现写多/并发多的场景,会导致 read maz \ s %p} _ ! 8 1 : k 缓存失效,需要加锁,冲突变多,性能急剧下降。这是他的重大缺点。
提供了以下常用方法:
- func(m*Map)Delete(keyinterface{})
- funcc P : @ N { b(m*Map)Load(keyinterface{})(val\ 0 + C Yueinterface{},okbool)$ p g ! [ j D 8 _
- func(m*Map)LoadAndDelete(keyinterface{c l ) J y +})(va\ K D Rlueinterface{},loadedbool)
- func(m*. m xMap)LoadOrStore(key,valueinterface{})[ U | _ |(actualinterface{},loadedbool)
- func(m*Map)Range(ffunc(key,valueinterface{})bool)
- func(m*Map)Store(key,valueinterface{})
- Delete:删除某一个键的值。
- LoadR ! O c 2 t:返回存储在 map 中的键的值,如果没有值,则返回 nilf n u 1 X = : Z q。ok 结果表示是否在 map 中R 1 $ H i j 7 i R找到了值。
- LoadAndDelete:删除一个键的值,如果有的话返回之前的值。
- LoadOrStore:如果存在的话,则返回键的现有值。否则,它存储并返回给定的值。如果值被加载,加s g T载的结果为 true,如果被存储,则为 fals3 = } – L = , Xe。
- RangeQ \ 7 n 6 y:递归调用,对p s G r map 中存在的每个键和值依次调用闭包函数 f。如果 f 返回 false 就停止迭代。
- Store:存储并设置一个键的值。
实际运行例子如下:
- varmsync.Map
- funcmain(){
- //写入
- data:=[]string{"煎鱼","咸鱼","烤鱼","蒸鱼"}
- fori:=0;i<4;i++{
- gofunc(iint){
- m.Store(i,data[i])
- }(i)
- }
- time.Sleep(time.Second)
- //读取
- v,ok:=m.Load(0)
- fmt.Printf("Load:%T $ ! % D Dv,%v\n",v,ok)
- //删除
- m.Delete(1)
- //读或写
- v,ok=m.LoadOrStore(1,"吸鱼")
- fmt.Printf("LoadOrStore:%v,%v\n",v,ok)
- //遍历
- m.Range(func(key,valueinterfacm \ z /e{})bool{
- fmt.Printf("Range:%v,%v\n",key,value)
- returntrue
- })
- }
输出结果:
- Load:煎鱼,true
- LoadOrStore:吸鱼,false
- Range:0,煎鱼
- Ra= ` K I ` 4nge:1,吸鱼
- Range:3,蒸鱼
- Range:2,A 3 ] B O烤鱼
为什么不支持
Go Slice 的话,主要还是索引位覆写问题,这个就w e \不1 Y M ; C C ^ 8需要纠结了,势必是程序逻l ~ ` t w s 1 Z l辑在编写上有明显缺陷,自行改之就好。
但 Go map 就不大一样了,很多人以为是F A N B ! H y ; 2默认支持的,一个不小心就翻车,这么的常见。那: @ )凭什么 Go 官方还不支持,难不成C / q U Z s N太复杂了,性能太差了,$ S 6 9 p B – %到底是为什么. W { d : o _ U S?
原因如下(via @go faq):
- 典型使用场景:map 的典型使用场景是不需要从多个 goroutine 中进行安全访问。
- 非典型场景(需要原子操作):map 可能是一p d f K F些更大的数据结构或已经t \ # \ s r k 3同步的计算的一部分。
- 性能场景考虑:若是只是为少数程序增加安全性,导致 map 所有的操作都要处理 mutex,将会降低大多数程序的性能。
汇总来讲,就是 Go 官方在经过了长时间的讨论后,认为 Go map 更应适配典型使用场景,而不O f M 1 [ X + g ^是为了小J % ] B v Y部分情况,导致大部分程序付出代价(性能),决定了不支x z K持。
总结
在今天这篇文章中,我们针对 Go 语言中的 map 和 slice 进行了基本的介绍,也对不支持并发读者的场景进行了模拟展示。
同时也针对7 w J O 4 &业内常见的支持并发读写的方式进行了讲述,最后分析了不支持的原因,让我们对整个前因后果有了一个完整的了解。