【译】Golang中的调度(2):Go调度器 - Go Scheduler

为了更好理解Go调度器的内在机制,我会以三个部分的内容分别进行阐述,链接如下:

  1. Golang中的调度(1):OS调度器 - OS Scheduler
  2. Golang中的调度(2):Go调度器 - Go Scheduler
  3. Golang中的调度(3):并发- Concurrency

本部分内容主要讨论Go调度器。

引言

在本系列文章的第一部分,我阐述了在OS调度器中各个方面的知识,这些内容对于理解与欣赏Go调度器的机制是非常重要的。在本部分内容,我将在语义级别上阐述Go调度器的工作模式,并专注于较高视角下的行为。Go调度器是一个复杂的系统,一些细节并不重要,重要的是要有一个良好的模型来表明调度器的工作方式和行为表象,这将有助你作出更好的工程决策。

开始你的程序

当你的Go程序启动时,主机会为每个标识了的虚拟内核分配一个逻辑处理器(P)。如果你的处理器每个物理内核具有多个硬件线程(Hyper-Threading超线程),而每个硬件线程将会作为一个虚拟内核提供给你的Go程序使用。为了更好的理解这一点,提供一下我的MacBook Pro的系统报告。

图 1

你可以看到我的Mac有一个四内核的处理器。但该报告中并未公开本Mac上每个物理内核上的硬件线程数。英特尔酷睿i7处理器具有超线程技术,这意味着每个物理内核具有两个硬件线程。这意味着当并行执行操作系统线程时,将有8个虚拟内核提供给Go程序使用。为了验证这一点,请看以下程序:

表 1

package main

import (
	"fmt"
	"runtime"
)

func main() {

    // NumCPU returns the number of logical
    // CPUs usable by the current process.
    fmt.Println(runtime.NumCPU())
}

当在我本机电脑运行以上程序时,NumCPU()函数的执行结果是8。在我电脑上运行的任何Go程序都会是8个处理器(8 P’s)。

每个P都分配有一个操作系统线程(M),该线程由操作系统管理,并且操作系统仍负责将线程置于内核上以执行,正如上部分内容所诉。这意味着当在我的机器上运行一个Go程序,我将能有8个线程去执行任务,每一个都分别对应到一个P上。

每个Go程序启动时都会有一个初始Goroutine(G),它是Go程序的执行路径。一个Goroutine从本质上讲,它是一个协程(Coroutine)。但这是Go,因此我们用G字母代替了字母C,并且使用了新的单词Goroutine。你可以将Goroutine视为应用程序级的线程,同时它与操作系统线程在很多方面也很类似。就像操作系统线程的上下文切换在内核上,Goroutine的上下文切换是在M上。

最后一块让人困惑的是运行队列。在Go调度器中有两个不同的运行队列:全局运行队列Global Run Queue(GRQ)和本地运行队列Local Run Queue(LRQ)。每个P都有一个LRQ,该LRQ管理分配给P的上下文中执行的Goroutines。这些Goroutine被分配给P的M轮流进行上下文切换。GRQ指的是尚未分配给某个P的Goroutines。从GRQ转移到LRQ的过程,我们将在稍后进行讨论。

图2提供了所有上述这些模型组件的图形表示。

图 2

协作式调度器

正如我们在第一部分讨论的一样,OS调度策略是抢占式的。从本质上,这意味着你无法在任何给定时间上预测调度器将要执行的操作。内核在做决策,但这一切都是不确定的。运行在操作系统之上的程序无法通过调度来控制内核的决策,除非在程序中加入诸如原子指令和互斥调用之类的同步原语。

Go调度器是Go runtime的一部分,且Go runtime已经内置于Go程序中。这意味着Go调度器运行在用户态,而不是内核态。当前Go调度器的实现不是抢占式,而是协作式的。协作式调度意味着调度器在做调度决策时,需要明确定义的用户态事件,这些事件发生在代码中的安全点。

Go协作式调度器的出色之处就在于它看起来是抢占式的:你无法预测Go调度器将要执行的操作。这是因为协作调度器的决策权并不在开发者手中,而是在Go runtime上。把Go调度器看作是抢占式的,因为它的调度是不确定的,这是很重要的,并且这没什么大不了的。

