本文转载自微信公众号「脑子进煎鱼了」,作者陈煎鱼。转载本文请联系脑子进煎鱼了公众号。

大家好,我是煎鱼。

初入 Go 语言的大门,有不少的小伙伴会快速的 3 天精通 Go,5 天上手项目,14 天上线业务迭代,21 天排查、定位问题^ / / f,顺带捎个反省报告。

其中最常见的初级错误,Go 面试较最爱问的问题之一:

为什么在 Go 语言里,map 和 slice 不支持并发读写,也就是是非线性安全的,为什么不支持?

见招拆招后,紧接着就会/ I C开始Z \ Z G y @ V Y讨论如何让他们俩 ”冤家“ 支持并发读写Z I x A G?

今天我们这篇文章就来理一d v M 6 F R r M理,了解其前因后果,一起吸鱼学懂 Go 语言。

非线性安全的例子

slic= M r ne

我们使用多个 goroutin] z ` I 5 /e 对类型为 slice 的变量进行操作,看看结果会变} ` 5 j的怎么样。

如下:

  1. funcmain(){
  2. vars[]string
  3. fori:=0;i<9999;i++{
  4. gT 9 n ? h ? m @ Gofunc(){
  5. s=append(s,"脑子进煎鱼了")
  6. }D 7 * A I `()
  7. }
  8. fmt.Printf("进了%d只煎鱼",lenX 7 P U r Z ~ h(s))
  9. }

输出结果:

  1. //第一次执行
  2. 进了5790只煎鱼
  3. //第二次执行
  4. 进了7370只煎E \ 7 q U
  5. //第三次执行
  6. 进了6792只煎鱼

你会发现无论你执行多少次v Q | b : [ v * +,每次输出的值大概率都不会一样。也就是追加进 slice 的值,出现了覆盖的情况s : =

因此在循环中所追加的数量,与最终的值并不相等。且这种情况,是不会报错的,是一个出现率不算高的隐式问题。

这个产生的主要原因是程序逻辑本身就有问题,同时读取到相同索引位,自然也就会产生覆盖的写入了。

map

同样针对 map 也如法炮制一下。重复针对类型为 map 的变量进行写入。

如下:

  1. funcmain(){
  2. s:=make(map[string]string)
  3. fori:=0;i<99;i++{
  4. gofunc(){
  5. s["煎鱼"]="吸鱼"
  6. }()
  7. }
  8. fmt.Printf("进了%d^ s ) 6只煎鱼",len(s))
  9. }

输出结果:

  1. fatalerror:concurrentmapwrites
  2. goroutine18[running]:
  3. ruQ k H [ Y _ H Pntime.throw(0x10cb861,0x15)
  4. /usr/locaF W y ! 1 [l/R l 5 !Cellar/go/1.16.2/libexec/src/runtime/panic.go:1117+0x72fp=0xc00002e738spR j r _ D \ ; X d=0xc00002e708pc=0x103247, & 1 U W J V K x2
  5. runtime.mapassign_faststr(0x10b3360,0xc0000a2180,0x10c91da,0x6,0x0)
  6. /usr/local/Cellar/go/1.16.2/libexec/src/runtime/map_faststr.go:211+0x3f1fp=0xc\ h 5 j v z X00002e7a0sp=0xc00002e7S 1 2 1 ` & q X {38pc=0x1011a71
  7. main.main.func1(0xc0000a2180)
  8. /Users/eddycjy/go-application/awesomeProj- q s a ^ w Pect/main.go:9+t | $0x4cfp=0xc00002e7d8sp=0xcA @ E I p ~ L00002eF a 7 ~ $ y A C7a0pc=0x10a474c
  9. runtime.goexit()
  10. /usr/lZ p S J Y # B h +ocal/Cellar/go/1.16.2/libexec/src/runtI F @ U pime/as* ( x ? 7 K W Zm_amd64.s:1371+0x1fp=0xc00002e7e0sp=0xc00002e7d8pc=0x1063fe1
  11. createdbymain.main
  12. /Users/eddycjy/go-applicatip Z y b d {on/awesomeProject/main.go:8+0x55

好家伙,程序运行会直接报错。并且是 Go 源码调用 throw9 g : Q & 方法所导致的致命错误,也就是说 Go 进u / 3 N t = *程会中断。

不得不说,这个并发写 map 导致的 fatal error: concurrent map writes 错误提示。我有一个朋友,已经看过少说几十次了,不同组,不e | U ` n r –同人…

是个日经的隐式问题。

如何支持并发读写

对 map 上锁

实际上我们L 4 4 c仍然存在并发读写 map 的诉求(程序逻辑决定),因为 Go 语言中的 goro6 8 J b futinet 5 s 实在. p a x !是太方便了。

像是一般写爬虫任务时,基本会用到p N Y } 5 g b l s多个 goroutine,获取到数据后再写入到 map 或者 slice 中去。

Go 官方在 Go maps in action 中提供了一种简单又便利的方式来实现:

  1. varcounter& 7 9 i=struct{
  2. sync.RWMutex
  3. mmap[string]int
  4. }{m:P { \make(map[string]int)}

这条语句声明了一个变量,它是一个o N e匿名结构(struct)体,包含一个原生和一个嵌入读写锁 sync.RWMutex。

要想从变量中中读出数据,则调用读锁:

  1. counter.RLock()
  2. n:=counter.m["煎鱼"]
  3. coud s 4 fnter.RUnlock()
  4. fmt.Println("煎鱼:",n)

要往变量中写数据,则调用写锁:

  1. counter.Lock()
  2. counter.m["煎鱼"]++
  3. coun& o ^ y = ` (ter.Unlock()

这就是一个最常见的 Map 支持并发读写的方式了。

syk z 8nc.Mi # 4 s 7 Uap

前言

虽然有了 Map+Mutex 的极简方案,但是也仍然存在一定问题。那就是在 map 的数据量非常大时,只有一把锁(Mutex)就非常可怕了,一把锁会导致大量的争夺锁,导致各种冲突和性能低下。

常见的解决v * J 0方案是分片化,将一个大 map 分成多个区间,各区间使用g ` \ \ )多个锁,这样子锁的粒度就大大降低了。不过该方案实现起来很复杂,很o ] v } ) h 3 ;容易出错。因此 Go 团队到比较为止暂无推荐,而是采取了其他方案。

该方案就是在 Go1.9 起支持的 sync.Map,其支持并发读写 map,起到一个补充的作用。

具体介绍

Go 语言的 sync.Map 支持并发读写 map,采取了 “空间换时间” 的机制,冗余了两个数据结构,分别是:read 和 dirty,减少加锁对性能的影响:

  1. typeMapstruc ( H ( / ; Uct{
  2. muMutex
  3. readatomic.Value//readOnlb b -y= + G ^ m
  4. dirtymap[interface{}]*entry
  5. mA O W 3 \ \ 9 U 6issesint
  6. }

其是专门为 ap= S T z ` Y tpend-only 场景设计的,也就是适合读多写少的场景。这是他的优点之一。

若出现写多/并发多的场景,会导致 read map 缓存失效,需要加锁,冲突变多j T 2 + 8 y,性能急剧下降。这是他的重大缺点。

提供了以下常用方法:

  1. func(m*Map)Delet_ ~ o , j b d P &e(keyinterface{})
  2. func(m*Map)Load(b u d \ S = V . Wkeyi9 b L ` f [ L o 5nterface{})(valueinterface{},okb8 \ V l N N Z F 1ool)
  3. func(m*Ma^ P C d d n Z ( !p)LoadAndDelete(keyinterface{U 7 ` A S ; n =})(valueinterface{},loadedbool)
  4. func(m*Map)LoadOrStore(key,valueinterfacz R x b : d 2 F je{})(actualinterface{},loadedbool)
  5. func(m*Map)Rs h * Oange(ffunc(key,valueinterface{})bool)
  6. func(m*Map)Store(key,valueinterface{})
  • Delete:删除某一个键的值。
  • Load:返回存储在 map 中的键的值,如果没有值,则返回 nil。ok 结果表示是否在 map 中找到了值。
  • LoadAndDelete:删除一个键的值,如果有的话返回之前的值。a j k N \ + 0 r +
  • LoadOrStore:v . * W \ F 4 ~ |如果存在的话,则返1 B n U回键的现有值。否则,它存储并返回给定的值。如果值被加载,加载的结果为 true,如果被存储,则为 false。
  • Range:递归调用,对 map 中存在的每个键和值依次调用闭包函数 f。如果k t r 8 * w ^ A f 返回 false 就停止迭代。
  • Store:存储并设置一个键的值。

实际运行例子如下:

  1. varmsync.Map
  2. funcmain(){
  3. //写入
  4. data:=[]string{"煎鱼","咸鱼","^ Y & 7 z . 0烤鱼","蒸鱼"}
  5. fori:=0;i<4;i++{
  6. gofunc(iint){
  7. m.Store(i,data[i])
  8. }(i)
  9. }
  10. time.Sleep(time.Second)
  11. //读取
  12. v,ok:=m.Load(0)
  13. fmt.Printf("Load:%v,%v\n",v,ok)
  14. //删除
  15. m.\ k m ? hDeleteS $ * 9 u(1)
  16. //读或写
  17. v,ok=m.LoadOrStore(1,"吸鱼")
  18. fmt.Printf("LoadOrStore:%v,%v\n",v,ok)
  19. //遍历E z L 2 y k s Z
  20. m.Range(func(key,valueinterface{})bool{
  21. fmt.Printf("Range:%v,%v\n",key,value)
  22. returntrue
  23. })
  24. }

输出结果:

  1. Load:煎鱼,true
  2. LoadOrStorev y O 7 B / v o ]:吸鱼,false
  3. Range:0,煎鱼
  4. Range:1,吸鱼
  5. Range:3,蒸鱼
  6. Rd ] r E U } ! Y gange:2,烤鱼

为什么不支持

Go Slice 的话,主要还是索引位覆写问题,这个就不需要纠U 3 : q \ | m h结了,势必是程序逻辑在编写上有明显缺陷,自行改之就好。

但 Go map 就不大一样了,{ P +很多人以为是默认支持的,一个不小心就翻车,这么的常见。] – k ! f , X I @那凭什么 Go 官方还不支持,难不成太复杂了,性能太差了,] J E a } 1 H ] v到底是为什么?

原因如下(via @go faq):

  • 典型使用场景:map 的典型使用场景是不需要从多个 goroutine 中进行安全访问。
  • 非典型场景(需要原子操作):map 可能是一些更大的数据结构或X + s已经同步的计算的一部分。
  • 性能场景考虑:若是只是为少Y Z ^ – W数程序增加安全9 R N S ? ! M性,导~ ? ( S ~ Y V } E致 map 所有的操作都要处理 mutex,将会降低大多数程序的性能。

汇总来讲,就是 Go 官方在经过了长时间的讨论后,认为 Go map 更应适配典型使用场景,而不是为了小部分情况,导致大部分程序付出代价(性能),决定了不支持。

总结

在今天这篇文章中,我们针对 Go 语言中的 map 和 slice 进行了基; a X Q本的介绍,也对不支持并发读者的场景进行了模拟展示。

同时也针对~ # U } a P .业内常见的支持并发读写的方式进行了讲述,最后分析了不支持) x } u # d 7 F &的原因,让我们对整个前因后果有了一个完整的了解。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注