go 协程操作map导致的数据竞争及解决方法

695 阅读3分钟

原文链接:何晓东 博客

场景

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

原因

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

调试

在本机多核CPU情况下,执行 go run --race main.go 启动项目,调用方法,会有提示 data race,再开始对应解决问题。

解决方案

① 使用 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 简单易理解

参考链接:

  1. 深度解密go之 sync.Map
  2. 【Go基础篇】 彻底搞懂 RWMutex 实现原理