Go 博客

Go 竞态检测器介绍

Dmitry Vyukov 和 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    // 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


package main

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


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 的调用会在 t 为 nil 的情况下进行。

为了修复竞态条件,我们将代码修改为只从主 goroutine 读取和写入变量 t


package main

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


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  

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

这里主 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 一起使用时,该缓冲区是不必要的,因为我们只是将读取的数据丢弃了。我们认为这种惯用的 CopyDiscard 用法不应该如此昂贵。

修复方法很简单。如果给定的 Writer 实现了 ReadFrom 方法,则像这样的 Copy 调用

io.Copy(writer, reader)

会被委托给这个可能更高效的调用

writer.ReadFrom(reader)

我们给 Discard 的底层类型添加了一个 ReadFrom 方法,该类型有一个内部缓冲区,所有用户共享此缓冲区。我们知道这在理论上是一个竞态条件,但由于对缓冲区的写入都应该被丢弃,我们认为这并不重要。

当实现竞态检测器后,它立即将这段代码标记为竞态。我们再次考虑了这段代码可能存在问题,但决定认为该竞态条件不是“真实”的。为了避免构建中的“误报”,我们实现了一个非竞态版本,仅在竞态检测器运行时启用。

但几个月后,Brad 遇到了一个令人沮丧且奇怪的 bug。经过几天的调试,他将其范围缩小到由 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
}

这个 bug 最终通过为每次使用 ioutil.Discard 都提供一个唯一的缓冲区而得到修复,消除了共享缓冲区上的竞态条件。

结论

竞态检测器是一个强大的工具,用于检查并发程序的正确性。它不会产生误报,因此请认真对待其警告。但它的有效性取决于你的测试;你必须确保测试充分地锻炼了代码的并发特性,以便竞态检测器能够完成其工作。

还在等什么?立即在你的代码上运行 "go test -race" 吧!

下一篇文章:第一个 Go 程序
上一篇文章:Go 与 Google Cloud Platform
博客索引