Go 中的竞态条件探测器
Dmitry Vyukov and Andrew Gerrand
2013 年 6 月 26 日
介绍
竞赛条件 是最隐蔽和难以捉摸的编程错误之一. 它们通常会导致不稳定和神秘的故障, 通常是在将代码部署到生产中之后很长时间了. 尽管 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 // 测试程序包
$ go run -race mysrc.go // 编译并运行程序
$ go build -race mycmd // 构建命令
$ go install -race mypkg // 安装程序包
要自己尝试运行竞态检测器, 请获取以下示例程序并运行:
$ go get -race golang.org/x/blog/support/racy
$ racy
示例
这里有竞态探测器检测到实际问题的两个示例.
示例 1: Timer.Reset
第一个示例是由竞态检测器发现的实际错误的简化版本. 它使用计时器在 0 到 1 秒之间的随机持续时间后打印消息. 如此重复五秒钟. 它使用time.AfterFunc
函数创建一个 Timer
作为第一个消息, 然后使用 Reset
方法来调度下一条消息, 每次重新使用 Timer
.
11 func main() {
12 start := time.Now()
13 var t *time.Timer
14 t = time.AfterFunc(randomDuration(), func() {
15 fmt.Println(time.Now().Sub(start))
16 t.Reset(randomDuration())
17 })
18 time.Sleep(5 * time.Second)
19 }
20
21 func randomDuration() time.Duration {
22 return time.Duration(rand.Int63n(1e9))
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:14 +0x169
Previous write by goroutine 1:
main.main()
race.go:15 +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
的调用将被设置为一个空的 t
.
要修正竞态条件, 我们将代码更改为仅从主 goroutine 中读取和写入变量 t
:
11 func main() {
12 start := time.Now()
13 reset := make(chan bool)
14 var t *time.Timer
15 t = time.AfterFunc(randomDuration(), func() {
16 fmt.Println(time.Now().Sub(start))
17 reset <- true
18 })
19 for time.Since(start) < 5*time.Second {
20 <-reset
21 t.Reset(randomDuration())
22 }
23 }
这里的主协程完全负责设置和重置 Timer
t
, 并且新的重置通道传达了以线程安全的方式重置计时器的需求.
一种简单但效率较低的方法是 避免重复使用计时器.
示例 2: ioutil.Discard
第二个示例更加微妙.
The ioutil
package's Discard
object implements io.Writer
, but discards all the data written to it. Think of it like /dev/null
: a place to send data that you need to read but don't want to store. It is commonly used with io.Copy
to drain a reader, like this:
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 方法, 该基础类型具有内部缓冲区, 该缓冲区在所有用户之间共享. 我们知道从理论上讲这是一个竞争条件, 但是由于所有对缓冲区的写操作都应该被丢弃, 所以我们认为这并不重要.
实施竞态检测器后, 它立即被标记为恶意代码 (golang.org/issue/3970). 再次, 我们认为代码可能有问题, 但是决定竞争条件不是 "真实的". 为了避免在我们的版本中出现 "误报", 我们实现了一个 非安全版本, 该功能仅在运行竞态检测器时才启用.
但是几个月后, Brad 遇到了一个 令人沮丧和奇怪的错误. 经过几天的调试, 他将其范围缩小到了由 ioutil.Discard
引起的实际竞争情况.
这是 io/ioutil
中的已知代码, 其中 Discard
是一个 devNull
, 它在所有用户之间共享一个缓冲区.
var blackHole [4096]byte // 共享缓冲区
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
的之间存储数据的合法位置. 通过同时使用多个 goroutines 散列文件 (每个文件共享相同的 blackHole
缓冲区), 竞争条件通过破坏读取和散列之间的数据来表现出来. 没有发生错误或惊慌, 但哈希是错误的. 脑壳疼!
func (t trackDigestReader) Read(p []byte) (n int, err error) {
// 缓冲区 p 是一个黑洞
n, err = t.r.Read(p)
// p 可能再这里被另一个协程破坏了,
// 在上面读取和下面写入之间
t.h.Write(p[:n])
return
}
通过为 ioutil.Discard
的每次使用分配唯一的缓冲区, 消除了共享缓冲区上的竞态条件, 最终修复了该错误 (golang.org/cl/7011047).
结论
竞态检测器是检查并发程序正确性的强大工具. 它不会发出误报, 因此请认真对待其警告. 但这仅与您的测试一样好. 您必须确保它们充分利用代码的并发属性, 以便竞态检测器能够执行其工作.
还在等什么? 从今天就可以用 "go test -race"
运行您的代码!
本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。