Go 语言中隐蔽的竞态条件:无同步 goroutine 间变量读写的风险解析

2次阅读

Go 语言中隐蔽的竞态条件:无同步 goroutine 间变量读写的风险解析

本文深入剖析一个看似单线程goMAXPROCS=1)却仍出现非确定性输出的 Go 竞态案例,揭示无显式同步时主协程与子协程对共享变量 glo 的并发读写如何导致未定义行为,并说明为何 race detector 实际可检测该问题。

本文深入剖析一个看似单线程(gomaxprocs=1)却仍出现非确定性输出的 go 竞态案例,揭示无显式同步时主协程与子协程对共享变量 `glo` 的并发读写如何导致未定义行为,并说明为何 race detector 实际可检测该问题。

在 Go 中,“无并发 ≠ 无竞态”。即使 GOMAXPROCS=1(默认值),程序仍运行于协作式调度模型下:goroutine 并非真正串行执行,而是在 I/O、channel 操作、函数调用、甚至某些循环边界处主动让出控制权,由调度器决定何时切换。这正是本例中竞态发生的根本原因。

观察原始代码:

package main  import "fmt"  var quit chan int var glo int  func test() {     fmt.Println(glo) // ⚠️ 无同步:读取共享变量 glo }  func main() {     glo = 0     n := 1000000     quit = make(chan int, n)     go test() // 启动 goroutine,但无任何等待或同步机制     for {         quit <- 1 // ⚠️ channel send 是调度点!可能在此刻切换到 test()         glo++     // 主协程持续写入 glo     } }

关键点在于:quit 阻塞式 channel 发送操作(尽管带缓冲,但缓冲满前不会阻塞)。更重要的是,Go 调度器允许在 channel 操作前后插入调度点。这意味着每次循环执行 quit

因此:

  • 当 n = 10000 时,循环迭代少,test 很可能在 glo 增加较少时就被调度执行,输出接近 10000;
  • 当 n = 1000000 时,main 执行更多次 glo++ 后才被调度切换,test 却可能在任意中间状态被唤醒并读取 glo,导致输出为随机的小于 1000000 的值。

重要澄清:go run -race 实际能可靠检测此竞态。若未触发警告,极可能是运行环境(如旧版 Go)、编译标志遗漏或检测时机问题。标准 Go 1.20+ 下,上述代码必报如下典型 race report:

WARNING: DATA RACE Read by goroutine X:   main.test()       ./main.go:8 +0x6e Previous write by main goroutine:   main.main()       ./main.go:18 +0xfe

正确做法:使用同步原语确保可见性与顺序

要获得确定性行为,必须显式同步。以下是三种推荐方案:

✅ 方案一:使用 sync.WaitGroup(推荐用于一次性通知)

package main  import (     "fmt"     "sync" )  var glo int var wg sync.WaitGroup  func test() {     defer wg.Done()     fmt.Println(glo) // 安全:main 在 wg.Wait() 前保证写入完成 }  func main() {     glo = 0     wg.Add(1)     go test()      n := 1000000     for i := 0; i < n; i++ {         glo++     }      wg.Wait() // 等待 test 执行完毕 }

✅ 方案二:使用 channel 同步(体现 Go 风格)

func main() {     glo = 0     done := make(chan int, 1)     go func() {         fmt.Println(glo)         done <- 1     }()      n := 1000000     for i := 0; i < n; i++ {         glo++     }      <-done // 等待 goroutine 输出完成 }

✅ 方案三:使用 sync/atomic(适用于简单整数计数)

import "sync/atomic"  var glo int64 // 注意类型需匹配 atomic 函数  func test() {     fmt.Println(atomic.LoadInt64(&glo)) }  func main() {     atomic.StoreInt64(&glo, 0)     go test()      n := int64(1000000)     for i := int64(0); i < n; i++ {         atomic.AddInt64(&glo, 1)     } }

总结

  • Go 的 GOMAXPROCS=1 不等于单线程执行,goroutine 仍可被调度器抢占;
  • 对共享变量的无同步读写(即使发生在不同 goroutine 的“看似顺序”逻辑中)构成数据竞争,结果不可预测;
  • go run -race 是检测此类问题的黄金工具,应作为开发标配;
  • 永远不要依赖调度时机实现逻辑正确性;使用 sync.WaitGroup、channel 或 atomic 显式同步,才是构建健壮并发程序的基石。

text=ZqhQzanResources