Go的竞态探测器

Race conditions 是最隐晦和难以捉摸的编程错误之一。 通常,在代码部署到生产之后很长时间才会发作,而且通常会导致很神秘的故障。 Go的并发机制使得编写干净并发代码变得容易,但它们并不能防止 /Race conditions/。 需要谨慎,勤勉和测试。 工具很有帮助。

Go 1.1引入竞态探测器,一个用于在Go代码中查找 Race conditions 的新工具。 它基于 C/C++ ThreadSanitizer运行库 ,此库被用于检测Google内部代码库和Chromium中的许多错误。 该技术于2012年9月与Go集成; 此后,它已经应用到了标准库中。 它现在已经成为持续建设过程的一部分,在这些过程中,它们会随着时间的推移而捕捉到产生的 Race conditions

1 工作原理

竞态探测器集成在go工具链中。 当设置了-race命令行标志时,编译器将使用访问内存的时间和方式的代码记录下来,用于设置所有内存访问, 而运行时库会监视对共享变量的不同步访问。 当检测到这种“racy”行为时,会打印一个警告。

由于其设计,竞态探测器只能在运行代码实际触发时才能检测到竞争条件,这意味着需要在真实的工作负载下运行启用探测器。 然而,启用竞态探测的可执行文件可能使用十倍的CPU和内存,因此始终启用探测器是不切实际的。 出于这个困境的一个办法是在启用竞态探测的情况下运行一些测试。 负载测试和集成测试是很好的候选者,因为它们往往会执行代码的并发部分。 另外的可选途径:生产工作负载环境中, 在运行的服务器池中, 部署单个启用竞态探测的实例。

2 使用

竞态探测器与Go工具链完全集成。 要启用竞态检测器的情况下,构建代码,只需将 -race 标志添加到命令行:

1
2
3
4
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

3 示例

3.1 Timer.Reset

当前例子是竞态探测器发现的实际bug的简化版本。 在使用定时器, 0到1秒的随机时间间隔之后打印消息。 打印过程反复进行了五秒钟。 使用 time.AfterFunc 为第一条消息创建一个 Timer ,然后使用 Reset 方法调度下一条消息,每次都复用原有 Timer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
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 at 0x00c420084018 by goroutine 7:
  main.main.func1()
      /tmp/babel-27165ee_/go-src-27165GUv.go:17 +0x17c

Previous write at 0x00c420084018 by main goroutine:
  main.main()
      /tmp/babel-27165ee_/go-src-27165GUv.go:18 +0x17a

Goroutine 7 (running) created at:
  time.goFunc()
      /home/parallels/.gvm/gos/go1.8/src/time/sleep.go:170 +0x51
==================
Found 1 data race(s)
exit status 66

竞态探测器展示出问题根源:来自不同 goroutines 对变量 t 有不同步读和写。 如果初始定时器时间间隔非常小,则定时器函数可能会在主 goroutine 赋值到 t 之前触发,因此对 t.Reset 的调用发生在 nil 上。

修复这个 race condition 问题,可通过读写发生在一个 goroutine 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
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())
}
}

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

goroutine 完全负责设置和重置定时器 t ,通过一个新的重置 channel 传达重置定时器的信号,然后以线程安全的方式重置定时器。

最简单但不相对不那么高效的方式是避免复用timer

3.2 ioutil.Discard

ioutil包的 Discard 实现了接口 io.Writer , 但是忽略了所有写给它的数据。 可认为如 dev/null 一般:发送你需要读取而不需要存储的数据的一个地方。 它通常与 /io.Copy 一起使用,清空reader,如下所示:

io.Copy(ioutil.Discard, reader)

回到2011年7月,Go团队注意到,以这种方式使用Discard效率不高:Copy功能在每次调用时内部都会分配一个 32kB 的缓冲区, 但是当与 Discard 一起使用时,缓冲区完全没必要,因为只是丢弃读取到的数据。 他们认为这种惯用的复制和丢弃不应该那么昂贵。修复此问题的方式,就是给 Writer 实现方法 ReadFrom,如下所示:

writer.ReadFrom(reader)

Go团队向 Discard 的底层类型添加了一个ReadFrom方法,该类型具有内部缓冲区,该缓冲区在其所有用户之间共享。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
}
}
}

这次修复依旧没能解决问题,因为用户自定义的 /Reader/,可能在读的过程中,执行写操作,这个时候共享的缓冲区就造成数据污染。

1
2
3
4
5
6
7
8
9
10
11
12
13
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) // 这里的p就是代表的就是balckHode
t.h.Write(p[:n])
return
}

tdr := trackDigestReader{r: file, h: sha1.New()}
io.Copy(ioutil.Discard, tdr)

最终还是通过为每次使用的 ioutil.Discard 添加唯一的缓冲区,来消除共享缓冲区的 Race condition

4 总结

竞态探测器是检查并发程序正确性的强大工具。 它不会呈现虚假问题,所以请认真地对待。

还在等什么?现在就对你的代码运行“go test -race”吧!

Last Updated 2017-05-23 Tue 22:17.
Render by hexo-renderer-org with Emacs 25.3.2 (Org mode 8.2.10)