Go 并发测试老是挂,我最后这么改稳的

最贵的一课是:我有一个跑了几百遍都绿的并发测试,本地从来没挂过。上线前那次 CI 突然红了,我点了个「重跑」就绿了,然后放了它过去。让 Go 并发测试怎么写才稳定,关键不是加 time.Sleep,是让测试不再依赖「谁先谁后」这个前提。

一、Go 并发测试怎么写才稳定,先别用 sleep 等 goroutine

现象是测试里写一段启动 goroutine 的代码,跟一句 time.Sleep,然后断言结果。

为什么踩:这么写在开发机上一直过,看起来特别省事。但机器一换、CI 一跑、负载一高,那个固定等待时间就不够了。

根因是 sleep 表达的是「大概」,而测试需要的是「确定」。你没法用一个概率描述去断言一个确定结果。

怎么避:等 goroutine 结束要用明确的同步原语。等一批干完用 WaitGroup,等某一个完成用 channel 收信号。代码里出现 sleep 在等并发,就该警觉。

坑二:测试里共享了可变状态

现象是同一个包里的多个测试单独跑都过,一起跑就随机挂一个。

为什么踩:测试函数之间共享了一个包级变量,比如一个全局缓存或计数器。Go 默认会把同一个包里的测试顺序执行,但如果哪个测试里开了 t.Parallel(),顺序就没了。而且前一个测试留下的状态会污染后一个。

怎么避:每个测试里自己造数据,不要在包级变量里攒状态。真要共享,就在每个测试开头重置一次。

还有一个更隐蔽的:测试用的时间戳、随机数种子是固定的,导致某些测试用例之间会撞。随机数据要带一个固定的种子,这样失败能复现。

坑三:断言只看结果不看时序

现象是测试偶尔报「最终值不对」,但重跑又对了。

为什么踩:并发代码的正确性分两层——最终值对不对,和中间过程会不会出问题。很多人只断言了第一层。

结果就是数据竞争被藏起来了:值最后算对了,但过程中有两个 goroutine 同时写了同一块内存。跑一百次碰上一次读出错值,就挂一次。

怎么避:并发测试必须带 -race 参数跑。这个参数会检测数据竞争,多数情况下能直接指出是哪两处访问冲突了。不带它跑并发测试,等于做了一半。

要注意 -race 只能报这次执行路径上真的发生了的竞争,所以并发度要够。别只起一个 goroutine 就完事。

止损清单

一旦发现某个并发测试开始随机挂,我的处理顺序是这样。

第一,先别重跑。重跑绿了不代表修好了,只代表这次没撞上。挂着的那次日志才是线索。

第二,把它单独跑很多次。用 -count 参数重复执行,把偶发变成必现。

第三,加 -race 再跑一遍。竞争检测器经常直接告诉你答案。

第四,如果还定位不到,把并发度调上去。两个 goroutine 撞不出来,就起一批。

第五,实在找不到,先把测试标成跳过,但在任务里记一条。最怕的是重跑绿了就当没事,然后在线上用一次 fatal 退出换回来。

我现在的习惯

新写并发代码时,先写一个带 -race 的测试,再写实现。这样竞争检测器跟着我一起写,不用等到最后回头补。

另外,测试里所有等待都换成显式同步,代码里一个 sleep 都不留。Go 并发测试怎么写才稳定,本质就是把「等一会儿」换成「等一个确定信号」。

常见问题

Q:Go 并发测试怎么写才稳定?

A:把所有 sleep 换成显式同步,测试之间不共享可变状态,并且始终带 -race 跑。这三点做到,偶发挂的比例会大幅下降。

Q:测试随机挂,重跑就绿,要不要管?

A:要管,而且优先管。偶发失败通常意味着真实的数据竞争或时序假设错误,线上会以更难查的形式出现。

Q:-race 会不会让测试太慢?

A:会明显变慢,这是它的代价。所以只在测试和本地开发时开,生产构建不要带。

相关阅读


本文作者西米,37 岁程序员,一个人加五个 AI 员工在跑一家公司。踩过的坑和真实账本都写在公众号「西米分享」。

完整代码、踩坑清单与最新进展,都在公众号。

📱 关注公众号「西米分享」