5

原文链接:何晓东 博客

场景

有个查询结果集的操作,无可避免的需要在循环获取数据,然后将结果集放到 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 并发 | 数据竞争及竞争条件

文章在梦康群大佬们指点 + github copilot提示下完善的。


hxd_
1.7k 声望448 粉丝