为什么 Go Map 和 Slice 是非线性安全的?

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

[[399435]]

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

大家好,我是煎鱼。

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

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

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

见招拆招后,紧接着就会开始讨论如何让他们俩 ”冤家“ 支持并发读写?

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

非线性安全的例子

slice

我们使用多个 goroutine 对类型为 slice 的变量进行操作,看看结果会变的怎么样。

如下:

  1. func main() { 
  2.  var s []string 
  3.  for i := 0; i < 9999; i++ { 
  4.   go func() { 
  5.    s = append(s, "脑子进煎鱼了"
  6.   }() 
  7.  } 
  8.  
  9.  fmt.Printf("进了 %d 只煎鱼", len(s)) 

输出结果:

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

你会发现无论你执行多少次,每次输出的值大概率都不会一样。也就是追加进 slice 的值,出现了覆盖的情况。

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

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

map

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

如下:

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

输出结果:

  1. fatal error: concurrent map writes 
  2.  
  3. goroutine 18 [running]: 
  4. runtime.throw(0x10cb861, 0x15) 
  5.         /usr/local/Cellar/go/1.16.2/libexec/src/runtime/panic.go:1117 +0x72 fp=0xc00002e738 sp=0xc00002e708 pc=0x1032472 
  6. runtime.mapassign_faststr(0x10b3360, 0xc0000a2180, 0x10c91da, 0x6, 0x0) 
  7.         /usr/local/Cellar/go/1.16.2/libexec/src/runtime/map_faststr.go:211 +0x3f1 fp=0xc00002e7a0 sp=0xc00002e738 pc=0x1011a71 
  8. main.main.func1(0xc0000a2180) 
  9.         /Users/eddycjy/go-application/awesomeProject/main.go:9 +0x4c fp=0xc00002e7d8 sp=0xc00002e7a0 pc=0x10a474c 
  10. runtime.goexit() 
  11.         /usr/local/Cellar/go/1.16.2/libexec/src/runtime/asm_amd64.s:1371 +0x1 fp=0xc00002e7e0 sp=0xc00002e7d8 pc=0x1063fe1 
  12. created by main.main 
  13.         /Users/eddycjy/go-application/awesomeProject/main.go:8 +0x55 

好家伙,程序运行会直接报错。并且是 Go 源码调用 throw 方法所导致的致命错误,也就是说 Go 进程会中断。

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

是个日经的隐式问题。

如何支持并发读写

对 map 上锁

实际上我们仍然存在并发读写 map 的诉求(程序逻辑决定),因为 Go 语言中的 goroutine 实在是太方便了。

像是一般写爬虫任务时,基本会用到多个 goroutine,获取到数据后再写入到 map 或者 slice 中去。

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

  1. var counter = struct{ 
  2.     sync.RWMutex 
  3.     m map[string]int 
  4. }{m: make(map[string]int)} 

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

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

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

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

  1. counter.Lock() 
  2. counter.m["煎鱼"]++ 
  3. counter.Unlock() 

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

sync.Map

前言

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

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

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

具体介绍

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

  1. type Map struct { 
  2.  mu Mutex 
  3.  read atomic.Value // readOnly 
  4.  dirty map[interface{}]*entry 
  5.  misses int 

其是专门为 append-only 场景设计的,也就是适合读多写少的场景。这是他的优点之一。

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

提供了以下常用方法:

  1. func (m *Map) Delete(key interface{}) 
  2. func (m *Map) Load(key interface{}) (value interface{}, ok bool) 
  3. func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) 
  4. func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) 
  5. func (m *Map) Range(f func(key, value interface{}) bool) 
  6. func (m *Map) Store(key, value interface{}) 
  • Delete:删除某一个键的值。
  • Load:返回存储在 map 中的键的值,如果没有值,则返回 nil。ok 结果表示是否在 map 中找到了值。
  • LoadAndDelete:删除一个键的值,如果有的话返回之前的值。
  • LoadOrStore:如果存在的话,则返回键的现有值。否则,它存储并返回给定的值。如果值被加载,加载的结果为 true,如果被存储,则为 false。
  • Range:递归调用,对 map 中存在的每个键和值依次调用闭包函数 f。如果 f 返回 false 就停止迭代。
  • Store:存储并设置一个键的值。

实际运行例子如下:

  1. var m sync.Map 
  2.  
  3. func main() { 
  4.  //写入 
  5.  data := []string{"煎鱼""咸鱼""烤鱼""蒸鱼"
  6.  for i := 0; i < 4; i++ { 
  7.   go func(i int) { 
  8.    m.Store(i, data[i]) 
  9.   }(i) 
  10.  } 
  11.  time.Sleep(time.Second
  12.  
  13.  //读取 
  14.  v, ok := m.Load(0) 
  15.  fmt.Printf("Load: %v, %v\n", v, ok) 
  16.  
  17.  //删除 
  18.  m.Delete(1) 
  19.  
  20.  //读或写 
  21.  v, ok = m.LoadOrStore(1, "吸鱼"
  22.  fmt.Printf("LoadOrStore: %v, %v\n", v, ok) 
  23.  
  24.  //遍历 
  25.  m.Range(func(key, value interface{}) bool { 
  26.   fmt.Printf("Range: %v, %v\n"key, value) 
  27.   return true 
  28.  }) 

输出结果:

  1. Load: 煎鱼, true 
  2. LoadOrStore: 吸鱼, false 
  3. Range: 0, 煎鱼 
  4. Range: 1, 吸鱼 
  5. Range: 3, 蒸鱼 
  6. Range: 2, 烤鱼 

为什么不支持

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

但 Go map 就不大一样了,很多人以为是默认支持的,一个不小心就翻车,这么的常见。那凭什么 Go 官方还不支持,难不成太复杂了,性能太差了,到底是为什么?

原因如下(via @go faq):

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

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

总结

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

同时也针对业内常见的支持并发读写的方式进行了讲述,最后分析了不支持的原因,让我们对整个前因后果有了一个完整的了解。

 

 

责任编辑:武晓燕 来源: 脑子进煎鱼了
相关推荐

2012-06-15 09:56:40

2022-02-09 16:02:26

Go 语言ArraySlice

2022-03-28 11:51:00

深度学习机器学习模型

2021-07-08 23:53:44

Go语言拷贝

2022-01-10 23:54:56

GoMap并发

2024-01-01 08:10:40

Go语言map

2024-01-05 08:45:35

Go语言map

2023-05-15 08:01:16

Go语言

2022-10-10 11:37:14

Gomap内存

2020-02-27 21:03:30

调度器架构效率

2021-12-09 10:51:47

Go继承

2020-04-07 16:12:56

Go编程语言开发

2023-11-28 11:44:54

Go切片

2023-11-21 15:46:13

Go内存泄漏

2015-07-13 10:27:40

GoRust竞争者

2023-11-20 22:26:51

Go开发

2017-08-31 11:28:47

Slice底层实现

2018-06-22 10:18:02

2023-10-23 20:03:02

Go缓存

2015-03-04 10:06:20

光纤OFDM信号
点赞
收藏

51CTO技术栈公众号