Go 博客
Go 竞争检测器介绍
引言
竞态条件是编程中最隐蔽和难以捉摸的错误之一。它们通常会导致不规律和神秘的故障,而且往往在代码部署到生产环境很久之后才发生。尽管 Go 的并发机制使得编写清晰的并发代码变得容易,但它们并不能阻止竞态条件。需要细心、勤奋和测试。而工具可以提供帮助。
我们很高兴地宣布,Go 1.1 包含了竞态检测器,这是一个用于发现 Go 代码中竞态条件的新工具。它目前适用于配备 64 位 x86 处理器的 Linux、OS X 和 Windows 系统。
竞态检测器基于 C/C++ ThreadSanitizer 运行时库,该库已被用于检测 Google 内部代码库和 Chromium 中的许多错误。该技术于 2012 年 9 月与 Go 集成;此后,它在标准库中检测到了 42 个竞态。它现在是我们持续构建过程的一部分,并持续捕获新出现的竞态条件。
工作原理
竞态检测器已集成到 go 工具链中。当设置 -race
命令行标志时,编译器会用代码检测所有内存访问,记录内存的访问时间和方式,而运行时库则监视共享变量的非同步访问。当检测到这种“竞态”行为时,会打印警告。(有关算法的详细信息,请参阅这篇文章。)
由于其设计,竞态检测器只能在实际运行代码触发竞态条件时才能检测到它们,这意味着在实际工作负载下运行启用竞态检测的二进制文件非常重要。然而,启用竞态检测的二进制文件可能会使用十倍的 CPU 和内存,因此一直启用竞态检测器是不切实际的。解决这个困境的一种方法是启用竞态检测器运行一些测试。负载测试和集成测试是很好的选择,因为它们倾向于锻炼代码的并发部分。另一种使用生产工作负载的方法是在运行的服务器池中部署一个启用竞态检测的实例。
使用竞态检测器
竞态检测器已完全集成到 Go 工具链中。要启用竞态检测器构建代码,只需在命令行中添加 -race
标志
$ go test -race mypkg // test the package
$ go run -race mysrc.go // compile and run the program
$ go build -race mycmd // build the command
$ go install -race mypkg // install the package
要亲自尝试竞态检测器,请将此示例程序复制到 racy.go
中
package main
import "fmt"
func main() {
done := make(chan bool)
m := make(map[string]string)
m["name"] = "world"
go func() {
m["name"] = "data race"
done <- true
}()
fmt.Println("Hello,", m["name"])
<-done
}
然后启用竞态检测器运行它
$ go run -race racy.go
示例
以下是竞态检测器捕获的两个真实问题的示例。
示例 1:Timer.Reset
第一个示例是竞态检测器发现的实际 bug 的简化版本。它使用计时器在 0 到 1 秒之间的随机持续时间后打印消息。它会重复执行五秒钟。它使用 time.AfterFunc
为第一条消息创建一个 Timer
,然后使用 Reset
方法调度下一条消息,每次都重用 Timer
。
10 func main() { 11 start := time.Now() 12 var t *time.Timer 13 t = time.AfterFunc(randomDuration(), func() { 14 fmt.Println(time.Now().Sub(start)) 15 t.Reset(randomDuration()) 16 }) 17 time.Sleep(5 * time.Second) 18 } 19 20 func randomDuration() time.Duration { 21 return time.Duration(rand.Int63n(1e9)) 22 } 23
这看起来是合理的代码,但在某些情况下它会以令人惊讶的方式失败
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xb code=0x1 addr=0x8 pc=0x41e38a]
goroutine 4 [running]:
time.stopTimer(0x8, 0x12fe6b35d9472d96)
src/pkg/runtime/ztime_linux_amd64.c:35 +0x25
time.(*Timer).Reset(0x0, 0x4e5904f, 0x1)
src/pkg/time/sleep.go:81 +0x42
main.func·001()
race.go:14 +0xe3
created by time.goFunc
src/pkg/time/sleep.go:122 +0x48
这是怎么回事?启用竞态检测器运行程序会更具启发性
==================
WARNING: DATA RACE
Read by goroutine 5:
main.func·001()
race.go:16 +0x169
Previous write by goroutine 1:
main.main()
race.go:14 +0x174
Goroutine 5 (running) created at:
time.goFunc()
src/pkg/time/sleep.go:122 +0x56
timerproc()
src/pkg/runtime/ztime_linux_amd64.c:181 +0x189
==================
竞态检测器显示了问题:不同 goroutine 对变量 t
的非同步读写。如果初始计时器持续时间非常小,计时器函数可能会在主 goroutine 为 t
赋值之前触发,因此对 t.Reset
的调用是以 nil t
进行的。
为了修复竞态条件,我们将代码更改为只从主 goroutine 读取和写入变量 t
10 func main() { 11 start := time.Now() 12 reset := make(chan bool) 13 var t *time.Timer 14 t = time.AfterFunc(randomDuration(), func() { 15 fmt.Println(time.Now().Sub(start)) 16 reset <- true 17 }) 18 for time.Since(start) < 5*time.Second { 19 <-reset 20 t.Reset(randomDuration()) 21 } 22 } 23
在这里,主 goroutine 完全负责设置和重置 Timer
t
,并且一个新的重置通道以线程安全的方式通信重置计时器的需求。
一个更简单但效率较低的方法是避免重用计时器。
示例 2:ioutil.Discard
第二个例子更为微妙。
ioutil
包的 Discard
对象实现了 io.Writer
,但会丢弃所有写入它的数据。可以把它想象成 /dev/null
:一个用于发送你需要读取但不想存储的数据的地方。它通常与 io.Copy
一起使用来耗尽读取器,就像这样
io.Copy(ioutil.Discard, reader)
早在 2011 年 7 月,Go 团队注意到以这种方式使用 Discard
是低效的:每次调用 Copy
函数都会分配一个内部的 32 kB 缓冲区,但当与 Discard
一起使用时,缓冲区是不必要的,因为我们只是丢弃读取的数据。我们认为这种惯用 Copy
和 Discard
的方式不应该如此昂贵。
修复方法很简单。如果给定的 Writer
实现了一个 ReadFrom
方法,那么像这样的 Copy
调用
io.Copy(writer, reader)
将委托给这个可能更高效的调用
writer.ReadFrom(reader)
我们为 Discard 的底层类型添加了一个 ReadFrom 方法,它有一个内部缓冲区,所有用户共享该缓冲区。我们知道这理论上是一个竞态条件,但由于所有对缓冲区的写入都应该被丢弃,我们认为这并不重要。
当竞态检测器实现时,它立即将此代码标记为竞态。我们再次认为代码可能存在问题,但认为竞态条件不是“真实的”。为了避免构建中的“误报”,我们实现了一个非竞态版本,该版本仅在竞态检测器运行时启用。
但几个月后,Brad 遇到了一个令人沮丧的奇怪错误。经过几天的调试,他将其归结为由 ioutil.Discard
引起的真实竞态条件。
这是 io/ioutil
中已知的竞态代码,其中 Discard
是一个 devNull
,它在所有用户之间共享一个缓冲区。
var blackHole [4096]byte // shared buffer
func (devNull) ReadFrom(r io.Reader) (n int64, err error) {
readSize := 0
for {
readSize, err = r.Read(blackHole[:])
n += int64(readSize)
if err != nil {
if err == io.EOF {
return n, nil
}
return
}
}
}
Brad 的程序包含一个 trackDigestReader
类型,它包装了一个 io.Reader
并记录了它所读取内容的哈希摘要。
type trackDigestReader struct {
r io.Reader
h hash.Hash
}
func (t trackDigestReader) Read(p []byte) (n int, err error) {
n, err = t.r.Read(p)
t.h.Write(p[:n])
return
}
例如,它可以用来在读取文件时计算文件的 SHA-1 哈希值
tdr := trackDigestReader{r: file, h: sha1.New()}
io.Copy(writer, tdr)
fmt.Printf("File hash: %x", tdr.h.Sum(nil))
在某些情况下,没有地方写入数据——但仍然需要对文件进行哈希处理——因此将使用 Discard
io.Copy(ioutil.Discard, tdr)
但在这种情况下,blackHole
缓冲区不仅仅是一个黑洞;它是一个合法的存储数据的地方,用于从源 io.Reader
读取数据并将其写入 hash.Hash
之间。当多个 goroutine 同时哈希文件,每个 goroutine 都共享同一个 blackHole
缓冲区时,竞态条件通过在读取和哈希之间破坏数据而显现出来。没有发生错误或 panic,但哈希值是错误的。真糟糕!
func (t trackDigestReader) Read(p []byte) (n int, err error) {
// the buffer p is blackHole
n, err = t.r.Read(p)
// p may be corrupted by another goroutine here,
// between the Read above and the Write below
t.h.Write(p[:n])
return
}
最终,通过为每次使用 ioutil.Discard
提供一个唯一的缓冲区来修复了这个 bug,消除了共享缓冲区上的竞态条件。
结论
竞态检测器是检查并发程序正确性的强大工具。它不会发出误报,因此请认真对待其警告。但它仅取决于您的测试;您必须确保它们充分锻炼代码的并发属性,以便竞态检测器能够完成其工作。
还在等什么?今天就在你的代码上运行 "go test -race"
!
下一篇文章:第一个 Go 程序
上一篇文章:Go 和 Google Cloud Platform
博客索引