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:会明显变慢,这是它的代价。所以只在测试和本地开发时开,生产构建不要带。
相关阅读
- Go 里 channel 和 mutex 该用哪个
- Go context 超时取消怎么一层层传下去
- Go 并发读写 map 报错,怎么改才安全
- Go 并发编程的 5 个常见坑,新手先看
- WaitGroup 最容易写错的三种写法
本文作者西米,37 岁程序员,一个人加五个 AI 员工在跑一家公司。踩过的坑和真实账本都写在公众号「西米分享」。
完整代码、踩坑清单与最新进展,都在公众号。
📱 关注公众号「西米分享」