数据竞争检测器

介绍

数据竞争(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"

可用选项有:

示例:

$ 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]
}

原始未保护的变量

数据竞争也可能发生在原始类型的变量上(boolintint64 等), 如本例所示:

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/amd64linux/ppc64lelinux/arm64linux/s390xlinux/loong64freebsd/amd64netbsd/amd64darwin/amd64darwin/arm64windows/amd64

在 Windows 上,竞态检测器运行时对所安装 C 编译器的 版本敏感;自 Go 1.21 起,使用 -race 构建 程序需要 C 编译器包含 8 版或更高版本的 mingw-w64 运行时库。您可以通过使用参数 --print-file-name libsynchronization.a 调用 C 编译器来测试。 更新的合规 C 编译器会打印此库的完整路径, 而旧版 C 编译器只会回显参数。

运行时开销

竞态检测的开销因程序而异,但对于典型程序,内存使用量 可能增加 5-10 倍,执行时间增加 2-20 倍。

竞态检测器当前为每个 deferrecover 语句额外分配 8 字节。这些额外分配直到 goroutine 退出时才回收。这意味着如果您有一个长时间运行的 goroutine 周期性地调用 deferrecover, 程序内存使用量可能会无限制增长。这些内存分配 不会出现在 runtime.ReadMemStatsruntime/pprof 的输出中。