Skip to main content

Go Scheduler

Go Scheduler

Processor

当 Go 程序启动时,它会为主机上识别的每个虚拟核心分配一个逻辑处理器(Processor)。如果处理器的每个物理核心有多个硬件线程(超线程),每个硬件线程将作为虚拟核心呈现给 Go 程序。

在 Go 的 GMP 调度模型中,Processor 是 Go 调度器的工作单元,负责调度 Goroutines 执行。Go 会将每个虚拟核心视作一个独立的逻辑处理器,并为每个虚拟核心分配一个 Processor。

图1

如上的 CPU 有 10 个物理核心,如果每个物理核心支持 2 个硬件线程,那么操作系统会将这 10 个物理核心显示为 20 个虚拟核心,因此 Go 程序理论上将有 20 个虚拟核心可用于执行任务。

Go 运行时可通过 runtime.NumCPU() 获取当前进程可用的逻辑 CPU 数量,但并不是全部的核心数:

package main

import (
"fmt"
"runtime"
)

func main() {
fmt.Println(runtime.NumCPU()) // 10
}

每个 Processor 被分配一个 Machine(线程),这个线程仍然由操作系统管理,操作系统仍然负责将线程放置在核心上执行。当运行一个 Go 程序时,有 runtime.NumCPU() 个线程可用于执行程序,每个线程都单独附加到一个 Processor。

每个 Go 程序也会被分配一个初始的 Goroutine,这是 Go 程序的执行路径。Goroutine 本质上是一个协程,可以将 Goroutine 视为应用级线程,它们在许多方面与操作系统线程相似。正如操作系统线程在核心上进行上下文切换,Goroutines 也在 Machine 上进行上下文切换。

Queue

在 Go 调度器中有两个不同的运行队列:全局运行队列(GRQ)和本地运行队列(LRQ)。

每个 Processor 都会被分配一个 LRQ,用于管理分配给该 Processor 执行的 Goroutines。这些 Goroutines 轮流在分配给该 Processor 的 Machine 上进行上下文切换执行。

GRQ 是用于尚未分配给 Processor 的 Goroutines,GRQ 中的 Goroutines 最终都会被分配到某个 Processor 的 LRQ。

图2

Scheduler

操作系统调度器是一个抢占式调度器,这意味着无法预测调度器在任何给定时间会做什么,内核正在做出决策,一切都是非确定性的。在操作系统上运行的应用程序无法控制内核内部的调度情况,除非利用像原子指令和互斥锁调用这样的同步原语。

Go 调度器是 Go 运行时的一部分,而 Go 运行时是内置于应用程序中的。这意味着 Go 调度器在用户空间中运行,位于内核之上。目前 Go 调度器的实现是协作式调度器,但也支持抢占式调度器,调度器需要在代码中的安全点发生的明确定义的用户空间事件来做出调度决策。

note

在 Go 1.13 及以前,调度器采用的是协作式调度(Cooperative Scheduling),即 Goroutine 主动让出 CPU,调度器才会进行调度。由于 Goroutine 需要主动让出 CPU,意味着:

  • 计算密集型任务可能长期占用 CPU,导致其他 Goroutine 迟迟得不到执行。
  • 长时间运行的 Goroutine 可能导致整个调度系统不公平。

从 Go 1.14 开始,Go 引入了 抢占式调度(Preemptive Scheduling),调度器可以主动打断运行中的 Goroutine,从而更好地利用 CPU 资源。但其仍然依赖 Safe Point,不能完全打断所有 Goroutine。

Go 的调度器的精彩之处在于它看起来和感觉上都是抢占式的,无法预测 Go 调度器将会做什么,因为调度器的决策并不掌握在开发者手中,而是在 Go 运行时。

State

Goroutines 有三个状态,这些状态决定了 Go 调度器如何调度给定的 Goroutine。Goroutine 可以处于三种状态之一:等待、可运行或执行。

  • 等待:Goroutine 已停止并在等待某些事情以便继续。这可能是由于等待系统调用、同步调用、原子和互斥操作等原因。这些类型的延迟是导致性能不佳的根本原因。
  • 可运行:Goroutine 想要在一个 Machine 上获得时间,以便执行其分配的指令。如果有很多想要时间的 Goroutine,Goroutine 就需要更长时间才能获得时间。此外,随着更多 Goroutine 争夺时间,任何给定 Goroutine 获得的时间也会缩短。这种调度延迟类型也可能是导致性能不佳的原因。
  • 执行中:Goroutine 已被放置在 Machine 上并正在执行其指令,与应用程序相关的工作正在进行。

Context Switching

Go 调度器需要在代码中的安全点发生的明确定义的用户空间事件来进行上下文切换。这些事件和安全点在函数调用中表现出来,函数调用对 Go 调度器的健康至关重要。

在 Go 程序中,有四类事件的发生允许调度器做出调度决策,但这并不意味着它总是会在这些事件之一上发生,仅意味着调度器获得了机会。

go

关键字 go 是创建 Goroutines 的方式。一旦创建了新的 Goroutine,就给调度器提供了一个做出调度决策的机会。

GC

垃圾回收器使用自己的 Goroutines 运行,这些 Goroutines 需要在 Machine 上获得运行的时间。这导致 GC 产生了很多调度混乱。然而,调度器对 Goroutine 的行为非常聪明,它将利用这种智能做出明智的决策。一个决策是,在垃圾回收期间,将需要访问堆的 Goroutine 与那些不访问堆的 Goroutine 进行上下文切换。当 GC 运行时,会做出很多调度决策。

System calls

