bigywater.tech
Go · 70

竞态检测器:ThreadSanitizer 在 Go 里的开销与漏报

竞态检测器能抓到什么、抓不到什么、要花多少钱?这篇用几个小程序实测:它基于 happens-before 而不是碰运气,但只检查实际执行过的路径;开销在不同类型的程序上差很多。

31.2× / 3.5×
-race 版相对普通版的耗时:访存密集循环 / 争锁程序
退出码 66
发现 data race 时 -race 程序的默认退出码,GORACE=exitcode 可改
只检查执行过的
同一个二进制,竞争分支没执行时退出码 0

抓到了,结果却是对的

实测 · 本机执行输出
  counter = 2000
  ==================
  WARNING: DATA RACE
  Read at 0x00010498e490 by goroutine 17:
    main.main.func1()
        <gtmp>/rc1/main.go:42 +0x88
  
  Previous write at 0x00010498e490 by goroutine 18:
    main.main.func1()
        <gtmp>/rc1/main.go:42 +0xa0
  
  Goroutine 17 (running) created at:
    main.main()
        <gtmp>/rc1/main.go:42 +0x5f0
  
  Goroutine 18 (finished) created at:
    main.main()
        <gtmp>/rc1/main.go:42 +0x5f0
  ==================
  ==================
  WARNING: DATA RACE
  Write at 0x00010498e490 by goroutine 17:
    main.main.func1()
        <gtmp>/rc1/main.go:42 +0xa0
  
  Previous write at 0x00010498e490 by goroutine 18:
  进程退出码:66
  同一份源码不加 -race 编译:counter = 1367(退出码 0,没有任何提示)

两个 goroutine 各做 1000 次 counter++,带 -race 的这次最后打印 2000,看起来没丢更新,但检测器报告了读写竞争并给出两个 goroutine 的创建位置;不带 -race 的版本这次打印 1367。竞争不是“结果错了”,而是没有同步关系。

依据是同步关系

实测 · 本机执行输出
  [sleepordered] 退出码 66
    读到 42
    WARNING: DATA RACE
    Read at 0x000102ee6490 by main goroutine:
    Previous write at 0x000102ee6490 by goroutine 17:
  [atomicmixed] 退出码 66
    普通读到 1
    WARNING: DATA RACE
    Read at 0x000104d1a498 by main goroutine:
    Previous write at 0x000104d1a498 by goroutine 17:
  [readonly] 退出码 0:只读,无竞争

time.Sleep 让读发生在写之后几十毫秒,检测器仍然报告竞争,因为两者之间没有锁、channel 或原子操作建立 happens-before。原子写配普通读也同样被报。只读共享则不报。

只检查跑过的代码

实测 · 本机执行输出
  [不触发] 退出码 0:done
  [TRIGGER=1] 退出码 66:done | WARNING: DATA RACE

竞争分支只在设了环境变量时才执行:不设时即使存在竞争也是退出码 0。所以要用测试覆盖到并发路径,并在 CI 里跑 go test -race(本文没有实验 go test)。

GORACE 选项

实测 · 本机执行输出
  GORACE=exitcode=7:退出码 7
  GORACE=halt_on_error=1:退出码 66;出现 "WARNING: DATA RACE" 1 次;最后一行 "=================="

GORACE=exitcode=7 把发现竞争时的退出码从默认的 66 改成 7;GORACE=halt_on_error=1 让进程在第一次竞争报告后就退出,实验里只出现 1 次报告。

goroutine 数量没有撞到旧上限

实测 · 本机执行输出
  -race版本:10000 个 goroutine 同时存活,正常结束(退出码 0)
  普通版本:10000 个 goroutine 同时存活,正常结束(退出码 0)
  -race版本:100000 个 goroutine 同时存活,正常结束(退出码 0)
  普通版本:100000 个 goroutine 同时存活,正常结束(退出码 0)

我听说旧版本的竞争检测器对同时存活的 goroutine 有 8128 的上限,在这台机器上 10 万个都没问题,所以不能写它仍然存在;至于是哪个版本取消的,我没有查。

开销看程序类型

实测 · 本机执行输出
  内存密集的循环(数组 1M×40 遍) 普通 :耗时 0.034 / 0.034 / 0.035 s;内存中位 14 MB
  内存密集的循环(数组 1M×40 遍) -race:耗时 1.057 / 1.062 / 1.064 s;内存中位 48 MB
    → 耗时中位数比 31.2 倍;内存中位数比 3.6 倍
  8 个 goroutine 争锁各 20 万次 普通 :耗时 0.141 / 0.148 / 0.164 s;内存中位 5 MB
  8 个 goroutine 争锁各 20 万次 -race:耗时 0.505 / 0.514 / 0.519 s;内存中位 32 MB
    → 耗时中位数比 3.5 倍;内存中位数比 5.8 倍

访存密集的循环慢了 31.2 倍、内存多 3.6 倍;主要是争锁的程序只慢 3.5 倍,但内存多了 5.8 倍。所以别用某一个“慢 5 到 10 倍”的口号估算,要在自己的程序上量。

分步演示

竞态检测器
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。-race 版与普通版由同一份源码用 go build 与 go build -race 编译;内存用 /usr/bin/time -l 的 maximum resident set size(中位数);耗时每个 5 次;GORACE 的两个选项各跑一次;没有读检测器的源码或文档,所有结论来自实测输出
    • 没有验证:go test -race、竞争检测器的内存历史窗口导致的漏报(shadow cell 数量等)、cgo 程序、Linux 上的开销、8128 goroutine 上限的历史、报告的完整格式(只截取了开头),本文的两个开销数字来自两个特定程序
    京ICP备2026067502号-1京公网安备11010502063487号