go 协程,数据竞争及对应解决方法

go 协程,数据竞争及对应解决方法

author: he xiaodong date: 2023-05-04
场景

有个查询结果集的操作,无可避免的需要在循环获取数据,然后将结果集放到 map 中,这个操作在压测的时候,没出现问题,发布到生产环境之后,开始偶现 fatal error: concurrent map read and map write 错误,导致容器重启了。

原因

多个协程同时对 map 进行读写操作,导致数据竞争 测试环境压测未复现是因为单个 pod 常规时间只有一个 CPU,资源不够用了才会使用两个 CPU,单核的情况下,协程是串行执行的,所以没有出现数据竞争的问题。 同时也没开着数据竞争检测,也没有检测出来这个问题

调试

在本机多核CPU情况下,执行 go run --race main.go 启动项目,调用方法,会有提示 data race,再开始对应解决问题。 出现数据竞争告警时,就是需要解决问题,无则是其他情况

==================
WARNING: DATA RACE
Write at 0x00c00008e000 by goroutine 7:
 main.main.func2()
Previous write at 0x00c00008e000 by goroutine 6:
 main.main.func1()
==================
解决方案

① 使用 sync.Mutex/sync.RWMutex 加锁

互斥锁: Mutex是互斥锁的意思,也叫排他锁,同一时刻一段代码只能被一个线程运行,使用只需要关注方法Lock(加锁)和Unlock(解锁)即可。 在Lock()和Unlock()之间的代码段称为资源的临界区(critical section),是线程安全的,任何一个时间点都只能有一个goroutine执行这段区间的代码。 Mutex在大量并发的情况下,会造成锁等待,对性能的影响比较大。

读写锁: 读写锁的读锁可以重入,在已经有读锁的情况下,可以任意加读锁。 在读锁没有全部解锁的情况下,写操作会阻塞直到所有读锁解锁。 写锁定的情况下,其他协程的读写都会被阻塞,直到写锁解锁。

根据业务场景,按需进行加锁,尽量减少锁的粒度,提高性能。

② 使用 sync.Map

go 原生的 map 不是线程安全的,sync.Map 是线程安全的,读取,插入,删除也都保持着常数级的时间复杂度。 并且它通过空间换时间的方式,使用 read 和 dirty 两个 map 来进行读写分离,降低锁时间来提高效率。

sync.Map 适用于读多写少的场景,如果并发写多的场景,还是需要加锁的对于写多的场景,会导致 read map 缓存失效, 需要加锁,导致冲突变多;而且由于未命中 read map 次数过多,导致 dirty map 提升为 read map,这是一个 O(N) 的操作,会进一步降低性能。

③ 使用 channel 通道传递数据

channel 是 goroutine 之间的通信方式,可以用来传递数据,也可以用来传递信号,比如结束信号,超时信号等。 go 的设计原则也是:通过通信来共享内存,而不是通过共享内存来通信。channel 也是线程安全的,可以用来解决数据竞争的问题。

额外原则: 如果有数据传递后,继续有进行处理,可以使用 channel,如果仅是赋值,无其他操作,直接加锁或者 sync.Map 简单易理解

额外笔记

数据竞争 (data race) 的发生条件是:当多个协程同时访问一个相同内存地址,并且至少有一个在进行写操作时,数据竞争意味着不确定的行为。 而不存在数据竞争不代表结果就是确定的。实际上,一个应用程序即使不存在数据竞争,但它的行为肯依赖于不可控的发生时间或执行顺序,这就是竞争条件 (race condition)

参考链接:

  1. 深度解密go之 sync.Map
  2. 【Go基础篇】 彻底搞懂 RWMutex 实现原理
  3. Go语言并发–传统锁与channel的选择
  4. [Go 并发 数据竞争及竞争条件](https://blog.csdn.net/weixin_41335923/article/details/124266638)