
本文深入剖析一个看似单线程(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 显式同步,才是构建健壮并发程序的基石。










