数据竞争检测器

Data Race Detector - 数据竞争检测器

原文:https://go.dev/doc/articles/race_detector

简介

​ 数据竞争是并发系统中最常见和最难以调试的错误类型之一。当两个 Goroutine 并发访问同一个变量并且至少一个访问是写入操作时,就会发生数据竞争。有关详情,请参见《Go 内存模型》

​ 以下是导致崩溃和内存损坏的数据竞争示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func main() {
	c := make(chan bool)
	m := make(map[string]string)
	go func() {
		m["1"] = "a" // 第一次冲突的访问。
		c <- true
	}()
	m["2"] = "b" // 第二次冲突的访问。
	<-c
	for k, v := range m {
		fmt.Println(k, v)
	}
}

使用方法

​ 为了帮助诊断此类错误,Go 包括一个内置的数据竞争检测器。要使用它,请将 -race 标志添加到 go 命令中:

1
2
3
4
$ go test -race mypkg    // 测试包
$ go run -race mysrc.go  // 运行源文件
$ go build -race mycmd   // 构建命令
$ go install -race mypkg // 安装包

报告格式

​ 当数据竞争检测器在程序中发现数据竞争时,它会打印一个报告。该报告包含冲突访问的栈跟踪,以及涉及的 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的文件中。特殊名称stdoutstderr会分别将报告写入标准输出和标准错误。
  • 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在退出前休眠的毫秒数。

例如:

1
$ GORACE="log_path=/tmp/race/report strip_path_prefix=/my/go/sources/" go test -race

排除测试

​ 当使用-race标志构建时,go命令会定义附加的构建标签race。您可以使用该标签来在运行竞态检测器时排除某些代码和测试。以下是一些示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// +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构建的二进制文件来发现更多竞争。

典型的数据竞争

​ 以下是一些典型的数据竞争。所有这些都可以被竞态检测器检测出来。

循环计数器竞争

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func() {
			fmt.Println(i) //  这里的 'i' 和您想要的不一样。
			wg.Done()
		}()
	}
	wg.Wait()
}

​ 函数字面值中的变量 i 是循环中使用的相同变量,因此在 goroutine 中读取与循环增量的竞争。(此程序通常会打印 55555,而不是 01234)。可以通过创建变量副本来修复程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func(j int) {
			fmt.Println(j) // 好的。读取了循环计数器的本地副本。
			wg.Done()
		}(i)
	}
	wg.Wait()
}

意外共享变量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 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 中引入新变量(注意使用 :=):

1
2
3
4
5
			...
			_, err := f1.Write(data)
			...
			_, err := f2.Write(data)
			...

未受保护的全局变量

​ 如果从多个 goroutine 调用以下代码,则会在 service map 上发生竞争。同一个 map 的并发读写是不安全的:

1
2
3
4
5
6
7
8
9
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]
}

​ 为了使代码安全,使用 mutex 保护访问:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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等),如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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)
			}
		}
	}()
}

​ 即使是这种"innocent(无害)“的数据竞争也可能导致难以调试的问题,这些问题可能是由于内存访问的非原子性、与编译器优化的干扰或访问处理器内存的重新排序问题引起的。

​ 这种竞态的典型解决方法是使用通道或互斥锁。为了保持无锁行为,也可以使用sync/atomic包。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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)
			}
		}
	}()
}

未同步的发送和关闭操作

​ 正如这个例子所演示的那样,在同一个通道上未同步的发送和关闭操作也可能导致竞争条件:

1
2
3
4
5
6
c := make(chan struct{}) // 或缓冲通道

// 竞争检测器无法推导出以下发送和关闭操作的发生顺序。
// 这两个操作是未同步的,会同时发生。
go func() { c <- struct{}{} }()
close(c)

​ 根据Go内存模型,通道上的发送在对该通道的相应接收完成之前发生。要同步发送和关闭操作,请使用确保发送完成后再关闭的接收操作:

1
2
3
4
5
c := make(chan struct{}) // 或缓冲通道

go func() { c <- struct{}{} }()
<-c
close(c)

要求

​ 竞态检测器需要启用cgo,并且在非Darwin系统上需要安装C编译器。竞争检测器支持linux/amd64、linux/ppc64le、linux/arm64、freebsd/amd64、netbsd/amd64、darwin/amd64、darwin/arm64和windows/amd64。

运行时开销

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

​ 竞争检测器当前为每个 deferrecover 语句分配额外的 8 个字节。这些额外的分配在 goroutine 退出之前不会被恢复。这意味着,如果您有一个长时间运行的 goroutine,它会定期发出 deferrecover 调用,程序的内存使用量可能会无限增长。这些内存分配不会出现在 runtime.ReadMemStatsruntime/pprof 的输出中。

最后修改 October 10, 2024: 更新 (a4b8f85)