前沿

你的代码会被翻译成一系列机器指令,然后依次执行。为了实现这一点,操作系统使用线程(Thread)的概念。线程负责顺序执行分配给它的指令。一直执行没有指令为止。这就是我将线程称为“执行通路”的原因。

你运行的每个程序都会创建一个进程,每个进程都有一个初始线程。而后线程可以创建更多的线程。每个线程互相独立地运行着,调度是在线程级别而不是在进程级别做出的。线程可以并发运行(每个线程在单个内核上轮流运行),也可以并行运行(每个线程在不同的内核上同时运行)。线程还维护自己的状态,以便安全、本地和独立地执行它们的指令。 如果有线程可以执行,操作系统调度器就会调度它到空闲的 CPU 核心上去执行,保证 CPU 不闲着。它还必须模拟一个假象,即所有可以执行的线程都在同时地执行着。在这个过程中,调度器还会根据优先级不同选择线程执行的先后顺序,高优先级的先执行,低优先级的后执行。当然,低优先级的线程也不会被饿着。调度器还需要通过快速而明智的决策尽可能减少调度延迟。

如果你之前看过 Go 程序的堆栈跟踪,那么你可能已经注意到了每行末尾的这些十六进制数字。如下:

1
2
3
4
5
goroutine 1 [running]:
   main.example(0xc000042748, 0x2, 0x4, 0x106abae, 0x5, 0xa)
       stack_trace/example1/example1.go:13 +0x39                 <- LOOK HERE
   main.main()
       stack_trace/example1/example1.go:8 +0x72                  <- LOOK HERE

+0x39PC 偏移量表示在程序没中断的情况下,线程即将执行的下一条指令。 如果控制权回到主函数中,则主函数中的下一条指令是0+x72PC 偏移量。


线程可以处于三种状态之一: 等待中(Waiting)、待执行(Runnable)或执行中(Executing)。


via


开始

每个 P 都被分配一个系统线程 M 。M 代表机器(machine),它仍然是由操作系统管理的,操作系统负责将线程放在一个核心上执行。这意味着当在我的机器上运行 Go 程序时,有 8 个线程可以执行我的工作,每个线程单独连接到一个 P。

每个 Go 程序都有一个初始 G。G 代表 Go 协程(Goroutine),它是 Go 程序的执行路径。Goroutine 本质上是一个 Coroutine,但因为是 Go 语言,所以把字母 “C” 换成了 “G”,我们得到了这个词。你可以将 Goroutines 看作是应用程序级别的线程,它在许多方面与系统线程都相似。正如系统线程在物理核心上进行上下文切换一样,Goroutines 在 M 上进行上下文切换。

最后一个重点是运行队列。Go 调度器中有两个不同的运行队列:全局运行队列(GRQ)和本地运行队列(LRQ)。每个 P 都有一个LRQ,用于管理分配给在P的上下文中执行的 Goroutines,这些 Goroutine 轮流被和P绑定的M进行上下文切换。GRQ 适用于尚未分配给P的 Goroutines。其中有一个过程是将 Goroutines 从 GRQ 转移到 LRQ,我们将在稍后讨论。

下面图示展示了它们之间的关系:

OS 调度器是一个抢占式调度器 从本质上看,这意味着你无法预测调度程序在任何给定时间将执行的操作。由内核做决定,一切都是不确定的。在操作系统之上运行的应用程序无法通过调度控制内核内部发生的事情,除非它们利用像 atomic 指令 和 mutex 调用之类的同步原语。 Go 调度器是 Go 运行时的一部分,Go 运行时内置在应用程序中。这意味着 Go 调度器在内核之上的用户空间中运行。Go 调度器的当前实现不是抢占式调度器,而是协作式调度器。作为一个协作的调度器,意味着调度器需要明确定义用户空间事件,这些事件发生在代码中的安全点,以做出调度决策。 Go 协作式调度器的优点在于它看起来和感觉上都是抢占式的。你无法预测 Go 调度器将会执行的操作。这是因为这个协作调度器的决策不掌握在开发人员手中,而是在 Go 运行时。将 Go 调度器视为抢占式调度器是非常重要的,并且由于调度程序是非确定性的,因此这并不是一件容易的事。

在 Go 程序中有四类事件,它们允许调度器做出调度决策:

  • 使用关键字 go 关键字 go 是用来创建 Goroutines 的。一旦创建了新的 Goroutine,它就为调度器做出调度决策提供了机会。

  • 垃圾回收 由于 GC 使用自己的 Goroutine 运行,所以这些 Goroutine 需要在 M 上运行的时间片。这会导致 GC 产生大量的调度混乱。但是,调度程序非常聪明地了解 Goroutine 正在做什么,它将智能地做出一些决策。

  • 系统调用 如果 Goroutine 进行系统调用,那么会导致这个 Goroutine 阻塞当前M,有时调度器能够将 Goroutine 从M换出并将新的 Goroutine 换入。然而,有时需要新的M继续执行在P中排队的 Goroutines。这是如何工作的将在下一节中更详细地解释。

  • 同步和编配 如果原子、互斥量或通道操作调用将导致 Goroutine 阻塞,调度器可以将之切换到一个新的 Goroutine 去运行。一旦 Goroutine 可以再次运行,它就可以重新排队,并最终在M上切换回来。