Goroutine状态

就和线程一样,Goroutine具有相同的三种状态。它决定了Go调度器在Goroutine中扮演的角色。一个Goroutine可以是三种状态之一:等待、就绪和运行。

  • 等待(waiting):该状态表明了Goroutine停止,并等待继续运行。这个状态可由以下情况引起:等待硬件响应(磁盘、网络),操作系统(系统调用),同步机制(原子操作,互斥操作)。这些类型的延迟是程序性能不佳的主要原因。
  • 就绪(runnable):该状态表明Goroutine想要从M获取运行时间来执行分配其上的机器指令集。如果有大量的Goroutine想要获取运行时间,那么这些Goroutine需要等待更久。而且,随着更多Goroutine争夺时间,任何给定Goroutine获得的时间都将缩短。这种类型的调度延迟也是性能下降的原因。
  • 运行(executing):该状态表明Goroutine已置于M上并正在执行其机器指令集。与程序相关的工作正在被执行,这是任何Goroutine都想处于的状态。

上下文切换

Go 调度器需要明确定义的用户态事件,该事件发生在代码中的安全点,这以便于上下文切换。这些事件中的安全点表现在函数调用。函数调用对于Go调度器的运行状况至关重要。如今(使用Go1.11或更低版本),如果你运行不进行函数调用的任何迭代次数很大的循环(tight loops),将导致调度器和垃圾回收的时延(latency)。在合理的时间内产生函数调用至关重要。

注意:对于1.12的提议已被接受,即可以在Go调度器中采用非协作式抢占技术,以允许对tight loops进行抢占。

在Go程序中存在四种事件能使得调度器做出调度决策,但是这并不意味着发生这些事件时总会产生调度行为,它只是表明调度器有机会调度。

  • 关键字go
  • 垃圾回收
  • 系统调用
  • 同步与编排(Orchestration)

关键字 go

关键字go表明创建新的Goroutine,一旦建立新的Goroutine,调度器就获得做调度决策的机会。

垃圾回收 GC

因为垃圾回收运行在它自己的Goroutine集,而这些Goroutine需要获得M上的时间片去运行,这就导致GC会产生大量的调度噪音。然而,Go调度器十分的聪明,它清楚Goroutine在做什么,并会利用所收集的信息做出明智的决策。一个明智的例子就是在GC时,将一个想要获取堆数据的Goroutine的与那些不接触堆数据的Groutine进行上下文切换。实际上,当GC时,调度器需要做出大量的调度决策。

系统调用

当Goroutine进行系统调用,那么它将阻塞其M。有时调度器通过上下文切换将Goroutine从M上取下并置一个新的Goroutine在该M之上。然而,有时需要一个新的M来继续执行P中的Goroutine队列。这些内容会在下文进行更详细地说明。

同步与编排

如果原子、互斥或管道(channel)操作阻塞了Goroutine,那么调度器可通过上下文切换一个新的Goroutine运行。一旦被阻塞了的Goroutine可继续运行,它会被重新入队列,并最终通过上下文切换回M上执行。

异步系统调用

操作系统有能力异步地处理系统调用,即能利用网络轮询器(network poller)来更高效地处理系统调用。例如MacOS的kqueue,Linux的epoll以及Windows中的iocp。

我们今天所使用的许多操作系统都可以异步处理基于网络的系统调用。这就是网络轮询器的得名由来,因为它的主要用途就是处理网络操作。通过利用网络轮询器进行网络系统调用,调度器能够防止Goroutine在进行这些系统调用时阻塞M。这有助于使M保持可用,以执行P的LRQ中的其他Goroutine,而无需创建新的M,这减少了OS调度器的调度负载。

了解工作原理的最佳方式是看一个例子。

图 3

图3显示了我们的基本调度图。G1正在M上执行,LRQ中还有3个Goroutine等待它们在M上的执行时间片。网络轮询器被闲置了。

图 4

图4中,G1希望进行网络系统调用,因此G1移至了网络轮询器并处理了异步网络系统调用。一旦G1被移至网络轮询器,这时M就可用于执行LRQ上其他的Groutine,在这个例子中,G2通过上下文切换置于M。

