Go 为了自身 goroutine 执行和调度的效率,自身在 runtime 中实现了一套 goroutine 的调度器,下面通过一段简单的代码展示一下 Go 应用程序在运行时的 goroutine,方便大家更好的理解。
The Go scheduler is part of the Go runtime, and the Go runtime is built into your application
for i := 0; i < 4; i++ {go func() {time.Sleep(time.Second)}()
}
fmt.Println(runtime.NumGoroutine())
上面这段代码的输出为:5
说明当前这个应用程序中存在 goroutine
的数量是 5
,事实上也符合我们的预期。那么问题来了,这 5 个 goroutine 作为操作系统用户态的基本调度单元是无法直接占用操作系统的资源来执行的,必须经过内核级线程的分发,这是操作系统内部线程调度的基本模型,根据用户级线程和内核级线程的对应关系可以分为 1 对 1,N 对 1 以及 M 对 N 这三种模型,那么上述的 5 个 goroutine 在内核级线程上是怎么被分发的,这就是 Go语言的 goroutine 调度器决定的。
整个 goroutine
调度器的实现基于 GMP 的三级模型来实现。
M 和 P 存在一一对应的绑定关系。大致的结构图如下所示:
GMP 模型图如下:
Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。
通常情况下,我们在代码中执行 go func(){}
后,GMP 模型是如何工作的?通过一个详细的图来展示一下。
goroutine
M
都可以从全局队列中拉取 G
来执行)G
都必须在 M
上才可以被执行,M
和 P
存在一一绑定的关系,如果 M
绑定的 P
中存在可以被执行的 G
,则从 P
中拉取 G
来执行;如果 P
中为空,没有可执行的 G
,则 M
从全局队列中拉取;如果全局队列也为空,则从其他的 P
中拉取 G
G
的运行分配必要的资源,等待 CPU 的调度func(){}
整个 goroutine 调度器最重要的调度策略是:复用,避免频繁的资源创建和销毁,最大限度的提升系统的吞吐量和并发程度。这也是操作系统进行线程调度的终极目标。复用(reuse)也是很多「池化技术」的基础。
围绕着这一原则,goroutine 调度器在以下几个方面进行调度策略的优化。
Java
中的 ForkJoin Pool
的窃取机制同一原理,都是当线程 M
空闲时,从其他繁忙的队列 P
中"窃取"任务 G
过来执行,而不是销毁空闲的 M。因为线程的创建和销毁是需要消耗系统资源的,避免线程的频繁创建和销毁可以极大的提升系统的并发程度。M
会主动将 P
交接给其他空闲的 M
。另外,在 go 的 1.14 版本中,go 语言的技术团队尝试在调度器中添加了可抢占的技术. (https://github.com/golang/go/issues/24543)[https://github.com/golang/go/issues/24543]
抢占技术的出现一方面解决了线程 M 在执行计算密集型任务时长时间占用 CPU,导致与之绑定的 P 上的其他 G 得不到执行而造成的"饥饿现象";
另一方面,抢占技术的出现对 GC 来讲解决 GC 时可能出现的 deadLock,相关的 issue 见:关于 GC 时 tight loops 应该可以被抢占的讨论(https://github.com/golang/go/issues/10958)[https://github.com/golang/go/issues/10958]
特殊的 M0 和 G0
在 go 语言的早期,goroutine 调度器的模型并不是 GMP,而是 GM。整个调度器维护一个全局的 G 的等待队列,所有的 M 从这个全局的队列中拉取 G 来执行,在 go1.1 中将这种模型直接干掉,取而代之的是现在的 GMP 模型,在 GM 模型的基础上增加 P 局部队列。官方之所有这么这么做,原因有三:
总结,Go 调度器很轻量也很简单,足以撑起 goroutine 的调度工作,并且让 Go 具有了原生(强大)并发的能力。Go 调度本质是把大量的 goroutine 分配到少量线程上去执行,并利用多核并行,实现更强大的并发。