Go竞态检测器
在第 8 章《测试微服务架构》中,我们探讨了如何使用 pprof
工具来分析 Go 应用程序的 CPU 和内存使用情况。帮助我们找到并发问题的重要工具之一是 Go 竞态检测器(Go Race Detector)。它是一个强大的工具,可以在应用程序运行时分析代码以发现并发问题。
Go 的竞态检测器是在 2012 年发布的 Go 1.1 版本中添加到工具链中的。该工具旨在帮助开发人员发现代码中的竞态条件。正如我们在前面的示例中看到的,在 Go 中编写并发代码很容易,但即使是最易读和设计良好的代码也可能出现 bug。
竞态检测器通过 –race
命令行标志启用,与 go
命令一起使用。例如,我们可以指示它与我们的程序一起运行:
$ go run –race main.go
竞态检测器还可以与其他命令一起使用,包括 build
和 test
命令。这使得在开发过程的任何阶段都可以轻松使用检测器来查找应用程序中的数据竞争。
一旦启用检测器,编译器会记录内存访问,Go 运行时会分析这些记录以查找数据竞争。正如我们所知,数据竞争通常是由多个 goroutine
在没有使用同步机制的情况下访问和修改一个共享资源引起的。
当发生数据竞争时,检测器将打印一份报告,详细说明问题,指出问题所在,并引导细心的开发人员修复检测到的问题。让我们用它来测试上一节中的数据竞争示例:
$ go run -race chapter09/concurrency/data-race/main.go
==================
WARNING: DATA RACE
Read at 0x0000011e6d70 by goroutine 8:
main.greet()
.../chapter09/data-races/main.go:15 +0xf5
Previous write at 0x0000011e6d70 by goroutine 7:
main.greet()
../chapter09/data-races/main.go:15 +0x1b3
==================
==================
WARNING: DATA RACE
Read at 0x00c00009e000 by goroutine 9:
runtime.growslice()
/usr/local/go/src/runtime/slice.go:178 +0x0
main.greet()
.../chapter09/data-races/main.go:15 +0x12f
Previous write at 0x00c00009e000 by goroutine 7:
main.greet()
.../chapter09/data-races/main.go:15 +0x164
==================
Hello, friend! I'm Goroutine 0.
Hello, friend! I'm Goroutine 2.
Goodbye, friend!
Found 2 data race(s)
exit status 66
正如预期的那样,竞态检测器在我们的数据竞争示例中发现了一些问题。输出指出了问题:
-
第一个数据竞争在
greet
函数的main.go:15
处被检测到。一个 goroutine 读取一个变量,而另一个 goroutine 写入它。 -
第二个数据竞争发生在切片在
append
期间增长时,这由对runtime.growslice()
的调用指示。该函数复制切片并处理更大底层数组的分配(如果需要)。对该切片的修改也以交错的方式进行,读取和写入发生在不同的 goroutine 中。 -
最后,程序的输出被打印出来,竞态检测器总结发现了两个数据竞争。
正如我们已经怀疑的那样,在没有同步机制的情况下对共享切片进行的并发更改导致了数据竞争。竞态检测器标识的代码块如下:
func greet(id int, wg *sync.WaitGroup) {
defer wg.Done()
g := fmt.Sprintf("Hello, friend! I'm Goroutine %d.", id)
greetings = append(greetings, g)
}
检测器突出显示的行是在 append
函数期间对 greetings
切片的读取和写入。正如我们在前面的章节中讨论的那样,append
函数由多个操作组成,如果它们在多个 goroutine 中交错,可能会导致数据竞争。
由于竞态检测器所需的检测,它只能在触发数据竞争时发现它们。因此,我们的应用程序应受到现实工作负载和用户旅程的影响,以便检测问题。
根据官方 Go 文档( https://go.dev/blog/race-detector ),启用竞态检测的应用程序使用的 CPU 和内存是原来的 10 倍,因此我们应该避免在生产环境中运行它们。相反,我们应该在启用竞态检测器的情况下运行负载测试和集成测试,因为这些测试通常会执行程序中最重要的部分。
竞态检测器的局限性
虽然竞态检测器是一个非常有用工具,但我们应该记住,它只能检查竞态条件。虽然它不会标记任何误报,但代码可能包含其他并发问题。我们应该记住,竞态检测器只是一个指示器。 |
无法测试的条件
虽然 Go 竞态检测器是一个有用的工具,但并发测试很难执行并证明其正确性。竞态检测器只专注于查找数据竞争,但我们在上一节《并发问题》中已经看到,还存在其他并发问题,例如死锁和资源泄漏。
由于对时间的依赖性,有四种本质上不可测试的并发问题:
-
竞态条件(Race Conditions):由于多个 goroutine 在没有正确使用同步机制的情况下读取和修改共享资源而导致的不稳定或不一致行为。例如,goroutine 读取并递增一个公共计数器。
-
死锁(Deadlocks):goroutine 被阻塞,等待永远不会可用的资源,要么是因为它们从未达到所需状态,要么是因为另一个 goroutine 锁定了资源并且从未释放它们。例如,一个 goroutine 等待从 nil 通道接收,而该通道从未被初始化。
-
活锁(Livelocks):与死锁类似,当 goroutine 继续尝试获取永远不会可用的资源时,它们会变成活锁,要么是因为它们从未达到所需状态,要么是因为另一个资源锁定了资源并且从未释放它们。在这种情况下,goroutine 会浪费 CPU 继续重试不可能的操作。例如,一个 goroutine 定期轮询以写入已被另一个 goroutine 锁定的变量,而该 goroutine 正在等待第一个 goroutine 锁定且从未接收的资源。
-
饥饿(Starvation):与活锁类似,goroutine 无法获得继续处理所需的所有资源。一个或多个 goroutine 被贪婪的 goroutine 阻止执行有意义的工作,这些贪婪的 goroutine 不释放资源。例如,一个 goroutine 锁定一个资源,然后执行一个非常长时间的操作,从而阻止其他 goroutine 在此期间访问该资源。
图9.8 描绘了死锁常见的场景:

在这种情况下,两个 goroutine 都需要两个资源来完成它们的工作。每个 goroutine 都持有一个资源,同时等待第二个资源。两个 goroutine
都无法完成其工作,从而导致死锁。如果每个 goroutine 都轮询检查资源或另一个 goroutine 的状态,则相同的情况也可能导致活锁。在这种情况下,每个 goroutine 都在使用 CPU 周期,但从未完成执行。
这四种不可测试的条件实际上是设计不当的代码或对并发机制行为的错误理解的结果。这就是我们在本章开始时深入讨论和探索 Go 并发机制的基础知识和行为的主要原因。这些条件可以通过使用良好的 linter
和代码审查来检测,但最好的防御措施是在编写代码时意识到这些问题。
图9.9 总结了使用并发时的三个经验法则:

这三个经验法则将帮助您最大限度地减少我们迄今为止讨论的难以检测的并发问题:
-
使用通道共享值:如前所述,我们应该避免使用变量和指针共享结果。即使正确使用锁保护,通道也更高效,并且可以简化您的代码。
-
延迟释放锁:使用锁时,您应该养成在获取锁后立即使用
defer
关键字调用锁释放的习惯。这将确保您的函数释放锁,无论完成逻辑分支或任何潜在错误如何。您还应该考虑是否需要读写锁,或者是否可以通过尽可能获取读互斥锁来避免饥饿。 -
等待所有子 goroutine 完成:正如我们所讨论的,goroutine 与它们创建的 goroutine 具有父子关系。您应该使用同步机制来确保父 goroutine 在关闭之前等待它创建的所有
goroutine
,以确保操作正确完成并且资源正确清理。
仅靠测试无法证明不存在这四种不可测试的条件,但它可以让我们对统计置信度有信心,即这些错误不会在生产环境中发生,对于对我们系统重要的场景。因此,编写涵盖代码并发部分的测试是我们测试策略的重要组成部分。
在下一节中,我们将探讨如何在并发条件下使用竞态检测器测试 BookSwap 应用程序。