图 5

图5中,网络轮询器完成了异步网络系统调用,并将G1移回至P的LRQ中。一旦G1被上下文切换回M上,它对应的Go相关代码就可以再次被执行。这里最大的好处是:执行网络系统调用不需要额外的M。网络轮询器拥有OS线程,并且它正在处理有效的事件循环。

同步系统调用

当Goroutine想要进行的系统调用无法异步地处理,会发生什么?在这种情况下,网络轮询器将无法使用,而进行系统调用地Goroutine将阻塞M。这种情况很不幸,但是却无法阻止其发生。不能异步处理典型的示例是基于文件的系统调用。如果你使用CGO,在某些情况下,调用C函数也会阻塞M。

注意:Windows操作系统能够异步地进行基于文件的系统调用。因为从技术上讲,在Windows上运行时,可以使用网络轮询器。

让我们看一下同步系统调用(例如文件I/O)导致M阻塞的情况。

图 6

图6再次显示了我们的基本调度图,但是这次G1将进行阻塞M1的同步系统调用。

图 7

图7中,调度器能识别G1将阻塞M。此时,调度器将M1与P分离,而G1仍然置于M1之上。然后,调度器引入一个新的M2来服务于P。这时,P可以从LRQ中选择G2并在M2上进行上下文切换。如果,在之前的交换中已经存在了M,则在本次过渡M的速度会比创建一个新的M快。

图 8

图8中,G1中进行阻塞的系统调用已经完成。此时,G1可以移回LRQ并再次由P服务。M1则被放置一边,以备这种情况再次发生时被启用。

工作偷窃(Work Stealing)

Go调度器的另一个特点是,它是一种采用工作偷窃的调度器,这有助于在某些方面保持调度效率。首先,你最不想看到的事情是M进入等待状态,因为一旦发生这种情况,操作系统调度器会通过上下文切换将M从内核上取下。而这意味着,即使有Goroutine处于就绪状态,直到M通过上下文切换回内核之前,P无法完成任何工作。工作偷窃还有助于在所有P上平衡Goroutine数量,从而更好地分配工作并更高效地完成工作。

让我们看一个例子。

图 9

图9中,我们有一个多线程Go程序,其中两个P分别为各自的4个Goroutine和GRQ中的一个Goroutine服务。如果其中的一个P很快地就执行完了它的所有Goroutine,会发生什么情况?

图 10

图10中,P1已经没有可执行的Goroutine了。但是在P2的LRQ和GRQ中都有就绪的Goroutine,这是P1需要工作偷窃的时刻。工作偷窃的规则如下所示。

表 2

runtime.schedule() {
    // only 1/61 of the time, check the global runnable queue for a G.
    // if not found, check the local queue.
    // if not found,
    //     try to steal from other Ps.
    //     if not, check the global runnable queue.
    //     if not found, poll network.
}

因此,基于表2中所述的规则,P1需要检查P2的LRQ的Goroutine,并拿走检查结果一半数量的Goroutine。

图 11

图11中,p2一半的Gotoutine被取至P1,G3正被放置于P1中的M上执行。

如果P2完成了其上所有Goroutine的服务而P1的LRQ中没有剩余的Goroutine,会发生什么?

图 12

图12中,P2完成了所有的工作,现在需要窃取一些。首先,它寻找P1的LRQ,但是没检查到剩余Goroutine,接着它就寻找GRQ,因此,它找到了G9。

图 13

图13中,P2从GRQ中偷窃了G9并开始执行。工作偷窃的最大好处是,它可以使得所有的M保持忙碌而不至于闲着。工作偷窃从内部机理被认为是旋转M,JBD在其work-stealing博客中很好的解释了旋转M的好处。

实际例子

有了上述调度器机制的认识之后,我想向你展示当所有这些结合在一起,随着时间推移,Go调度器是如何执行更多的工作。想象一下用C语言编写的多线程程序,该程序包含两个OS线程,它们之间相互传递消息。

图 14

图14中,有两个线程来回传递消息。线程1通过上下文切换在内核C1正在执行,线程1将消息发送给线程2。

