为什么你不应该接受有 race 的代码

在任何语言的并发编程场景中,都有 race 问题,现代语言为了解决 race 问题有两种思路,一种是像 rust 那样的通过所有权+Sync/Send 限制用户尽量无法写出带 race 的代码;一种是像 Go 这样,通过 race detector 在测试期间检查数据竞争问题。

Go 的 race detector 设计决定了其无法在线上环境开启,而很多公司的项目上线前其实是没有 race test 的环节的,这就导致了一些 Gopher 认为我写出 race 的代码也没关系,因为可以“最终一致”。

在 Go 官方的 《The Go Memory Model》 一文中已经驳斥过这些观点了,比如有这么一个例子:

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
}

func main() {
	go setup()
	for !done {
	}
	print(a)
}

全局变量 a 的写入和 done 的修改从代码层面讲存在先后关系,但因为你没有在代码中使用任何同步工具(哪怕是 atomic 操作),所以这样的代码你没法保证在 for 循环检查到 done 变成 true 之后,一定能打印出 "hello, world"。

这里还只是因为 CPU 和编译器的乱序执行导致了问题,按照这些程序员的想法,我不关心顺序,我只关注最终一致,反正 done 肯定能被改成 true,a 也一定能被修改成 hello, world。

这种想法也是有问题的,多核环境下 CPU 有多级缓存,如果连 atomic 都不使用,那么在多个核心间也不一定会同步你这种写行为。最差的情况下,在一个核心里写了 done = true,另一个核心一直读不到,这也是正常的(并不好复现)。说不定什么时候硬件为了优化就真的会给你这么干啊。

官方给出的这个例子,我们在 build/run 的时候加上简单的 -race flag,也是可以及时发现问题的:

~/test git:master ❯❯❯ go run -race ./r.go
==================
WARNING: DATA RACE
Write at 0x0000011506a1 by goroutine 6:
  main.setup()
      /Users/xargin/test/r.go:8 +0x73

Previous read at 0x0000011506a1 by main goroutine:
  main.main()
      /Users/xargin/test/r.go:13 +0x3e

Goroutine 6 (running) created at:
  main.main()
      /Users/xargin/test/r.go:12 +0x32
==================
==================
WARNING: DATA RACE
Read at 0x000001121bf0 by main goroutine:
  main.main()
      /Users/xargin/test/r.go:15 +0x53

Previous write at 0x000001121bf0 by goroutine 6:
  main.setup()
      /Users/xargin/test/r.go:7 +0x30

Goroutine 6 (finished) created at:
  main.main()
      /Users/xargin/test/r.go:12 +0x32
==================
hello, worldFound 2 data race(s)
exit status 66

发现了有 data race 就一定要去解决,如果允许这样的代码进入到你的工程里,那么这样的错误就会越来越多,当未来某个时刻你需要去查并发导致的 bug 了,那成百上千的 race 输出都是技术债,到时候再来还就晚了。

如果可以的话,race test 也最好集成在你的 CI 环境中,初级工程师最擅长的就是这个:

如果你允许有 race 的代码进入主分支,日积月累会有更多的 race 通过初级程序员的复制粘贴扩散出去。

当你花了一个星期还没有办法定位出线上偶发的并发问题的时候,可能就只能提桶跑路了。

要写并发相关的代码,还是要好好学习一下并发知识的。这里可以推荐两本相关的书:《Shared Memory Synchronization》 和 《perfbook》。