前几天在读者交流群里看到一位小伙伴,发出了一个致命提问,那就是:“单机的 goroutine 数量控制在多少比较合适?”。
也许你和群内小伙伴第一反应一样,会答复 “控制多少,我觉得没有定论”。
紧接着延伸出了更进一步的疑惑:“goroutine 太多了会影响 gc 和调度吧,主要是怎么预算这个数是合理的呢?”
这是本文要进行探讨的主体,因此本文的结构会是先探索基础知识,再一步步揭开,深入理解这个问题。
Goroutine 是什么
Go 语言作为一个新生编程语言,其令人喜爱的特性之一就是 goroutine。Goroutine 是一个由 Go 运行时管理的轻量级线程,一般称其为 “协程”。
go f(x, y, z)
操作系统本身是无法明确感知到 Goroutine 的存在的,Goroutine 的操作和切换归属于 “用户态” 中。
Goroutine 由特定的调度模式来控制,以 “多路复用” 的形式运行在操作系统为 Go 程序分配的几个系统线程上。
同时创建 Goroutine 的开销很小,初始只需要 2-4k 的栈空间。Goroutine 本身会根据实际使用情况进行自伸缩,非常轻量。
func say(s string) {
for i := 0; i < 9999999; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
}
func main() {
go say("煎鱼")
say("你好")
}
人称可以开几百几千万个的协程小霸王,是 Go 语言的得意之作之一。
调度是什么
既然有了用户态的代表 Goroutine,操作系统又看不到他。必然需要有某个东西去管理他,才能更好的运作起来。
这指的就是 Go 语言中的调度,最常见、面试最爱问的 GMP 模型。因此接下来将会给大家介绍一下 Go 调度的基础知识和流程。
下述内容摘自煎鱼和 p 神写的《Go 语言编程之旅》中的章节内容。
调度基础知识
Go scheduler 的主要功能是针对在处理器上运行的 OS 线程分发可运行的 Goroutine,而我们一提到调度器,就离不开三个经常被提到的缩写,分别是:
- G:Goroutine,实际上我们每次调用
go func
就是生成了一个 G。 - P:Processor,处理器,一般 P 的数量就是处理器的核数,可以通过
GOMAXPROCS
进行修改。 - M:Machine,系统线程。
这三者交互实际来源于 Go 的 M: N 调度模型。也就是 M 必须与 P 进行绑定,然后不断地在 M 上循环寻找可运行的 G 来执行相应的任务。
调度流程
我们以 GMP 模型的工作流程图进行简单分析,官方图如下:
![img](/img/GoGoroutine 数量控制/1.png)
- 当我们执行
go func()
时,实际上就是创建一个全新的 Goroutine,我们称它为 G。 - 新创建的 G 会被放入 P 的本地队列(Local Queue)或全局队列(Global Queue)中,准备下一步的动作。需要注意的一点,这里的 P 指的是创建 G 的 P。
- 唤醒或创建 M 以便执行 G。
- 不断地进行事件循环
- 寻找在可用状态下的 G 进行执行任务
- 清除后,重新进入事件循环
在描述中有提到全局和本地这两类队列,其实在功能上来讲都是用于存放正在等待运行的 G,但是不同点在于,本地队列有数量限制,不允许超过 256 个。
并且在新建 G 时,会优先选择 P 的本地队列,如果本地队列满了,则将 P 的本地队列的一半的 G 移动到全局队列。
这可以理解为调度资源的共享和再平衡。
窃取行为
我们可以看到图上有 steal 行为,这是用来做什么的呢,我们都知道当你创建新的 G 或者 G 变成可运行状态时,它会被推送加入到当前 P 的本地队列中。
其实当 P 执行 G 完毕后,它也会 “干活”,它会将其从本地队列中弹出 G,同时会检查当前本地队列是否为空,如果为空会随机的从其他 P 的本地队列中尝试窃取一半可运行的 G 到自己的名下。
官方图如下:
![img](/img/GoGoroutine 数量控制/2.png)
在这个例子中,P2 在本地队列中找不到可以运行的 G,它会执行 work-stealing
调度算法,随机选择其它的处理器 P1,并从 P1 的本地队列中窃取了三个 G 到它自己的本地队列中去。
至此,P1、P2 都拥有了可运行的 G,P1 多余的 G 也不会被浪费,调度资源将会更加平均的在多个处理器中流转。
有没有什么限制
在前面的内容中,我们针对 Go 的调度模型和 Goroutine 做了一个基本介绍和分享。
接下来我们回到主题,思考 “goroutine 太多了,会不会有什么影响”。
在了解 GMP 的基础知识后,我们要知道在协程的运行过程中,真正干活的 GPM 又分别被什么约束?
煎鱼带大家分别从 GMP 来逐步分析。
M 的限制
第一,要知道在协程的执行中,真正干活的是 GPM 中的哪一个?
那势必是 M(系统线程) 了,因为 G 是用户态上的东西,最终执行都是得映射,对应到 M 这一个系统线程上去运行。
那么 M 有没有限制呢?
答案是:有的。在 Go 语言中,M 的默认数量限制是 10000,如果超出则会报错:
GO: runtime: program exceeds 10000-thread limit
通常只有在 Goroutine 出现阻塞操作的情况下,才会遇到这种情况。这可能也预示着你的程序有问题。
若确切是需要那么多,还可以通过 debug.SetMaxThreads
方法进行设置。
G 的限制
第二,那 G 呢,Goroutine 的创建数量是否有限制?
答案是:没有。但理论上会受内存的影响,假设一个 Goroutine 创建需要 4k(via @GoWKH):
- 4k * 80,000 = 320,000k ≈ 0.3G内存
- 4k * 1,000,000 = 4,000,000k ≈ 4G内存
以此就可以相对计算出来一台单机在通俗情况下,所能够创建 Goroutine 的大概数量级别。
注:Goroutine 创建所需申请的 2-4k 是需要连续的内存块。
P 的限制
第三,那 P 呢,P 的数量是否有限制,受什么影响?
答案是:有限制。P 的数量受环境变量 GOMAXPROCS
的直接影响。
环境变量 GOMAXPROCS
又是什么?在 Go 语言中,通过设置 GOMAXPROCS
,用户可以调整调度中 P(Processor)的数量。
另一个重点在于,与 P 相关联的的 M(系统线程),是需要绑定 P 才能进行具体的任务执行的,因此 P 的多少会影响到 Go 程序的运行表现。
P 的数量基本是受本机的核数影响,没必要太过度纠结他。
那 P 的数量是否会影响 Goroutine 的数量创建呢?
答案是:不影响。且 Goroutine 多了少了,P 也该干嘛干嘛,不会带来灾难性问题。
何为之合理
在介绍完 GMP 各自的限制后,我们回到一个重点,就是 “Goroutine 数量怎么预算,才叫合理?”。
“合理” 这个词,是需要看具体场景来定义的,可结合上述对 GPM 的学习和了解。得出:
- M:有限制,默认数量限制是 10000,可调整。
- G:没限制,但受内存影响。
- P:受本机的核数影响,可大可小,不影响 G 的数量创建。
Goroutine 数量在 MG 的可控限额以下,多个把个、几十个,少几个其实没有什么影响,就可以称其为 “合理”。
真实情况
在真实的应用场景中,没法如此简单的定义。如果你 Goroutine:
- 在频繁请求 HTTP,MySQL,打开文件等,那假设短时间内有几十万个协程在跑,那肯定就不大合理了(可能会导致 too many files open)。
- 常见的 Goroutine 泄露所导致的 CPU、Memory 上涨等,还是得看你的 Goroutine 里具体在跑什么东西。
还是得看 Goroutine 里面跑的是什么东西。
总结
在这篇文章中,分别介绍了 Goroutine、GMP、调度模型的基本知识,针对如下问题进行了展开:
- 单机的 goroutine 数量控制在多少比较合适?
- goroutine 太多了会影响 gc 和调度吧,主要是怎么预算这个数是合理的呢?
单机的 goroutine 数量只要控制在限额以下的,都可以认为是 “合理”。
真实场景得看具体里面跑的是什么,跑的如果是 “资源怪兽”,只运行几个 Goroutine 都可以跑死。
因此想定义 “预算”,就得看跑的什么了。