注意:消息的传递方式无关紧要,重要的是在此编排过程中线程的状态。

图 15

图15中,一旦线程1发送完消息后,它就需要等待响应。这将导致线程1被上下文切换从内核1取下,并进入等待状态。一旦线程2收到有关该消息的通知,它将进入就绪状态。现在操作系统可以执行上下文切换,使线程2在内核2上执行,接着,线程2处理消息并发送新消息给线程1。

图 16

图16中,线程1收到线程2的消息后,再次进行线程上下文切换。线程2从运行态切换到等待态,线程1从等待态切换到就绪态并最终进入运行态,这使得其可以处理并发送新消息。

所有的这些上下文切换和状态更改都需要执行时间,这限制了工作可以完成的速度。每个上下文切换的潜在时延时间约为1000纳秒,硬件每纳秒有希望执行12条指令,然而,或多或少的,12k条指令在上下文切换期间未能执行。由于线程也在不同的内核之间相互反馈,因此由于高速缓存未命中(cache-line miss)而导致的额外延迟可能性也很高。

让我们以相同的例子为例,但是使用Gotoutine和Go调度器。

图 17

图17中,存在两个goroutine,他们彼此之间来回发送消息。M1在C1上,G1在M1上进行上下文切换,正在执行其工作。G1的工作是将消息发送给G2。

图 18

图18中,一旦G1完成了发送信息的工作,它需要等待响应,这会导致G1通过上下文切换从M1取下,并进入等待态。一旦G2收到有关该消息的通知后,它会进入就绪态。现在Go调度器可以通过上下文切换使得G2在M1上得到执行,这时M1仍然是在C1上运行的。接着,G2处理该消息,并发送一个新消息返回给G1。

图 19

图19中,G1收到G2发送的消息时,又发生了上下文切换。现在G2通过上下文切换从运行态转变成等待态,G1从等待态进入就绪态并最终回到运行态,以此来处理和发送新的消息回去。

从表面看,事情似乎没有什么不同。无论是使用线程还是Goroutine,都会发生相同的上下文切换和状态改变。咋一看可能并不明显,然而,事实上在使用线程和Goroutine却有很大的不同。

在使用Goroutine的情况下,所有处理都使用相同的OS线程和内核。这意味着,从操作系统的角度来看,操作系统线程永远不会进入等待状态。因此,在使用线程时我们因为上下文切换而丢失的可执行指令时间在使用Goroutine时不会丢失。

从本质上将,Go在操作系统级将IO密集型任务转变成了CPU密集型的工作。因为所有的上下文切换都发生在应用程序级,在使用线程时每次上下文切换,我们会丢失平均12k条指令的运行机会,而使用Goroutine不会。在Go中,在这些相同的每次上下文切换中,我们会丢失200纳秒或2.4k条指令。Go调度器同样有助于提高高速缓存效率和内存访问不一致(Non-uniform memory access UNMA)。这就是为什么我们不需要线程多于虚拟内核的原因。在Go中,随着时间累计,有可能完成更多的工作,这是因为Go调度器尝试使用更少的线程并在每个线程上执行更多的操作,这有助于减少操作系统和硬件的负载。

结论

Go调度器在设计中考虑到了操作系统和硬件的复杂性,这是非常了不起的。在操作系统级别将IO密集型任务转变成CPU密集型任务的能力是我们在如何更好地利用CPU的一大胜利,这也是为什么你不需要比虚拟内核更多的线程的原因。你可以合理地期望通过每个虚拟内核只有一个OS线程来完成所有工作(CPU或IO密集型)。这样做对于网络应用程序和其他不阻塞OS线程的系统调用的应用程序是可行的。

作为开发者,你仍需要根据你的应用程序是在做什么来确定你正在处理的任务类型。你不能通过创建无数数量的Goroutine来期望获得惊人的性能。少即是多,但是有了对Go调度器的理解,你就可以做出更好的工程决策。在下一部分内容中,我将探讨并发编程来获得更好的性能,同时对可能需要添加至代码中的复杂性进行平衡。

英文原文链接:

Scheduling In Go : Part II - Go Scheduler

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章