写在前面
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 调度器决定的。
GMP 模型
整个 goroutine
调度器的实现基于 GMP 的三级模型来实现。
- G:goroutine (go 代码)
- M:内核级线程,运行在操作系统的核心态。(在 Go 中支持最大的 M 的数量是 10000,但是操作系统中通常情况是不可以创建这么多的线程。)
- P:processor,可以理解成一个等待分发给 M 调度执行的 goroutine 队列。(P的个数是由 runtime 的 GOMAXPROCS 来决定的。)
M 和 P 存在一一对应的绑定关系。大致的结构图如下所示:
GMP 模型图如下:
- 全局队列(Global Queue):存放等待运行的 G。
- P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
- P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
- M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。
goroutine 之旅
通常情况下,我们在代码中执行 go func(){}
后,GMP 模型是如何工作的?通过一个详细的图来展示一下。
- 首先创建一个新的
goroutine
- 如果本地的局部队列中有足够的空间可以存放,则放入局部队列中;如果局部队列满,则放入一个全局队列(所有的
M
都可以从全局队列中拉取G
来执行) - 所有的
G
都必须在M
上才可以被执行,M
和P
存在一一绑定的关系,如果M
绑定的P
中存在可以被执行的G
,则从P
中拉取G
来执行;如果P
中为空,没有可执行的G
,则M
从全局队列中拉取;如果全局队列也为空,则从其他的P
中拉取G
- 为
G
的运行分配必要的资源,等待 CPU 的调度 - 分配到 CPU,执行
func(){}
调度策略
整个 goroutine 调度器最重要的调度策略是:复用,避免频繁的资源创建和销毁,最大限度的提升系统的吞吐量和并发程度。这也是操作系统进行线程调度的终极目标。复用(reuse)也是很多「池化技术」的基础。
围绕着这一原则,goroutine 调度器在以下几个方面进行调度策略的优化。
- 工作队列的窃取机制:这个跟
Java
中的ForkJoin Pool
的窃取机制同一原理,都是当线程M
空闲时,从其他繁忙的队列P
中"窃取"任务G
过来执行,而不是销毁空闲的 M。因为线程的创建和销毁是需要消耗系统资源的,避免线程的频繁创建和销毁可以极大的提升系统的并发程度。 - 交接机制:当线程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
- M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。
- G0 是每次启动一个 M 都会第一个创建的 goroutine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。
最开始的 MG 模型
在 go 语言的早期,goroutine 调度器的模型并不是 GMP,而是 GM。整个调度器维护一个全局的 G 的等待队列,所有的 M 从这个全局的队列中拉取 G 来执行,在 go1.1 中将这种模型直接干掉,取而代之的是现在的 GMP 模型,在 GM 模型的基础上增加 P 局部队列。官方之所有这么这么做,原因有三:
- 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
- M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。
- 系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
小结
总结,Go 调度器很轻量也很简单,足以撑起 goroutine 的调度工作,并且让 Go 具有了原生(强大)并发的能力。Go 调度本质是把大量的 goroutine 分配到少量线程上去执行,并利用多核并行,实现更强大的并发。
参考
- [Golang三关-典藏版] Golang 调度器 GMP 原理与调度全分析
- Go Scheduler 的 GMP 模型