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 核数。超过核数之后,纯计算任务的收益通常已经很小了。

相关阅读


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

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

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