Go 译文之竞态检测器 race

做者:Dmitry Vyukov,Andrew Gerrand | Introducing the Go Race Detectorlinux

译者前言

第三篇 Go 官方博客译文,主要是关于 Go 内置的竞态条件检测工具。它能够有效地帮助咱们检测并发程序的正确性。使用很是简单,只需在 go 命令加上 -race 选项便可。git

本文最后介绍了两个真实场景下的竞态案例,第一个案例相对比较简单。重点在于第二个案例,这个案例比较难以理解,在原文的基础上,我也简单作了些补充,不知道是否把问题讲的足够清楚。同时,这个案例也告诉咱们,任什么时候候咱们都须要重视检测器给咱们的提示,由于一不当心,你就可能为本身留下一个大坑。github


概要

在程序世界中,竞态条件是一种潜伏深且很难发现的错误,若是将这样的代码部署线上,常会产生各类谜通常的结果。Go 对并发的支持让咱们能很是简单就写出支持并发的代码,但它并不能阻止竞态条件的发生。golang

本文将会介绍一个工具帮助咱们实现它。缓存

Go 1.1 加入了一个新的工具,竞态检测器,它可用于检测 Go 程序中的竞态条件。当前,运行在 x86_64 处理器的 Linux、Mac 或 Windows 下可用。bash

竞态检测器的实现基于 C/C++ 的 ThreadSanitizer 运行时库,ThreadSanitier 在 Googgle 已经被用在一些内部基础库以及 Chromium上,而且帮助发现了不少有问题的代码。微信

ThreadSanitier 这项技术在 2012 年 9 月被集成到了 Go 上,它帮助检测出了标准库中的 42 个竞态问题。它如今已是 Go 构建流程中的一部分,当竞态条件出现,将会被它捕获。并发

如何工做

竞态检测器集成在 Go 工具链,当命令行设置了 -race 标志,编译器将会经过代码记录全部的内存访问,什么时候以及如何被访问,运行时库也会负责监视共享变量的非同步访问。当检测到竞态行为,警告信息会把打印出来。(具体详情阅读 文章负载均衡

这样的设计致使竞态检测只能在运行时触发,这也意味着,真实环境下运行 race-enabled 的程序就变得很是重要,但 race-enabled 程序耗费的 CPU 和内存一般是正常程序的十倍,在真实环境下一直启用竞态检测是很是不切合实际的。dom

是否感觉到了一阵凉凉的气息?

这里有几个解决方案能够尝试。好比,咱们能够在 race-enabled 的状况下执行测试,负载测试和集成测试是个不错的选择,它偏向于检测代码中可能存在的并发问题。另外一种方式,能够利用生产环境的负载均衡,选择一台服务部署启动竞态检测的程序。

开始使用

竞态检测器已经集成到 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

接下来,咱们介绍 2 个实际的案例。

案例 1:Timer.Reset

这是一个由竞态检测器发现的真实的 bug,这里将演示的是它的一个简化版本。咱们经过 timer 实现随机间隔(0-1 秒)的消息打印,timer 会重复执行 5 秒。

首先,经过 time.AfterFunc 建立 timer,定时的间隔从 randomDuration 函数得到,定时函数打印消息,而后经过 timer 的 Reset 方法重置定时器,重复利用。

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))
}

咱们的代码看起来一切正常。但在屡次运行后,咱们会发如今某些特定状况下可能会出现以下错误:

anic: 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

什么缘由?启用下竞态检测器测试下吧,你会恍然大悟的。

$ go run -race main.go
==================
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
==================

结果显示,程序中存在 2 个 goroutine 非同步读写变量 t。若是初始定时时间很是短,就可能出如今主函数还未对 t 赋值,定时函数已经执行,而此时 t 仍然是 nil,没法调用 Reset 方法。

咱们只要把变量 t 的读写移到主 goroutine 执行,就能够解决问题了。以下:

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())
    }
}

main goroutine 彻底负责 timer 的初始化和重置,重置信号经过一个 channel 负责传递。

固然,这个问题还有个更简单直接的解决方案,避免重用定时器便可。示例代码以下:

package main

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

func main() {
    start := time.Now()
    var f func()
    f = func() {
        fmt.Println(time.Now().Sub(start))
        time.AfterFunc(time.Duration(rand.Int63n(1e9)), f)
    }
    time.AfterFunc(time.Duration(rand.Int63n(1e9)), f)
    time.Sleep(5 * time.Second)
}

