goroutine 开太多为什么会变慢,我复盘了一次
给涂色模板做线稿批处理的时候,我一开始的做法很直接:有多少张图就起多少个 goroutine,让它们自己去读文件、处理、写回去。二十张线稿的规模下,两秒钟跑完。所以 goroutine 开太多程序为什么会变慢,原因不是 goroutine 本身贵,是你下游的资源被挤爆了——我那次撞的是磁盘。
一、goroutine 开太多程序为什么会变慢:先看当时的现场
任务是把一批线稿做同样的图像处理,输出到另一个目录。
第一版写法是「一张图一个 goroutine」,全部一起放出去。二十张的时候很快,因为并发度还没超过机器的实际能力。
后来素材变多,同一段代码开始变慢,而且机器明显发烫、风扇狂转。总耗时不是线性增长,是加速变差。
二、摊开看这件事
我做了两组对照,只改并发数,其他代码一行不动。
第一组是全部放出去——有几张图就起几个 goroutine。第二组是加一个上限,同一时间只允许固定数量的 goroutine 在跑,跑完一个再补一个。
第二组在素材量大的时候明显更快,而且耗时曲线更平。
结论是:并发数不是越大越好,超过某个点之后,加并发只会让总时间变长。这也是 goroutine 开太多程序为什么会变慢最直接的答案。
三、我判断错在哪
第一个错是把 goroutine 的廉价当成了整条链路的廉价。起一个 goroutine 确实开销很小,但它要占的文件句柄、要读的磁盘带宽、要用的内存缓冲区,都不是免费的。
第二个错是以为瓶颈在 CPU。图像处理听起来像算力活,但我这批是简单处理,真正花时间的是读文件写文件。CPU 在等磁盘,goroutine 堆在那里什么也干不了。
第三个错是没做限流。全部放出去听起来很爽,实际上等于把调度权交给了运行时的随机性,谁先拿到资源完全不可控。同一批任务每次跑的耗时都不一样,就是因为这个。
四、改法
改法很朴素:加一个带缓冲的 channel 当令牌,缓冲区大小就是允许同时跑的数量。
任务开始前先从 channel 里取一个令牌,干完再放回去。取不到就等着。这样同一时间的并发数就有了硬上限。
这个数怎么定?我的做法是二分着试:从一个很小的值开始,逐次翻倍,看总耗时什么时候不再下降。不再下降的那个点之前的档位,就是合适的值。
我没有把这套做法包装成什么通用方法,就是老老实实试了几轮。但比拍脑袋定一个数靠谱得多。
五、这件事能不能复制
能,而且我认为所有做批处理的人都该走一遍这个过程。
原因很简单:批处理的瓶颈几乎总是落在某一个具体的资源上,不是磁盘就是网络就是连接池。找到它、给它设上限,速度自然上来。
反过来,如果你的任务是纯 CPU 计算、没有任何外部依赖,那并发数往上加到核数附近通常还是有效的,再往上收益就很小了。
判断依据也很直接:看任务在等什么。在等 CPU,加到核数;在等磁盘或者网络,一定要限流,而且限得比你以为的更狠。
六、顺带说一个坑
限流之后,如果某个任务 panic 了,令牌可能放不回去,整个池子会慢慢卡死。
所以放回令牌那一步要用 defer,跟开 goroutine 时收尾的逻辑写在一起。限流引入了新的失败模式,这一点当初我没考虑到,后来又返工了一次——goroutine 开太多程序为什么会变慢,答案往往就藏在这种被忽略的细节里。
常见问题
Q:goroutine 开太多程序为什么会变慢?
A:goroutine 本身开销小,但它占用的下游资源有限。开太多之后大量 goroutine 在等磁盘、网络或连接池,排队变长、内存占用升高,总耗时反而增加。
Q:并发数怎么定?
A:先找瓶颈资源,再从小的并发数开始逐次增加,观察总耗时的变化。停止下降的那个点之前的档位就是合适的值。
Q:纯计算任务也要限流吗?
A:要,但上限可以放宽到接近 CPU 核数。超过核数之后,纯计算任务的收益通常已经很小了。
相关阅读
- Go 里 channel 和 mutex 该用哪个
- Go context 超时取消怎么一层层传下去
- Go 并发读写 map 报错,怎么改才安全
- Go 并发测试老是挂,我最后这么改稳的
- Go 并发编程的 5 个常见坑,新手先看
本文作者西米,37 岁程序员,一个人加五个 AI 员工在跑一家公司。踩过的坑和真实账本都写在公众号「西米分享」。
完整代码、踩坑清单与最新进展,都在公众号。
📱 关注公众号「西米分享」