Go的GMP模型


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 语言的得意之作之一。

GMP线程与模型

Golang在底层实现了混合型线程模型。M即系统线程,由系统调用产生,一个M关联一个KSE,即两级线程模型中的系统线程。G为Groutine,即两级线程模型的的应用及线程。M与G的关系是N:M。

Go线程模型与GMP

调度是什么

既然有了用户态的代表 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 模型的工作流程图进行简单分析,如下:

GMP模型

  1. 当我们执行 go func() 时,实际上就是创建一个全新的 Goroutine,我们称它为 G。

  2. 新创建的 G 会被放入 P 的本地队列(Local Queue)或全局队列(Global Queue)中,准备下一步的动作。需要注意的一点,这里的 P 指的是创建 G 的 P。

  3. 唤醒或创建 M 以便执行 G。

  4. 不断地进行事件循环

  5. 寻找在可用状态下的 G 进行执行任务

  6. 清除后,重新进入事件循环

在描述中有提到全局和本地这两类队列,其实在功能上来讲都是用于存放正在等待运行的 G,但是不同点在于,本地队列有数量限制,不允许超过 256 个。

并且在新建 G 时,会优先选择 P 的本地队列,如果本地队列满了,则将 P 的本地队列的一半的 G 移动到全局队列。

这可以理解为调度资源的共享和再平衡。

GMP调度策略

  1. 复用线程: work stealing 和 hand off 机制保证M线程的高效复用
  2. 利用并行: GOMAXPROCS 设置P的数量,充分利用CPU的多核和m多线程
  3. 抢占:完全公平的时间片轮转,每个G只能占用10ms,防止其他G饿死

 

golang_work_stealing

有没有什么限制

在前面的内容中,我们针对 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 都可以跑死。

因此想定义 “预算”,就得看跑的什么了。

 

参考: 

https://xuthus.cc/go/go-gmp-model.html

https://mp.weixin.qq.com/s/uWP2X6iFu7BtwjIv5H55vw