Go GPM 模型
基本概念
- 普通栈:需要调度的 Goroutine 组成的函数栈,是可增长的栈,因为 Goroutine 可以越开越多;
- 线程栈:需要将 Goroutine 放置到线程上的 M 们组成,实质上 M 也是由 Goroutine 生成的,线程栈大小固定(设置了 M 的数量)。所有调度相关的代码,会先切换到该 Goroutine 的栈中再执行。也就是说线程的栈也是用的 G 实现,而不是使用的 OS 的;
- 全局队列:该队列存储的 G 将被所有的 M 全局共享,为保证数据竞争问题,需加锁处理;
- 本地队列:该队列存储数据资源相同的任务,每个本地队列都会绑定一个 M ,指定其完成任务,没有数据竞争,无需加锁处理,处理速度远高于全局队列;
- 上下文切换:
- 对于代码中某个值来说,上下文是指这个值所在的局部(全局)作用域对象;
- 对于进程而言,上下文就是进程执行时的环境,具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存(堆栈)信息等;
- 线程清理:由于每个 P 都需要绑定一个 M 进行任务执行,所以当清理线程的时候,只需要将 P 释放,即解除绑定(M 就没有任务)即可。P 被释放主要由两种情况:
- 主动释放:最典型的例子是,当执行 G 任务时有系统调用,当发生系统调用时 M 会处于阻塞状态。调度器会设置一个超时时间,当超时时会将 P 释放;
- 被动释放:如果发生系统调用,有一个专门监控程序,进行扫描当前处于阻塞的 PM 组合。当超过系统程序设置的超时时间,会自动将 P 资源抢走。去执行队列的其它 G 任务;
调度器演进
Go 程序是用户层程序,它本身就是整体运行在一个或多个操作系统线程上的。所以 Goroutine 们要竞争的「CPU」资源就是操作系统线程。因此,调度器的任务就是将 Goroutine 按照一定算法放到不同的操作系统线程中去执行。
- 单线程调度器 - 0.x
- 包含少量的代码;
- 程序中只能存在一个活跃线程,由 G-M 模型组成;
- 多线程调度器 - 1.0
- 允许运行多线程的程序;
- 全局锁导致竞争严重;
- 任务窃取调度器 - 1.1
- 引入了处理器 P,构成了目前的 G-M-P 模型;
- 在处理器 P 的基础上实现了基于工作窃取的调度器;
- 在某些情况下,Goroutine 不会让出线程,进而造成饥饿问题;
- 时间过长的垃圾回收(STW 问题)会导致程序长时间无法工作;
- 抢占式调度器 - 1.2~至今
- 基于协作的抢占式调度器 - 1.2~1.3
- 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;
- Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停;
- 基于信号的抢占式调度器 - 1.14~至今
- 实现基于信号的真抢占式调度;
- 垃圾回收在扫描栈时会触发抢占调度;
- 抢占的时间点不够多,还不能覆盖全部的边缘情况;
- 基于协作的抢占式调度器 - 1.2~1.3
- 非均匀存储访问(NUMA - Non-Uniform Memory Access)调度器(仅有提案)
- 对运行时的各种资源进行分区;
- 实现非常复杂,未提上日程;
德米特里·维尤科夫 - Dmitry Vyukov 在其 Scalable Go Scheduler Design Doc 一文中指出了 G-M 模型的一个重要不足:限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。
为了解决这些问题,德米特里·维尤科夫又亲自操刀改进了 Go 调度器,在 Go 1.1 版本中实现了 GPM 调度模型和 Work Stealing 算法,这个模型一直沿用至今。模型如下图所示:
|
|
GPM 模型的实现算是 Go 调度器的一大进步,但调度器仍然有一个令人头疼的问题,那就是不支持抢占式调度,这导致一旦某个 G 中出现死循环的代码逻辑,那么 G 将永久占用分配给它的 P 和 M,而位于同一个 P 中的其他 G 将得不到调度,出现「饿死」的情况。
更为严重的是,当只有一个 P(GOMAXPROCS=1)时,整个 Go 程序中的其他 G 都将「饿死」。于是德米特里·维尤科夫又提出了 Go Preemptive Scheduler Design 并在 Go 1.2 中实现了 基于协作的抢占式 调度。
抢占式调度的原理:Go 编译器在每个函数或方法的入口处加上了一段额外的代码 (runtime.morestack_noctxt
),让运行时有机会在这段代码中检查是否需要执行抢占调度。
这种解决方案只能说局部解决了「饿死」问题,只在有函数调用的地方才能插入「抢占」代码(埋点),对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占。比如,死循环等并没有给编译器插入抢占代码的机会,这就会导致 GC 在等待所有 Goroutine 停止时的等待时间过长,从而 导致 GC 延迟,内存占用瞬间冲高;甚至在一些特殊情况下,导致在 STW 时死锁。
为了解决这些问题,Go 在 1.14 版本中接受了 奥斯汀·克莱门茨(Austin Clements) 的提案,增加了对非协作的抢占式调度的支持,这种抢占式调度是基于系统信号的,也就是通过向线程发送信号的方式来抢占正在运行的 Goroutine。除了这些大的迭代外,Goroutine 的调度器还有一些小的优化改动,比如通过文件 I/O Poller 减少 M 的阻塞等。
Go 运行时已经实现了 Netpoller,这使得即便 G 发起网络 I/O 操作,也不会导致 M 被阻塞(仅阻塞 G),也就不会导致大量线程(M)被创建出来。但是对于文件 I/O 操作来说,一旦阻塞,那么线程(M)将进入挂起状态,等待 I/O 返回后被唤醒。这种情况下 P 将与挂起的 M 分离,再选择一个处于空闲状态(idle)的 M。如果此时没有空闲的 M,就会新创建一个 M(线程),所以,这种情况下,大量 I/O 操作仍然会导致大量线程被创建。
为了解决这个问题,Go 开发团队的 伊恩·兰斯·泰勒(Ian Lance Taylor) 在 Go 1.9 中增加了一个针对文件 I/O 的 Poller 的功能,这个功能可以像 Netpoller 那样,在 G 操作那些支持监听(pollable)的文件描述符时,仅会阻塞 G,而不会阻塞 M。不过这个功能依然不能对常规文件有效,常规文件是不支持监听的(pollable)。但对于 Go 调度器而言,这也算是一个不小的进步了。
从 Go 1.2 以后,Go 调度器就一直稳定在 GPM 调度模型上,尽管有各种优化和改进,但也都是基于这个模型之上的。德米特里·维尤科夫在 2014 年 9 月提出了一个新的设计草案文档:NUMA‐aware scheduler for Go,作为对未来 Goroutine 调度器演进方向的一个提议,不过至今似乎这个提议也没有列入开发计划。
GPM 调度模型
GPM 代表了三个角色,分别是 Goroutine、Processor、Machine。
- Goroutine:携带任务,使用
go
关键字创建的执行体,它对应一个结构体g
,结构体里保存了 Goroutine 的堆栈信息,即并发任务状态。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行,而且 G 对象是可以重用的。 - Processor:分配任务,表示处理器,可以被看做运行在线程上的本地调度器,对 G 来说,P 相当于 CPU 核,G 只有绑定到 P(在 P 的
local runq
中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态,任务队列等,有了它才能建立 G、M 的联系。 - Machine:寻找任务,表示操作系统的线程,OS 线程抽象,负责调度任务,和某个有效的 P 绑定,从 P 的
runq
中不断取出 G,切换到 G 的执行栈上并执行 G 的函数,调用goexit
做清理工作并回到 M,如此反复。M 本身不具备执行状态,在需要任务切换时,M 将堆栈状态写回 G,任何其它 M 都能据此恢复执行。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。
注意:
- P 的个数由 GOMAXPROCS 指定,是固定的,因此限制最大并发数。
- M 的个数是不定的,由 Go Runtime 调整,默认最大限制为 10000 个。
Go 使用协程的几个主要原因:
- 内核线程创建与切换太重:创建和切换都要进入到内核态,进入到内核态开销较大,性能代价大,而协程切换不需要进入到内核态。
- 线程内存使用太重:创建一个内核线程默认栈大小为 8M,而创建一个用户线程即 Goroutine 只需要 2K 内存,当 Goroutine 栈不够用时也会自动增加。
- Goroutine 的调度更灵活,所有协程的调度、切换都发生在用户态,没有创建线程的开销,即使出现某个协程运行阻塞时,线程上的其他协程也会被调度到其他线程上运行。
基本调度过程:
- 创建一个 G 对象。
- 将 G 保存至 P 中。
- P 去唤醒一个 M,然后继续执行它的执行序(分配下一个 G)。
- M 寻找空闲的 P,读取该 P 要分配的 G。
- 接下来 M 执行一个调度循环,调用 G → 执行 → 清理线程 → 继续找新的 G 执行。
G - Goroutine
Goroutine 是 Go 语言调度器中待执行的任务,它在运行时调度器中的地位与线程在操作系统中差不多,但是它占用了更小的内存空间,也降低了上下文切换的开销。
Goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态提供的线程,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的 CPU。
G 的调度方式:
- 抢占式调度:如果某个 G 没有进行系统调用(
syscall
)、没有进行 IO 操作、没有阻塞在一个channel
操作上,那么,调度器通过抢占式调度让当前 G 停下来并调度下一个可运行的 G。除非极端的无限循环,否则只要 G 调用函数,Go 运行时就有了抢占 G 的机会。Go 程序启动时,运行时会去启动一个名为sysmon
的 M(一般称为监控线程),这个 M 的特殊之处在于它不需要绑定 P 就可以运行(以g0
这个 G 的形式存在)。
|
|
channel
阻塞或网络 IO 情况下的调度:
如果 G 被阻塞在某个 channel
操作或网络 IO 操作上时,G 会被放置到某个等待(wait
)队列中,而 M 会尝试运行 P 的下一个可运行的 G。如果这个时候 P 没有可运行的 G 供 M 运行,那么 M 将解绑 P,并进入挂起状态。当 IO 操作完成或 channel
操作完成,在等待队列中的 G 会被唤醒,标记为可运行(runnable
),并被放入到某 P 的队列中,绑定一个 M 后继续执行。
- 系统调用阻塞情况下的调度:
如果 G 被阻塞在某个系统调用(system call
)上,那么不光 G 会阻塞,执行这个 G 的 M 也会解绑 P,与 G 一起进入挂起状态。如果此时有空闲的 M,那么 P 就会和它绑定,并继续执行其他 G;如果没有空闲的 M,但仍然有其他 G 要去执行,那么 Go 运行时就会创建一个新 M(线程)。当系统调用返回后,阻塞在这个系统调用上的 G 会尝试获取一个可用的 P,如果没有可用的 P,那么 G 会被标记为 runnable
,之前的那个挂起的 M 将再次进入挂起状态。
P - Processor
调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。
因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS,这些处理器会绑定到不同的内核线程上。
M - Machine
Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。
在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数,我们也可以在程序中使用 runtime.GOMAXPROCS
来改变最大的活跃线程数。
在默认情况下,一个四核机器会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的 runtime.m 结构体。
在大多数情况下,我们都会使用 Go 的默认设置,也就是线程数等于 CPU 数,默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少很多额外开销。
M 是 Go 代码运行的真实载体,包括 Goroutine 调度器自身的逻辑也是在 M 中运行的。
注意:如果 G 被阻塞在某个 channel
操作或网络 IO 操作上时,M 可以不被阻塞,这避免了大量创建 M 导致的开销。但如果 G 因慢系统调用而阻塞,那么 M 也会一起阻塞,但在阻塞前会与 P 解绑,P 会尝试与其他 M 绑定继续运行其他 G。但若没有现成的 M,Go 运行时会建立新的 M,这也是系统调用可能导致系统线程数量增加的原因。