概述
没错,这里我确实是说的竞态,而不是静态。Go 因为其简单和出色的并发开发体验而广受欢迎,但是我们经常还是会写出有 Data Race 的代码,但是 Go 很多时候却可以帮助我们检查出来,这篇文章就尝试介绍一下 Go 是如何做到的。
什么是 Data Race
在并发编程中,当多个协程、线程或者进程同时对某一块内存区域进行读写操作时,可能会出现数据竞争(Data Race)的问题。数据竞争是指两个或多个线程在没有合理同步的情况下同时访问同一个共享变量,并且至少有一个线程在写入变量的值。如果这些访问是不同的线程,并且至少一个访问是写访问,那么这种情况就被称为数据竞争。
数据竞争可能导致不可预期的程序行为,包括崩溃、死锁、无限循环等,所以,在并发编程中,必须避免数据竞争这个问题。常见的解决方法是使用同步机制,例如锁、信号量等,来确保不同线程对共享变量的访问是有序的。
Go 检测数据竞争
Go 的自带工具链条中提供了若干个工具来检测数据竞争,分别是:
- 在测试的时候检测 Data Race:
go test -race mypkg
- 在编译的时候检测 Data Race:
go build -race mycmd
- 在安装的时候检测 Data Race:
go install -race mypkg
- 在运行的时候检测 Data Race:
go run -race mysrc.go
当他检测到有 Data Race 时,就会以这样的格式报错:
[root@liqiang.io]# go test -race liqiang.io/liuliqiang/testpackage
WARNING: DATA RACE
Read by goroutine 185:
net.(*pollServer).AddFD()
src/net/fd_unix.go:89 +0x398
net.(*pollServer).WaitWrite()
src/net/fd_unix.go:247 +0x45
net.(*netFD).Write()
src/net/fd_unix.go:540 +0x4d4
net.(*conn).Write()
src/net/net.go:129 +0x101
net.func·060()
src/net/timeout_test.go:603 +0xaf
Previous write by goroutine 184:
net.setWriteDeadline()
src/net/sockopt_posix.go:135 +0xdf
net.setDeadline()
src/net/sockopt_posix.go:144 +0x9c
net.(*conn).SetDeadline()
src/net/net.go:161 +0xe3
net.func·061()
src/net/timeout_test.go:616 +0x3ed
Goroutine 185 (running) created at:
net.func·061()
src/net/timeout_test.go:609 +0x288
Goroutine 184 (running) created at:
net.TestProlongTimeout()
src/net/timeout_test.go:618 +0x298
testing.tRunner()
src/testing/testing.go:301 +0xe8
虽然内容很多,但是,通常你只需要看两个关键词即可:
- Read by goroutine:这表示当出现 Data Race 时,读同一块内存的代码是谁;
- Previous write by goroutine:这表示当出现 Data Race 时,写同一块内存的代码是谁;
常用解决方法
这样你就可以去分析为什么会出现 Data Race 了,当你分析出来原因之后,我用过的解决方式有:
- 使用同步机制:这是最简单的解决方法了,可以直接使用同步机制,例如锁、信号量等;同步机制可以确保不同线程对共享变量的访问是有序的,从而避免 Data Race 问题。
- 使用原子操作:原子操作是一种特殊的操作,可以确保对共享变量的访问是原子性的,从而避免 Data Race 问题。例如 Go 自带的 Atomic 数据类型:
eg: atomic.Uint64
以及我常用的go.uber.org/atomic
的扩展类型:atomic.String
等 - 使用并发安全的数据结构:一些数据结构,例如线程安全的队列、哈希表等,可以避免 Data Race 问题。在使用这些数据结构时,应该注意避免多个线程同时修改同一个节点或者元素,这其实也是一种同步机制,不过是通过数据类型来抽象了。
Go 的实现原理
当知道了什么是 Data Race 以及常用的解决方式之后,下一步我们就来了解一下 Go 是如何检测 Data Race 的。根据 Data Race 的定义,我们知道,要出现 Data Race,那么一定是有两个人对同一个内存进行同时的读写,这期间出现了交叉的过程,那么 Go 其实就是通过这个原理出发进行实现的。
Go 检测 Data Race 和我们加锁有点类似,就是在内存访问之前和访问之后都进行打点,例如这么一段原始代码:
[root@liqiang.io]# cat raw.go
func main() {
go func() {
x = 1
}()
fmt.Println(x)
}
当开启了 Data Race(-race
)之后,Go 生成的代码可能就变成了:
[root@liqiang.io]# cat compile.go
func main() {
go func() {
// 通知 Race Detector 写操作即将发生
race.WriteAcquire(&x)
x = 1
// 通知 Race Detector 写操作已完成
race.WriteRelease(&x)
}()
// 通知 Race Detector 读操作即将发生
race.ReadAcquire(&x)
value := x
// 通知 Race Detector 读操作已完成
race.ReadRelease(&x)
fmt.Println(value)
}
相当于在内存操作前后进行了打点,然后有一个专门用来检测 Data Race 的组件:Data Race Detector,它可以用来检测对于同一块内存的访问是否有冲突的地方,整体的流程为:
- Race Detector 使用一个名为 Shadow Memory 的数据结构来存储内存访问的元数据。对于每个内存地址,Shadow Memory 会记录最近的两个访问操作,包括操作类型(读或写)、操作的 Goroutine 以及操作发生的时刻。
- 检查并发访问:当 Race Detector 检测到一个内存访问操作时,它会检查与该操作相关的 Shadow Memory 记录。如果发现以下条件之一,那么就认为存在数据竞争:
- 当前操作是写操作,且与最近的两个访问操作之一(无论是读还是写)并发发生(即没有 happens-before 关系),且这两个访问操作来自不同的 Goroutine。
- 当前操作是读操作,且与最近的一个写操作并发发生(即没有 happens-before 关系),且这两个操作来自不同的 Goroutine。
- 报告数据竞争:当检测到数据竞争时,Race Detector 会生成详细的报告,包括数据竞争发生的位置、涉及的 Goroutine 以及栈跟踪等信息。
不同的内存处理
上面提到的是一个通用的思路,但是我们知道 Go 的内存是分不同类型的,例如最简单的堆内存和栈内存,他们的访问域是不同的,Data Race 的触发条件也是不一样的(例如堆内存触发的概率会比较高,栈内存就很低了)。下面总结一下不同的内存的不同处理方式:
- 全局变量和堆内存:对于全局变量和堆上分配的内存,编译器通常会为所有读写操作插入数据竞争检测代码,这是因为全局变量和堆内存在多个 Goroutine 之间共享,因此它们更容易发生数据竞争;
- 栈内存:对于栈内存(例如局部变量),编译器可能会采取不同的处理策略。由于栈内存通常是 Goroutine 本地的,且在函数调用之间具有独立的生命周期,因此在许多情况下,栈内存的访问不容易发生数据竞争。然而,当栈内存的地址被共享到其他 Goroutine(例如通过指针传递或闭包捕获)时,编译器需要为这些内存访问插入数据竞争检测代码。
- 优化和消除:编译器可能会优化或消除对某些内存访问的数据竞争检测。例如,编译器可能会分析代码的静态信息和运行时行为,从而识别出某些内存访问不可能发生数据竞争,进而避免为这些访问插入检测代码。这种优化可以减小性能损失,但同时可能会导致某些极端情况下的数据竞争检测不完整。
- 原子操作和同步原语:编译器会为原子操作(如 atomic.AddInt32、atomic.LoadUint64 等)和同步原语(如 Mutex、RWMutex、Channel 等)插入特殊的检测代码。这些代码有助于 Race Detector 更准确地分析程序的同步行为,从而更可靠地检测数据竞争。
Data Race 的 goroutine 模型
Race Detector 与 Go 运行时紧密集成,以便在程序执行期间实时检测数据竞争。在运行时,Race Detector 的各个功能分布在多个 Goroutine 和线程中。下面我总结了一些比较常用到的组件:
- Stub Code:如前所述,Go 编译器会在内存访问和同步操作处插入额外的代码,以便与 Race Detector 通信。这些插入的代码在程序运行期间执行,直接在发生内存访问和同步操作的 Goroutine 中运行。
- Shadow Memory:Race Detector 使用 Shadow Memory 来存储内存访问的元数据。Shadow Memory 的管理和更新在发生内存访问操作的 Goroutine 中进行,以保证元数据的实时性。
- 数据竞争检测:Race Detector 的数据竞争检测逻辑通常在发生内存访问操作的 Goroutine 中执行。这意味着,当一个 Goroutine 执行一个内存访问操作时,Race Detector 会在同一个 Goroutine 中检查是否存在数据竞争。
- 报告和诊断:当 Race Detector 检测到数据竞争时,它会生成详细的报告,包括数据竞争发生的位置、涉及的 Goroutine 以及栈跟踪等信息。报告生成可能会涉及多个 Goroutine 和线程,因为它需要收集和整理各种上下文信息。
其中第 4 点,我们可以展开看看,因为它也涉及到多个 goroutine 的协调:
- 当一个 Goroutine 发生内存访问操作时,Race Detector 会检查与之竞争的其他 Goroutine 是否存在。这是通过分析 Shadow Memory 中的元数据来完成的,元数据包含了每个内存地址的访问历史和同步关系。
- 如果检测到数据竞争,Race Detector 会收集有关竞争 Goroutine 的信息,包括其 ID、栈跟踪、发生竞争的内存地址以及相关的源代码位置。
- Race Detector 可以同时检测到多个数据竞争事件。对于每个事件,它都会生成一个独立的报告。报告中会包含详细的竞争 Goroutine 信息,以帮助开发者理解和解决问题。
- 最后,当程序执行结束或者在检测到数据竞争时,Race Detector 会将收集到的所有报告打印到标准错误输出(stderr)。每个报告都会单独显示,并按照发生的顺序排列。这样,开发者可以逐个查看和分析数据竞争事件。
总结
在本文中,我介绍了 Go 的 Data Race 检测机制的使用和原理,需要注意的是,虽然 Go 的 Data Race 启用很方便,并且 Go 编译器和 Race Detector 会尽可能地检测所有内存访问的数据竞争,但它们不能保证 100% 的准确性和完整性。因此,开发者仍然需要遵循良好的编程实践,确保程序的同步和并发行为是正确和安全的。
并且从前面的介绍中可以发现,增加了 Data Race 检测之后,内存占用肯定会增加,执行速度也会降低,根据官方的文档说明:内存占用会有 5-10 倍的增加,执行时间会有 2-20 倍的降低。