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 | go test -race mypkg // test the package |
3 示例
3.1 Timer.Reset
当前例子是竞态探测器发现的实际bug的简化版本。
在使用定时器, 0到1秒的随机时间间隔之后打印消息。 打印过程反复进行了五秒钟。
使用 time.AfterFunc
为第一条消息创建一个 Timer
,然后使用 Reset
方法调度下一条消息,每次都复用原有 Timer
。
1 | func main() { |
这似乎是合理的代码,但在某些情况下,它以令人惊讶的方式失败:
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 | func main() { |
主 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 | var blackHole [4096]byte // shared buffer |
这次修复依旧没能解决问题,因为用户自定义的 /Reader/,可能在读的过程中,执行写操作,这个时候共享的缓冲区就造成数据污染。
1 | type trackDigestReader struct { |
最终还是通过为每次使用的 ioutil.Discard 添加唯一的缓冲区,来消除共享缓冲区的 Race condition 。
4 总结
竞态探测器是检查并发程序正确性的强大工具。 它不会呈现虚假问题,所以请认真地对待。
还在等什么?现在就对你的代码运行“go test -race”吧!
Render by hexo-renderer-org with Emacs 25.3.2 (Org mode 8.2.10)