[翻译]Go 的竞态检测器

理解竞态对于并发编程来说很重要,如果能通过某种手段来了解程序中存在的竞态,以便进一步的调整避免竞态,也是非常有效的优化手段。Go 1.1 的工具链引入了竞态检测器可以检测并展示程序中存在的竞态情况。Go 团队撰写了博文详细介绍了这一工具的原理和使用。原文在此《Introducing the Go Race Detector》。

————翻译分隔线————

Go 的竞态检测器

Dmitry Vyukov 和 Andrew Gerrand

竞态条件几乎是最为隐蔽和难以发现的程序错误。它们通常会导致诡异且无法解释的错误,尤其是在代码已经部署到生产环境很长时间之后。虽然 Go 的并发机制使得编写清晰的并发代码变得容易,但是它们无法避免竞态条件。认真、勤快的测试是必须的。而工具可以给予帮助。

我们很高兴的宣布 Go 1.1 包含了竞态检测器,一个用于发现 Go 代码中的竞态条件的新工具。它当前在 64 位 x86 处理器的 Linux、MacOS 和 64 位 x86 处理器的 Windows 系统中可用。

该竞态检测器是基于 C/C++ ThreadSanitizer 运行时库,这个库已经被用在 Google 的基础代码和 Chromium 中,并检测出了许多错误。该技术于 2012 年 12 月被集成到了 Go;从那时开始,它已经检测出标准库的 42 处竞态代码。它现在是持续集成过程的一部分,会持续的发现竞态条件并捕捉。

工作原理

这个竞态检测器被整合进 go 工具链。当命令行参数 -race 被设置时,编译器会记录代码中所有的内存访问,包括在什么时候、是如何访问的,而运行时库会监视非同步的共享变量。当这种“下流”的行为被检测到,会打印一个警告。(参阅这个文章了解算法的细节。)

由于其设计,这个竞态检测器只能检测到被正在运行的代码触发的竞态条件,这意味着让开启竞态的执行文件运行在真实的工作压力下很重要。然而,开启竞态的执行文件会使用十倍的 CPU 和内存,因此一直启用竞态检测器是不现实的。一个避免这个困境的办法是在竞态检测器开启的情况下运行一些测试。由于压力测试和集成测试更倾向于对并发的部分进行验证,因此是不错的选择。另一种在生产环境工作负载下使用的办法是部署一个开启竞态的实例到一个服务池中去(译注:TcpCopy 或许也是个不错的选择)。

竞态检测器的使用

竞态检测器已经被完全整合到了 Go 工具链中。为了编译开启竞态检测器的代码,只需要增加命令行参数 -race 标识:

如果要亲手尝试一下竞态检测器的话,获取并执行这个例子程序:

$ go get -race code.google.com/p/go.blog/support/racy
$ racy

实例

这里有两个竞态检测器捕捉到的真实案例。

实例 1:Timer.Reset

第一个简单的例子是竞态检测器发现的一个真实的错误。它使用一个计时器在随机 0 到 1 秒的延迟后打印一条消息。然后重复这一过程五秒钟。它使用 time.AfterFunc 创建了一个 Timer 用于第一条消息,然后用 Reset 方法来调度接下来的消息,每次都复用这个 Timer。

package main

import (
        "fmt"
        "math/rand"
        "time"
)


func main() {
    start := time.Now()
    var t *time.Timer
    t = time.AfterFunc(randomDuration(), func() {
        fmt.Println(time.Now().Sub(start))
        t.Reset(randomDuration())
    })
    time.Sleep(5 * time.Second)
}

func randomDuration() time.Duration {
    return time.Duration(rand.Int63n(1e9)) 
}

这看起来是很合理的代码,但是在一些特定的环境下,它会用一种奇异的方式出错:

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 赋值之前被执行,而在值为 nil 的 t 上调用 t.Reset。

为了修复这个竞态条件只需要修改主 goroutine 对变量 t 读写的代码:

func main() {
     start := time.Now()
     reset := make(chan bool)
     var t *time.Timer
     t = time.AfterFunc(randomDuration(), func() {
         fmt.Println(time.Now().Sub(start))
         reset <- true
     })
     for time.Since(start) < 5*time.Second {
         <-reset
         t.Reset(randomDuration())
     }
}

这里的主 goroutine 对设置和重置 Timer t 负全责,而新添加的 channel reset 用来通讯,以确保在线程安全的途径下重置定时器。

一个简单而更有效率的办法是避免重用定时器

实例 2:ioutil.Discard

第二个例子更加微妙。

ioutil 包的 Discard 对象实现了 io.Writer,来丢弃写入它的所有数据。可以将其比作 /dev/null:一个可以向其发送数据,而不用存储它们的地方。这通常可以用在 io.Copy 排空一个 Reader,就像这样:

io.Copy(ioutil.Discard, reader)

回到 2011 年七月,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 // 共享的缓冲区

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 同时对文件进行哈希时,全部都共享同一个 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”吧!

Join the Conversation

5 Comments

  1. ”It is currently available for Linux, MacOS, and Windows systems with 64-bit x86 processors.“ 似乎翻译为“64 位 x86 处理器上的 Linux, MacOS 和 Windows 中”,而非“在 Linux、MacOS 和 64 位 x86 处理器的 Windows 系统中”。

    http://blog.golang.org/race-detector 里面 Supported Systems 也是 “darwin/amd64, linux/amd64, and windows/amd64”

Leave a comment

Your email address will not be published. Required fields are marked *