写在前面
Go 为了自身 goroutine 执行和调度的效率,自身在 runtime 中实现了一套 goroutine 的调度器,下面通过一段简单的代码展示一下 Go 应用程序在运行时的 goroutine,方便大家更好的理解。
The Go scheduler is part of the Go runtime, and the Go runtime is built into your application
上面这段代码的输出为:5。说明当前这个应用程序中存在 goroutine 的数量是 5,事实上也符合我们的预期。那么问题来了,这 5 个 goroutine 作为操作系统用户态的基本调度单元是无法直接占用操作系统的资源来执行的,必须经过内核级线程的分发,这是操作系统内部线程调度的基本模型,根据用户级线程和内核级线程的对应关系可以分为 1 对 1,N 对 1 以及 M 对 N 这三种模型,那么上述的 5 个 goroutine 在内核级线程上是怎么被分发的,这就是 Go语言的 goroutine 调度器决定的。
GMP 模型
整个 goroutine 调度器的实现基于 GMP 的三级模型来实现。
- G:goroutine
- M:内核级线程,运行在操作系统的核心态。在 Go 中支持最大的 M 的数量是 10000,但是操作系统中通常情况是不可以创建这么多的线程。
- P:processor,可以理解成一个等待分发给 M 调度执行的 goroutine 队列。P的个数是由 runtime 的 GOMAXPROCS 来决定的。
M 和 P 存在一一对应的绑定关系。大致的结构图如下所示:
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]。
- 抢占技术的出现一方面解决了线程 M 在执行计算密集型任务时长时间占用 CPU,导致与之绑定的 P 上的其他 G 得不到执行而造成的"饥饿现象";
- 另一方面,抢占技术的出现对 GC 来讲解决 GC 时可能出现的 deadLock,相关的 issue 见:关于 GC 时 tight loops 应该可以被抢占的讨论
[https://github.com/golang/go/issues/10958]
。
最开始的 MG 模型
在 go 语言的早期,goroutine 调度器的模型并不是 GMP,而是 GM。整个调度器维护一个全局的 G 的等待队列,所有的 M 从这个全局的队列中拉取 G 来执行,在 go1.1 中将这种模型直接干掉,取而代之的是现在的 GMP 模型,在 GM 模型的基础上增加 P 局部队列。官方之所有这么这么做,原因有二:
- 全局的 G 等待队列,不同的M从队列里取 G 都需要加锁,锁的粒度很大,严重制约了系统并发能力的提升;
- 没有局部队列,那么当线程在执行 IO 密集型操作时,M 阻塞在 IO 操作上,对应的 G 也没有办法得到执行(GMP 中可以将 G 交接给其他的 M 执行),因此 GM 模型在应对 IO 密集型任务时性能表现低下。