代码很是简洁易懂,缺点呢,就是效率相对不高。

案例 2:ioutil.Discard

这个案例的问题隐藏更深。

ioutil 包中的 Discard 实现了 io.Writer 接口,不过它会丢弃全部写入它的数据,可类比 /dev/null。可在咱们须要读取数据但又不许备保存的场景下使用。它经常会和 io.Copy 结合使用,实现抽空一个 reader,以下:

io.Copy(ioutil.Discard, reader)

时间回溯至 2011 年,当时 Go 团队注意以这种方式使用 Discard 效率不高,Copy 函数每次调用都会在内部分配 32 KB 的缓存 buffer,但咱们只是要丢弃读取的数据,并不须要分配额外的 buffer。咱们认为,这种习惯性的用法不该该这样耗费资源。

解决方案很是简单,若是指定的 Writer 实现了 ReadFrom 方法,io.Copy(writer, reader) 调用内部将会把读取工做委托给 writer.ReadFrom(reader) 执行。

Discard 类型增长 ReadFrom 方法共享一个 buffer。到这里,咱们天然会想到,这里理论上会存在竞态条件,但由于写入到 buffer 中的数据会被马上丢弃,咱们就没有过重视。

竞态检测器完成后,这段代码马上被标记为竞态的,查看 issues/3970。这促使咱们再一次思考,这段代码是否真的存在问题呢,但结论依然是这里的竞态不影响程序运行。为了不这种 "假的警告",咱们实现了 2 个版本的 black_hole buffer,竞态版本和无竞态版本。而无竞态版只会其在启用竞态检测器的时候启用。

black_hole.go,无竞态版本。

// +build race

package ioutil

// Replaces the normal fast implementation with slower but formally correct one.
func blackHole() []byte {
    return make([]byte, 8192)
}

black_hole_race.go,竞态版本。

// +build !race

package ioutil

var blackHoleBuf = make([]byte, 8192)

func blackHole() []byte {
    return blackHoleBuf
}

但几个月后,Brad 遇到了一个迷之 bug。通过几天调试,终于肯定了缘由所在,这是一个由 ioutil.Discard 致使的竞态问题。

实际代码以下:

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 类型字段,和 io.Reader 中信息的 hash 摘要。

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 HASH。

tdr := trackDigestReader{r: file, h: sha1.New()}
io.Copy(writer, tdr)
fmt.Printf("File hash: %x", tdr.h.Sum(nil))

某些状况下,若是没有地方可供数据写入,但咱们仍是须要计算 hash,就能够用 Discard 了。

io.Copy(ioutil.Discard, tdr)

此时的 blackHole buffer 并不是仅仅是一个黑洞,它同时也是 io.Reader 和 hash.Hash 之间传递数据的纽带。当多个 goroutine 并发执行文件 hash 时,它们所有共享一个 buffer,Read 和 Write 之间的数据就可能产生相应的冲突。No error 而且 No panic,可是 hash 的结果是错的。就是如此可恶。

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
}

最终,经过为每个 io.Discard 提供惟一的 buffer,咱们解决了这个 bug,排除了共享 buffer 的竞态条件。代码以下:

var blackHoleBuf = make(chan []byte, 1)

 func blackHole() []byte {
    select {
    case b := <-blackHoleBuf:
        return b
    default:
    }
    return make([]byte, 8192)
}

func blackHolePut(p []byte) {
    select {
    case blackHoleBuf <- p:
    default:
    }
}

iouitl.go 中的 devNull ReadFrom 方法也作了相应修正。

func (devNull) ReadFrom(r io.Reader) (n int64, err error) {
buf := blackHole()
    defer blackHolePut(buf)
    readSize := 0
    for {
        readSize, err = r.Read(buf)

    // other
}

经过 defer 将使用完的 buffer 从新发送至 blackHoleBuf,由于 channel 的 size 为 1,只能复用一个 buffer。并且经过 select 语句,咱们在没有可用 buffer 的状况下,建立新的 buffer。

结论

竞态检测器,一个很是强大的工具,在并发程序的正确性检测方面有着很重要的地位。它不会发出假的提示,认真严肃地对待它的每条警示很是必要。但它并不是万能,仍是须要以你对并发特性的正确理解为前提,才能真正地发挥出它的价值。

试试吧!开始你的 go test -race。


波罗学的微信公众号

相关文章
相关标签/搜索