概述

没错,这里我确实是说的竞态,而不是静态。Go 因为其简单和出色的并发开发体验而广受欢迎,但是我们经常还是会写出有 Data Race 的代码,但是 Go 很多时候却可以帮助我们检查出来,这篇文章就尝试介绍一下 Go 是如何做到的。

什么是 Data Race

在并发编程中,当多个协程、线程或者进程同时对某一块内存区域进行读写操作时,可能会出现数据竞争(Data Race)的问题。数据竞争是指两个或多个线程在没有合理同步的情况下同时访问同一个共享变量,并且至少有一个线程在写入变量的值。如果这些访问是不同的线程,并且至少一个访问是写访问,那么这种情况就被称为数据竞争。

数据竞争可能导致不可预期的程序行为,包括崩溃、死锁、无限循环等,所以,在并发编程中,必须避免数据竞争这个问题。常见的解决方法是使用同步机制,例如锁、信号量等,来确保不同线程对共享变量的访问是有序的。

Go 检测数据竞争

Go 的自带工具链条中提供了若干个工具来检测数据竞争,分别是:

当他检测到有 Data Race 时,就会以这样的格式报错:

  1. [root@liqiang.io]# go test -race liqiang.io/liuliqiang/testpackage
  2. WARNING: DATA RACE
  3. Read by goroutine 185:
  4. net.(*pollServer).AddFD()
  5. src/net/fd_unix.go:89 +0x398
  6. net.(*pollServer).WaitWrite()
  7. src/net/fd_unix.go:247 +0x45
  8. net.(*netFD).Write()
  9. src/net/fd_unix.go:540 +0x4d4
  10. net.(*conn).Write()
  11. src/net/net.go:129 +0x101
  12. net.func·060()
  13. src/net/timeout_test.go:603 +0xaf
  14. Previous write by goroutine 184:
  15. net.setWriteDeadline()
  16. src/net/sockopt_posix.go:135 +0xdf
  17. net.setDeadline()
  18. src/net/sockopt_posix.go:144 +0x9c
  19. net.(*conn).SetDeadline()
  20. src/net/net.go:161 +0xe3
  21. net.func·061()
  22. src/net/timeout_test.go:616 +0x3ed
  23. Goroutine 185 (running) created at:
  24. net.func·061()
  25. src/net/timeout_test.go:609 +0x288
  26. Goroutine 184 (running) created at:
  27. net.TestProlongTimeout()
  28. src/net/timeout_test.go:618 +0x298
  29. testing.tRunner()
  30. src/testing/testing.go:301 +0xe8

虽然内容很多,但是,通常你只需要看两个关键词即可:

常用解决方法

这样你就可以去分析为什么会出现 Data Race 了,当你分析出来原因之后,我用过的解决方式有:

Go 的实现原理

当知道了什么是 Data Race 以及常用的解决方式之后,下一步我们就来了解一下 Go 是如何检测 Data Race 的。根据 Data Race 的定义,我们知道,要出现 Data Race,那么一定是有两个人对同一个内存进行同时的读写,这期间出现了交叉的过程,那么 Go 其实就是通过这个原理出发进行实现的。

Go 检测 Data Race 和我们加锁有点类似,就是在内存访问之前和访问之后都进行打点,例如这么一段原始代码:

  1. [root@liqiang.io]# cat raw.go
  2. func main() {
  3. go func() {
  4. x = 1
  5. }()
  6. fmt.Println(x)
  7. }

当开启了 Data Race(-race)之后,Go 生成的代码可能就变成了:

  1. [root@liqiang.io]# cat compile.go
  2. func main() {
  3. go func() {
  4. // 通知 Race Detector 写操作即将发生
  5. race.WriteAcquire(&x)
  6. x = 1
  7. // 通知 Race Detector 写操作已完成
  8. race.WriteRelease(&x)
  9. }()
  10. // 通知 Race Detector 读操作即将发生
  11. race.ReadAcquire(&x)
  12. value := x
  13. // 通知 Race Detector 读操作已完成
  14. race.ReadRelease(&x)
  15. fmt.Println(value)
  16. }

相当于在内存操作前后进行了打点,然后有一个专门用来检测 Data Race 的组件:Data Race Detector,它可以用来检测对于同一块内存的访问是否有冲突的地方,整体的流程为:

  1. Race Detector 使用一个名为 Shadow Memory 的数据结构来存储内存访问的元数据。对于每个内存地址,Shadow Memory 会记录最近的两个访问操作,包括操作类型(读或写)、操作的 Goroutine 以及操作发生的时刻。
  2. 检查并发访问:当 Race Detector 检测到一个内存访问操作时,它会检查与该操作相关的 Shadow Memory 记录。如果发现以下条件之一,那么就认为存在数据竞争:
    • 当前操作是写操作,且与最近的两个访问操作之一(无论是读还是写)并发发生(即没有 happens-before 关系),且这两个访问操作来自不同的 Goroutine。
    • 当前操作是读操作,且与最近的一个写操作并发发生(即没有 happens-before 关系),且这两个操作来自不同的 Goroutine。
  3. 报告数据竞争:当检测到数据竞争时,Race Detector 会生成详细的报告,包括数据竞争发生的位置、涉及的 Goroutine 以及栈跟踪等信息。

不同的内存处理

上面提到的是一个通用的思路,但是我们知道 Go 的内存是分不同类型的,例如最简单的堆内存和栈内存,他们的访问域是不同的,Data Race 的触发条件也是不一样的(例如堆内存触发的概率会比较高,栈内存就很低了)。下面总结一下不同的内存的不同处理方式:

Data Race 的 goroutine 模型

Race Detector 与 Go 运行时紧密集成,以便在程序执行期间实时检测数据竞争。在运行时,Race Detector 的各个功能分布在多个 Goroutine 和线程中。下面我总结了一些比较常用到的组件:

其中第 4 点,我们可以展开看看,因为它也涉及到多个 goroutine 的协调:

  1. 当一个 Goroutine 发生内存访问操作时,Race Detector 会检查与之竞争的其他 Goroutine 是否存在。这是通过分析 Shadow Memory 中的元数据来完成的,元数据包含了每个内存地址的访问历史和同步关系。
  2. 如果检测到数据竞争,Race Detector 会收集有关竞争 Goroutine 的信息,包括其 ID、栈跟踪、发生竞争的内存地址以及相关的源代码位置。
  3. Race Detector 可以同时检测到多个数据竞争事件。对于每个事件,它都会生成一个独立的报告。报告中会包含详细的竞争 Goroutine 信息,以帮助开发者理解和解决问题。
  4. 最后,当程序执行结束或者在检测到数据竞争时,Race Detector 会将收集到的所有报告打印到标准错误输出(stderr)。每个报告都会单独显示,并按照发生的顺序排列。这样,开发者可以逐个查看和分析数据竞争事件。

总结

在本文中,我介绍了 Go 的 Data Race 检测机制的使用和原理,需要注意的是,虽然 Go 的 Data Race 启用很方便,并且 Go 编译器和 Race Detector 会尽可能地检测所有内存访问的数据竞争,但它们不能保证 100% 的准确性和完整性。因此,开发者仍然需要遵循良好的编程实践,确保程序的同步和并发行为是正确和安全的。

并且从前面的介绍中可以发现,增加了 Data Race 检测之后,内存占用肯定会增加,执行速度也会降低,根据官方的文档说明:内存占用会有 5-10 倍的增加,执行时间会有 2-20 倍的降低。

Ref