如果一个 Goroutine 进行系统调用导致该 Goroutine 阻塞 Machine,调度器可能将该 Goroutine 从 Machine 上切换出去,并将一个新的 Goroutine 切换到同一个 Machine 上。但有时也可能需要一个新的 Machine 来继续执行在 Processor 中排队的 Goroutine。

Synchronization

如果原子、互斥或通道操作调用会导致 Goroutine 阻塞,调度器可以上下文切换一个新的 Goroutine 来运行。一旦 Goroutine 可以再次运行,它可以被重新排队,并最终在 Machine 上被上下文切换回来。

异步系统调用

当运行的操作系统能够异步处理系统调用时,可以使用称为网络轮询器的东西来更有效地处理系统调用。这是通过在这些操作系统中使用 kqueue(MacOS)、epoll(Linux)或 iocp(Windows)来实现的。

通过使用网络轮询器处理网络系统调用,调度器可以防止 Goroutines 在进行这些系统调用时阻塞 Machine。这有助于保持 Machine 可用,以便在 Processor 的 LRQ 中执行其他 Goroutines,而无需创建新的 Machine,减少操作系统的调度负载。

图3

如图3,G1 正在 M 上执行,此时还有 3 个 Goroutines 在 LRQ 中等待获得 M 的时间。网络轮询器处于空闲状态,没有任何任务。

图4

如图4,G1 想要进行网络系统调用,因此 G1 被移至网络轮询器并处理异步网络系统调用。一旦 G1 被移至网络轮询器,M 就可以执行来自 LRQ 的不同 Goroutine。在这种情况下,G2 在 M 上进行上下文切换。

图5

如图5,当异步网络系统调用由网络轮询器完成,G1 被移回到 P 的 LRQ。一旦 G1 可以在 M 上进行上下文切换,它负责的 Go 相关代码就可以再次执行。这里的最大优势是,执行网络系统调用时不需要额外的 M,因为网络轮询器有一个操作系统线程,并且它正在处理一个高效的事件循环。

同步系统调用

当 Goroutine 想要进行无法异步完成的系统调用时会发生什么?在这种情况下,网络轮询器无法使用,进行系统调用的 Goroutine 将会阻塞 Machine,没有办法防止这种情况发生。

一个常见的示例是同步系统调用(如文件 I/O)会导致 Machine 阻塞的情况。

图6

如图6, G1 将进行一个会阻塞 M1 的同步系统调用。

图7

如图7,调度器能够识别出 G1 导致 M1 阻塞。此时,调度器将 M1 从 P 中分离,阻塞的 G1 仍然附着在 M1 上面。然后,调度器引入一个新的 M2 来服务 P。此时,可以从 LRQ 中选择 G2 并在 M2 上进行上下文切换。如果由于先前的交换已经存在一个 M,则此过渡比创建一个新的 M 更快。

图8

如图8,当 G1 发出的阻塞系统调用完成,可以重新进入 LRQ,并再次由 P 调度。M1 随后被放置在一旁以备将来使用,如果这种情况需要再次发生。

Work Stealing

调度器也是一个工作窃取调度器,这有助于保持调度的高效。用户最不希望发生的一件事情是 Machine 进入等待状态,因为一旦发生,操作系统将把 Machine 从核心上上下文切换出去。这意味着 Processor 无法完成任何工作,即使有一个 Goroutine 处于可运行状态,直到 Machine 被上下文切换回核心。工作窃取还有助于在所有 Processor 之间平衡 Goroutines,从而使工作更好地分配并更高效地完成。

图9

如图9,有一个多线程的 Go 程序,其中两个 P 分别服务于四个 Goroutine,并且 GRQ 中有一个单独的 Goroutine。如果其中一个 P 快速服务于它的所有 Goroutine,会发生什么?

图10

如图10,P1 没有更多的 Goroutines 可以执行。但是在 P2 的 LRQ 和 GRQ 中都有处于可运行状态的 Goroutines。这是 P1 需要窃取工作的时候。

note

Work Stealing 规则如下:

  • 检查 GRQ 中可运行的 Goroutine。
  • 如果未找到,则检查 LRQ。
  • 如果仍未找到,则尝试从其他 Processor 中窃取。
  • 如果仍未找到,则再次检查 GRQ。
  • 以此重复检查。

根据如上的规则,P1 需要检查 P2 在其 LRQ 中的 Goroutines,并取出一半。

图11

如图12,一半的 Goroutines 来自 P2,现在 P1 可以执行这些 Goroutines。

此时,如果 P2 完成了对所有 Goroutines,而 P1 的 LRQ 中没有剩余的内容,会发生什么?

图12

如图12,P2 完成了所有工作,现在需要窃取一些。首先,它会查看 P1 的 LRQ,如果没有找到任何 Goroutines,接下来会查看 GRQ,此时会找到 G9。

图13

如图13,P2 从 GRQ 中窃取了 G9 并开始执行。这种工作窃取的好处在于,它使得 M 能够保持忙碌而不至于闲置。这种工作窃取在内部被视为让 M 处于旋转状态。

总结

Go 调度器在设计中充分考虑了操作系统和硬件工作原理的复杂性,真的非常惊人。将 I/O/阻塞工作转换为操作系统层面的 CPU 绑定工作,使我们在利用更多 CPU 能力的过程中获得了很大的胜利。这就是为什么你不需要比虚拟核心数量更多的操作系统线程。你可以合理预期,仅凭每个虚拟核心一个操作系统线程就能完成所有工作(CPU 和 I/O/阻塞绑定)。这样做对于网络应用程序和其他不需要阻塞操作系统线程的系统调用的应用程序是可行的。

作为开发者,仍然需要了解应用程序在处理哪些类型的工作,不能创建无限数量的 Goroutines 并期望获得惊人的性能。少即是多,通过理解这些 Go 调度器的语义,可以做出更好的工程决策。