Go并发调度进阶
1. 调度器的基本设计原则和概念
我们首先了解一下调度器的设计原则及一些基本概念来建立对调度器较为宏观的认识。理解调度器涉及的主要概念包括以下三个:
- G: Goroutine,即我们在 Go 程序中使用 go 关键字创建的执行体;
- M: Machine,或 worker thread,即传统意义上进程的线程;
- P: Processor,即一种人为抽象的、用于执行 Go 代码的处理器。只有当 M 与一个 P 关联后才能执行 Go 代码。除非 M 发生阻塞或在进行系统调用时间过长时,没有与之关联的 P。
1. M工作线程的暂止和复始
运行时调度器的任务是给不同的工作线程 (worker thread) 分发可供运行的(ready-to-run)Goroutine。我们不妨设每个工作线程总是贪心的执行所有存在的 Goroutine,那么当运行进程中存在 n 个线程(M),且 每个 M 在某个时刻有且只能调度一个 G。
那么会有以下性质:
- 性质 1:当用户态代码创建了p(p>n)个G时,则必定存在p−n个 G 尚未被 M 调度执行;
- 性质 2:当用户态代码创建的q(q<n)时,则必定存在 n-q 个 M 不存在正在调度的 G。
这两条性质分别决定了工作线程的 暂止(park) 和 复始(unpark),复始就是解除暂止。
我们不难发现,调度器的设计需要在性质1和性质2之间进行权衡:即既要保持足够的运行工作线程来利用有效硬件并发资源,又要暂止过多的工作线程来节约 CPU 能耗。如果我们把调度器想象成一个系统,则寻找这个权衡的最优解意味着我们必须求解调度器系统中 每个 M 的状态,即系统的全局状态。这是非常困难的,不妨考虑以下两个难点:
难点 1: 在多个 M 之间不使用屏障的情况下,得出调度器中多个 M 的全局状态是不可能的。
我们都知道计算的局部性原理,为了利用这一原理,调度器所需调度的 G 都会被放在每个 M 自身对应的本地队列中。换句话说,每个 M 都无法直接观察到其他的 M 所具有的 G 的状态,存在多个 M 之间的共识问题。这本质上就是一个分布式系统。显然,每个 M 都能够连续的获取自身的状态,但当它需要获取整个系统的全局状态时却不容易, 原因在于我们没有一个能够让所有线程都同步的时钟。换句话说, 我们需要依赖屏障来保证多个 M 之间的全局状态同步。更进一步,在不使用屏障的情况下, 能否利用每个 M 在不同时间中记录的本地状态中计算出调度器的全局状态呢,答案是不可能的。
难点 2: 为了获得最佳的线程管理,我们必须得获得未来的信息,即当一个新的 G 即将就绪(ready)时,则不再停止一个工作线程。
举例来说,目前我们的调度器存在 4 个 M,并其中有 3 个 M 正在调度 G,则其中有 1 个 M 处于空闲状态。这时为了节约 CPU 能耗,我们希望对这个空闲的 M 进行暂止操作。但是,正当我们完成了对此 M 的暂止操作后, 用户态代码正好执行到了需要调度一个新的 G 时,我们又不得不将刚刚暂止的 M 重新启动,这无疑增加了开销。我们当然有理由希望,如果我们能知晓一个程序生命周期中所有的调度信息, 提前知晓什么时候适合对 M 进行暂止自然再好不过了。尽管我们能够对程序代码进行静态分析,但这显然是不可能的:考虑一个简单的 Web 服务端程序,每个用户请求 到达后会创建一个新的 G 交于调度器进行调度。但请求到达是一个随机过程,我们只能预测可能到达的请求数,而不能完整知晓所有的调度需求。
那么我们又应该如何设计一个通用且可扩展的调度器呢?我们很容易想到三种平凡的做法:
设计 1: 集中式管理所有状态
显然这种做法自然是不可取的,在多个并发实体之间集中管理所有状态这一共享资源,需要锁的支持, 当并发实体的数量增大时,将限制调度器的可扩展性。
设计 2: 每当需要就绪一个 G1 时,都让出一个 P,直接切换出 G2,再复始一个 M 来执行 G2
因为复始的 M 可能在下一个瞬间又没有调度任务,则会发生线程颠簸(thrashing),进而我们又需要暂止这个线程。另一方面,我们希望在相同的线程内保存维护 G,这种方式还会破坏计算的局部性原理。
设计 3: 任何时候当就绪一个 G、也存在一个空闲的 P 时,都复始一个额外的线程,不进行切换
因为这个额外线程会在没有检查任何工作的情况下立即进行暂止,最终导致大量 M 的暂止和复始行为,产生大量开销。
基于以上考虑,目前的 Go 的调度器实现中设计了工作线程的自旋(spinning)状态,放弃了上述三种设计:
- 如果一个工作线程的本地队列、全局运行队列或网络轮询器(netpoller)中均没有可调度的任务,则该线程成为自旋线程;
- 满足该条件、被复始的线程也被称为自旋线程,对于这种线程,运行时不做任何事情。
自旋线程在进行暂止之前,会尝试从任务队列中寻找任务。当发现任务时,则会切换成非自旋状态, 开始执行 Goroutine。而找到不到任务时,则进行暂止。
当一个 Goroutine 准备就绪时,会首先检查自旋线程的数量,而不是去复始一个新的线程。
如果最后一个自旋线程发现了任务并且停止自旋时,则复始一个新的自旋线程。这个方法消除了不合理的线程复始峰值,且同时保证最终的最大 CPU 并行度利用率。
我们可以通过下图来直观理解工作线程的状态转换:
如果存在空闲的 P,且存在暂止的 M,并就绪 G +------+ v | 执行 --> 自旋 --> 暂止 ^ | 自旋期间没有任务就将M暂止 +--------+ 如果发现task就执行 执行完成就自旋
总的来说,调度器的方式可以概括为:如果存在一个空闲的 P 并且没有自旋状态的工作线程 M,则当就绪一个 G 时,就复始一个额外的线程 M。这个方法消除了不合理的线程复始峰值,且同时保证最终的最大 CPU 并行度利用率。
因此,就绪(ready)一个 G 的通用流程为:
- 提交一个 G 到 per-P 的本地工作队列 // 后续文章探讨per-P
- 执行 StoreLoad 风格的写屏障 // x86架构的写屏障:
- 检查 sched.nmspinning 数量 // nm:n个M自旋的数量
而从自旋到非自旋转换的一般流程为:
- 减少 nmspinning 的数量
- 执行 StoreLoad 风格的写屏障
- 在所有 per-P 本地任务队列检查新的任务
当然,此种复杂性在全局任务队列中并不适用,因为当给一个全局队列提交任务时, 不进行线程的复始操作。
下篇从源码角度来分析下调度流程,感受不一样的底层设计魅力。