数据竞争检测器
介绍
数据竞争(data race)是并发系统中最常见且最难调试的 bug 类型之一。 当两个 goroutine 并发访问同一个变量,且至少有一个是写操作时,就会发生数据竞争。 详情请参阅 Go 内存模型。
下面是一个可能导致崩溃和内存损坏的数据竞争示例:
func main() {
c := make(chan bool)
m := make(map[string]string)
go func() {
m["1"] = "a" // [待翻译: First conflicting access.]
c <- true
}()
m["2"] = "b" // [待翻译: Second conflicting access.]
<-c
for k, v := range m {
fmt.Println(k, v)
}
}
使用方法
为了帮助诊断此类 bug,Go 内置了一个数据竞争检测器。
要使用它,只需在 go 命令中添加 -race 标志:
$ go test -race mypkg // [待翻译: to test the package] $ go run -race mysrc.go // [待翻译: to run the source file] $ go build -race mycmd // [待翻译: to build the command] $ go install -race mypkg // [待翻译: to install the package]
报告格式
当竞争检测器在程序中发现数据竞争时,它会打印一份报告。 该报告包含冲突访问的调用栈,以及涉及相关 goroutine 创建时的调用栈。 示例如下:
WARNING: DATA RACE
Read by goroutine 185:
net.(*pollServer).AddFD()
src/net/fd_unix.go:89 +0x398
net.(*pollServer).WaitWrite()
src/net/fd_unix.go:247 +0x45
net.(*netFD).Write()
src/net/fd_unix.go:540 +0x4d4
net.(*conn).Write()
src/net/net.go:129 +0x101
net.func·060()
src/net/timeout_test.go:603 +0xaf
Previous write by goroutine 184:
net.setWriteDeadline()
src/net/sockopt_posix.go:135 +0xdf
net.setDeadline()
src/net/sockopt_posix.go:144 +0x9c
net.(*conn).SetDeadline()
src/net/net.go:161 +0xe3
net.func·061()
src/net/timeout_test.go:616 +0x3ed
Goroutine 185 (running) created at:
net.func·061()
src/net/timeout_test.go:609 +0x288
Goroutine 184 (running) created at:
net.TestProlongTimeout()
src/net/timeout_test.go:618 +0x298
testing.tRunner()
src/testing/testing.go:301 +0xe8
选项
GORACE 环境变量用于设置竞争检测器的选项。
格式如下:
GORACE="option1=val1 option2=val2"
可用选项有:
-
log_path(默认stderr):竞争检测器将其报告写入名为log_path.pid的文件。 特殊名称stdout和stderr分别表示将报告写入标准输出和标准错误。 -
exitcode(默认66):在检测到竞争后退出时使用的退出状态码。 -
strip_path_prefix(默认""):从所有报告的文件路径中去除此前缀,以使报告更简洁。 -
history_size(默认1):每个 goroutine 的内存访问历史记录大小为32K * 2**history_size 个元素。 增加此值可以避免报告中出现 "failed to restore the stack" 错误,但代价是增加内存使用。 -
halt_on_error(默认0):控制程序在报告第一个数据竞争后是否退出。 -
atexit_sleep_ms(默认1000):在退出前,主 goroutine 中休眠的毫秒数。
示例:
$ GORACE="log_path=/tmp/race/report strip_path_prefix=/my/go/sources/" go test -race
排除测试
当你使用 -race 标志构建时,go 命令会定义额外的
构建标签 race。
你可以使用此标签在运行竞争检测器时排除某些代码和测试。
一些例子:
// +build !race
package foo
// [待翻译: The test contains a data race. See issue 123.]
func TestFoo(t *testing.T) {
// ...
}
// [待翻译: The test fails under the race detector due to timeouts.]
func TestBar(t *testing.T) {
// ...
}
// [待翻译: The test takes too long under the race detector.]
func TestBaz(t *testing.T) {
// ...
}
如何使用
首先,使用竞争检测器运行你的测试(go test -race)。
竞争检测器只能发现运行时发生的竞争,因此它无法找到未执行代码路径中的竞争。
如果你的测试覆盖不全面,你可以在真实负载下运行使用 -race 构建的二进制文件,可能会发现更多竞争。
典型的数据竞争
以下是一些典型的数据竞争示例。所有这些都可以被竞争检测器检测到。
循环计数器上的竞争
func main() {
var wg sync.WaitGroup
wg.Add(5)
var i int
for i = 0; i < 5; i++ {
go func() {
fmt.Println(i) // [待翻译: Not the 'i' you are looking for.]
wg.Done()
}()
}
wg.Wait()
}
函数字面量中的变量 i 与循环使用的是同一个变量,因此 goroutine 中的读操作与循环递增操作存在竞争。
(此程序通常打印 55555,而不是 01234。)
可以通过复制该变量来修复此程序:
func main() {
var wg sync.WaitGroup
wg.Add(5)
var i int
for i = 0; i < 5; i++ {
go func(j int) {
fmt.Println(j) // [待翻译: Good. Read local copy of the loop counter.]
wg.Done()
}(i)
}
wg.Wait()
}
意外共享的变量
// ParallelWrite 将数据写入 file1 和 file2,返回错误。
func ParallelWrite(data []byte) chan error {
res := make(chan error, 2)
f1, err := os.Create("file1")
if err != nil {
res <- err
} else {
go func() {
// 此 err 与主 goroutine 共享,
// 因此这里的写操作与下面的写操作存在竞争。
_, err = f1.Write(data)
res <- err
f1.Close()
}()
}
f2, err := os.Create("file2") // 对 err 的第二个冲突写入。
if err != nil {
res <- err
} else {
go func() {
_, err = f2.Write(data)
res <- err
f2.Close()
}()
}
return res
}
修复方法是在 goroutine 中引入新变量(注意使用 :=):
... _, err := f1.Write(data) ... _, err := f2.Write(data) ...
未保护的全局变量
如果以下代码被多个 goroutine 调用,会导致对 service 映射的竞争条件。
对同一映射的并发读写是不安全的:
var service map[string]net.Addr
func RegisterService(name string, addr net.Addr) {
service[name] = addr
}
func LookupService(name string) net.Addr {
return service[name]
}
要使代码安全,使用互斥锁保护访问:
var (
service map[string]net.Addr
serviceMu sync.Mutex
)
func RegisterService(name string, addr net.Addr) {
serviceMu.Lock()
defer serviceMu.Unlock()
service[name] = addr
}
func LookupService(name string) net.Addr {
serviceMu.Lock()
defer serviceMu.Unlock()
return service[name]
}
原始未保护的变量
数据竞争也可能发生在原始类型的变量上(bool、int、int64 等),
如本例所示:
type Watchdog struct{ last int64 }
func (w *Watchdog) KeepAlive() {
w.last = time.Now().UnixNano() // 第一次冲突访问。
}
func (w *Watchdog) Start() {
go func() {
for {
time.Sleep(time.Second)
// 第二次冲突访问。
if w.last < time.Now().Add(-10*time.Second).UnixNano() {
fmt.Println("No keepalives for 10 seconds. Dying.")
os.Exit(1)
}
}
}()
}
即使是这种 "无害的" 数据竞争也可能导致难以调试的问题,原因在于 内存访问的非原子性、 与编译器优化的干扰, 或处理器内存访问的重排序问题。
解决此类竞争的典型方法是使用通道或互斥锁。
为了保持无锁行为,也可以使用
sync/atomic 包。
type Watchdog struct{ last int64 }
func (w *Watchdog) KeepAlive() {
atomic.StoreInt64(&w.last, time.Now().UnixNano())
}
func (w *Watchdog) Start() {
go func() {
for {
time.Sleep(time.Second)
if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() {
fmt.Println("No keepalives for 10 seconds. Dying.")
os.Exit(1)
}
}
}()
}
未同步的发送和关闭操作
如本例所示,对同一通道的未同步发送和关闭操作 也可能构成竞态条件:
c := make(chan struct{}) // 或有缓冲通道
// 竞态检测器无法推导出以下发送和关闭操作的先于发生关系。
// 这两个操作未同步且并发发生。
go func() { c <- struct{}{} }()
close(c)
根据 Go 内存模型,通道上的发送操作先于 该通道上相应接收操作的完成。要同步 发送和关闭操作,可使用一个接收操作来确保 发送在关闭之前完成:
c := make(chan struct{}) // 或有缓冲通道
go func() { c <- struct{}{} }()
<-c
close(c)
系统要求
竞态检测器需要启用 cgo,在非 Darwin 系统上
需要安装 C 编译器。
竞态检测器支持
linux/amd64、linux/ppc64le、
linux/arm64、linux/s390x、
linux/loong64、freebsd/amd64、
netbsd/amd64、darwin/amd64、
darwin/arm64 和 windows/amd64。
在 Windows 上,竞态检测器运行时对所安装 C 编译器的
版本敏感;自 Go 1.21 起,使用 -race 构建
程序需要 C 编译器包含 8 版或更高版本的 mingw-w64
运行时库。您可以通过使用参数
--print-file-name libsynchronization.a 调用 C 编译器来测试。
更新的合规 C 编译器会打印此库的完整路径,
而旧版 C 编译器只会回显参数。
运行时开销
竞态检测的开销因程序而异,但对于典型程序,内存使用量 可能增加 5-10 倍,执行时间增加 2-20 倍。
竞态检测器当前为每个 defer 和 recover 语句额外分配 8 字节。这些额外分配直到 goroutine 退出时才回收。这意味着如果您有一个长时间运行的 goroutine 周期性地调用
defer 和 recover,
程序内存使用量可能会无限制增长。这些内存分配
不会出现在 runtime.ReadMemStats 或 runtime/pprof 的输出中。