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

第一个示例是竞态检测器发现的实际错误的简化版本。它使用计时器在 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 的调用使用的是 nil t

为了修复竞态条件,我们将代码更改为仅从主 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 遇到一个令人沮丧和奇怪的错误。经过几天的调试,他将其缩小到由 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 缓冲区,竞态条件通过在读取和哈希之间破坏数据来体现出来。没有发生错误或恐慌,但哈希值是错误的。太糟糕了!

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 的使用提供一个唯一的缓冲区,从而消除了共享缓冲区上的竞态条件。

结论

竞态检测器是检查并发程序正确性的强大工具。它不会发出误报,因此请认真对待其警告。但它只有在你测试良好的情况下才能发挥作用;您必须确保它们彻底地检验代码的并发特性,以便竞态检测器能够发挥作用。

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

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