6.6 Go语言的并发编程

在本节,我们将会对Go语言下的并发编程及其模型进行介绍。这些内容会让读者在阅读后面几章的时候更加顺畅。

之前我们说过,Go语言在操作系统提供的内核线程之上搭建了一个特有的两级线程模型。由此,也就引出了Goroutine这个特有名词。Go语言的开发者们之所以专门创建了这样的一个名词,是因为他们认为已经存在的线程、协程、进程等术语都传达了错误的含义。为了与它们有所区别,Goroutine这个词才得以诞生。

那么,Goroutine所代表的正确的含义是什么?Go语言打出的标语是这样的:

不要用共享内存的方式来通信。作为替代,应该以通信作为手段来共享内存。

更确切地讲,把数据放在共享内存区中供多个线程中的程序访问的这种方式虽然在基本思想上非常简单,但是却使并发访问控制变得异常复杂。只有做好了各种约束和限制,才有可能会使这种看似简单的方法得以正确地实施。但是,正确性往往不是我们唯一想要的。我们常常还需要足够的可伸缩性。然而,一些同步方法的使用让这种需求的达成变得困难了许多。就像我们在上一节所讲的那样。

Go语言不推荐以共享内存区的方式传递数据。作为替代,我们应该优先使用Channel。Channel主要被用来在多个Goroutine之间传递数据,并且还会保证其过程的同步。不过,作为另一种可选方法,Go语言依然提供了一些传统的并发访问控制方法(互斥量、条件变量,等等)。

在后面的几章中,我们会分别对Goroutine、Channel和Go语言提供的传统并发访问控制方法进行介绍。但是,在这之前,我们先要对Go语言的并发编程模型进行必要的讲解。下面,我们一起来探究Go语言构建的这个两级线程模型的内部机理。不过,如果你还不想关注较底层的模型和实现,那么可以跳过这一节直接阅读下一章。等到你想要或不得不了解它们的时候再翻回来查阅也不迟。

6.6.1 线程实现模型

以我们目前对两级线程模型的了解,Goroutine可以被看作是Go语言特有的“应用程序线程”。但是,实际上,Goroutine背后的支撑体系可远没有这么简单。

说起Go语言的线程实现模型,有3个必知的核心元素。它们支撑起了这个线程实现模型的主框架,其简要说明如下。

  • M:Machine的缩写。一个M代表了一个内核线程。

  • P:Processor的缩写。一个P代表了M所需的上下文环境。

  • G:Goroutine的缩写。一个G代表了对一段需要被并发执行的Go语言代码的封装。

可以看到,这些核心元素的表示相当精炼(只需一个字母),含义也非常明确。请读者记住这3个字母,我们在后面会以它们代表对应的元素。

简单来说,一个G的执行需要M和P的支持。一个M在与一个P关联之后就形成了一个有效的G运行环境(内核线程+上下文环境)。每个P都会包含一个可运行的G的队列(runq)。该队列中的G会被依次传递给与本地P关联的M并获得运行时机。在这里,我们把运行当前程序的那个M称为当前M,而把与当前M关联的那个P称为本地P。后面我们会以此为参考进行描述。

从宏观上看,M、P和G之间的联系如图6-29所示。但是它们的实际关系要比这幅图所展示的复杂很多。不过我们先不用理会这里所说的复杂关系。让我们再把焦点扩大一些,看看它们与内核调度实体(KSE)之间的关系是怎样的,如图6-30所示。

6.6 Go语言的并发编程 - 图1

图 6-29 Go语言的线程实现模型中的3个核心元素

{%}

图 6-30 M、P、G与KSE的关系

可以看到,M与KSE之间总是一对一的。一个M能且仅能代表一个内核线程。Go语言的运行时系统(runtime system)用它来代表一个内核调度实体。M与KSE之间的关联是非常稳固的。也就是说,在一个M的生命周期内,它会且仅会与一个KSE产生关联。相比之下,M与P以及P与G之间的关联都是易变的。它们之间的关系会在实际调度的过程中被改变。其中,M与P之间也总是一对一的,而P与G之间则是一对多的(还记得我们刚刚说过的P中的待运行的G的队列吗?)。注意,由于M、P和G之间的关系在实际调度过程中的多变性,所以图6-30中所示的可能关联仅能作为一般性的示意。此外,M与G之间也会建立关联,因为一个G终归会由一个M来负责运行。但是,相比之下,这种关联并不是这一模型中的重要关系。所以,为了突出重点,我们在前面两幅图中并没有体现出这种关联。

至此,我们已经知道了这些核心实体之间可能存在的关系。Go语言的运行时系统会对这些实体的实例进行实时管理和调度。我们在下一小节会专门对此进行介绍。现在,让我们再次聚焦,看一看在这些实体内部都有哪些细节值得关注。

1. M

一个M代表了一个内核线程。在大多数情况下,创建一个M的原因都是由于没有足够的M来关联P并运行其中的可运行的G。不过,在运行时系统执行系统监控或垃圾回收等任务的时候也会导致新的M的创建。M的部分结构如图6-31所示。

6.6 Go语言的并发编程 - 图3

图 6-31 M的结构(部分)

M结构中的字段众多。我们在这里只是挑选了对于我们初步认识M最重要的4个字段。其中,字段curg会存放当前M正在运行的那个G的指针,字段p会指向与当前M相关联的那个P,而字段mstartfn则代表了我们马上就会讲到的M的起始函数。在M被调度的过程中,这3个字段是最能体现它的即时情况的。而另外的字段nextp则会被用于暂存与当前M有潜在关联关系的P。我们可以把调度器将某个P赋给某个M的nextp字段的操作称为对M和P的预联。在有些时候,运行时系统会把刚刚被重新启用的M和已与它预联的那个P关联在一起。这就是nextp字段所起到的作用。

图6-31从侧面体现出了M与P和G之间可能建立的主要关联。请读者首先记住它,并带着它理解后面的内容。

M在被创建之初会被加入到全局的M列表(runtime.allm)中。紧接着,它的起始函数和准备关联的P(大多数情况下是导致此M创建操作的那个P)会被设置。最后,运行时系统会为它专门创建一个新的内核线程并与之相关联。这样,这个新的M就为执行G做好了准备。其中,起始函数仅当运行时系统要用此M执行系统监控或垃圾回收等任务的时候才会被设置。而这里的全局M列表其实并没有什么特殊的意义。运行时系统在需要的时候会通过它获取到所有M的信息。同时它也可以防止M被当作垃圾回收掉。

在新的M被创建完成之后会先进行一番初始化工作。其中包括了对自身所持的栈空间以及信号处理方面的初始化。在这些初始化工作都完成之后,该M的起始函数会被执行(如果存在的话)。注意,如果这个起始函数代表的是系统监控任务的话,那么该M会一直在那里执行而不会继续后面的流程。否则,在初始函数被执行完毕之后,当前M将会与那个准备与它关联的P完成关联。至此,一个并发执行环境才真正形成。在这之后,M开始寻找可运行的G并运行之。这一过程可以被看作是调度的一部分。我们在下一小节再细说。

运行时系统所管辖的M(或者说runtime.allm中的M)有时候会被停止,比如在运行时系统准备开始执行垃圾回收任务的时候。运行时系统在停止M的时候,会在对它的属性进行必要的重置之后,把它放入调度器的空闲M列表(runtime·sched.midle)。这很重要,因为在需要一个未被使用的M的时候,运行时系统会先尝试从该列表中获取。

注意,M本身是无状态的。M是否空闲仅以它是否存在于调度器的空闲M列表中为依据。虽然运行时系统可以通过全局M列表获取到所有的M,但是却无法得知它们的状态(因为它们没有状态)。

单个Go程序所使用的M的最大数量是可以被设置的。在我们使用命令运行Go程序的时候,一个引导程序先会被启动。这个引导程序会为Go程序的运行建立必要的环境。引导程序会对M的最大数量进行初始设置。这个初始值是10000。也就是说,一个Go程序最多可以使用10000个M。这就意味着,在最理想的情况下,同时可以有10000个内核线程被同时运行。请注意,这里说的是最理想的情况。由于操作系统内核对进程的虚拟内存的布局的控制以及大小的限制,如此量级的线程可能很难共存。从这个角度看,Go语言本身对于线程数量的限制几乎可以被忽略。

除了上述的初始设置之外,我们也可以在Go程序中对该限制进行设置。为了达到此目的,我们需要调用标准库代码包runtime/debug包中的SetMaxThreads函数并提供新的M最大数量。runtime/debug.SetMaxThreads函数在被执行完成后,会把旧的M最大数量作为结果值返回。非常重要的一点是,如果我们在调用runtime/debug.SetMaxThreads函数时给定的新值比当时M的实际数量还要小的话,运行时系统就会发起一个运行时恐慌。所以,我们要小心使用这个函数。请记住,如果我们真的需要设置M的最大数量,那么越早调用runtime/debug.SetMaxThreads函数就越好。对于它的设定值,我们也要仔细地斟酌。

2. P

P是使G能够在M中运行的关键。Go语言的运行时系统会适时地让P与不同的M建立或断开关联,以使P中的那些可运行的G能够在需要的时候及时获得运行时机。这与操作系统内核在CPU之上实时的切换不同的进程或线程的情形是类似的。

通过调用函数runtime.GOMAXPROCS,我们可以改变单个Go程序可以间接拥有的P的最大数量。除此之外,我们还可以在运行Go程序之前设置环境变量GOMAXPROCS的值来对Go程序可以拥有的P的最大数量做出预先设定。P的最大数量相当于是对可以被并发运行的用户级别的G的数量做出限制。(关于什么是用户级别的G,我们会在6.6.3节予以说明。)我们已经知道,每个P都需要关联一个M才能使其中的可运行的G得到执行。但是这却不意味着环境变量GOMAXPROCS的值会限制住M的总数量。当M因系统调用的进行而被阻塞(更确切地说,是它运行的G进入了系统调用)的时候,运行时系统会将该M和与之关联的P分离开来。这时,如果这个P的可运行G队列中还有未被运行的G,那么运行时系统就会找到一个空闲M,或创建出一个新的M,并与该P关联以满足这些G的运行需要。如果我们在Go程序中创建的大部分Goroutine中都包含了很多需要间接地进行各种系统调用(比如各种I/O操作)的代码的话,那么即使环境变量GOMAXPROCS的值被设定为1,也可能会有多个M被创建出来。所以,实际的M总数量很可能会比环境变量GOMAXPROCS所指代的数量多。由此可见,Go程序真正使用的内核线程的数量并不会因此而受到限制。

在Go程序开始被运行的时候,我们在前面提到的引导程序也会对P的最大数量进行设置。P的最大数量的默认值是1。因此,在默认情况下,无论我们在程序中用go语句启用出多少个Goroutine,它们都只会被塞入同一个P的可运行G的队列中。不过要注意,正如前文所说,这并不意味着只会有一个与内核线程一一对应的M去运行它们。当环境变量GOMAXPROCS的值大于0的时候,引导程序会认为我们要对P的最大数量进行设置。它会先检查一下我们设置的值的有效性。如果该值不大于运行时系统对此设定的硬性上限值,那么此值就被认为是有效的,否则该值就会被这个硬性限制取代。也就是说,最终的P最大数量值绝不会比引导程序中的这个硬性上限值大。该硬性上限值是2的8次方,即256。这个硬性上限值为256的原因是Go语言目前还不能保证在数量比256更多的P同时存在的情形下Go程序仍能保持高效。也就是说,这个硬性上限值并不是永久的。它在以后可能会被改变。

注意,虽然我们可以在应用程序中随意地调用runtime.GOMAXPROCS函数,但是它的执行会暂时使所有的P都相继进入停止状态并试图阻止任何用户级别的G的运行。只有在新的P最大数量被设定完成之后,运行时系统才会开始陆续恢复它们。这对于程序的性能是非常大的损耗。所以,我们最好只在Go程序的main函数的开始处调用runtime.GOMAXPROCS函数。当然,在Go程序中不对它进行调用而只预先设置环境变量GOMAXPROCS是最好不过的了。

在确定P的最大数量之后,运行时系统会根据这个数值初始化全局的P列表(runtime·allp)。与全局M列表类似,该列表中包含了当前运行时系统创建的所有P。随后,运行时系统会把调度器的可运行G队列(runtime·sched.runq)中的所有G均匀的放入到全局P列表中的各个P的可运行G队列当中。至此,运行时系统需要用到的所有P都已就绪。至于这里的调度器的可运行G队列的用途以及其中的G是从哪里得来的,我们在后面会陆续说明。

与空闲M列表类似,在运行时系统中也存在着一个调度器的空闲P列表(runtime.sched.pidle)。当一个P不再与任何M关联的时候,运行时系统就会把它放入到该列表,而当运行时系统需要一个空闲的P关联某个M的话,会从此列表中取出一个。由此我们也可知这个空闲P列表的准入条件。注意,即使P进入到了空闲P列表中,它的可运行G列表也不一定是空的。这两者之间没有必然的联系。

与M不同,P本身是有状态的。一个P可能具有的状态如下。

  • Pidle:此状态表明当前P未与任何M存在关联。

  • Prunning:此状态表明当前P正在与某个M关联。

  • Psyscall:此状态表明当前P中的被运行的那个G正在进行系统调用。

  • Pgcstop:此状态表明运行时系统正在进行垃圾回收。在运行时系统进行垃圾回收的时候,会试图把全局P列表中的都置于此状态。

  • Pdead:此状态表明当前P已经不会再被使用。当我们在Go程序运行的过程中通过调用runtime.GOMAXPROCS函数减少P最大数量的时候,多余的P就会被运行时系统置于此状态。

P的初始状态是Pgcstop,虽然运行时系统并不会在这时进行垃圾回收。不过,P处于这一初始状态的时间会非常短暂。在紧接着的初始化和填充P中的可运行G队列之后,运行时系统会将其状态设置为Pidle并放入到调度器的空闲P列表中。此空闲P列表中的所有P都会由调度器根据实际情况进行取用。图6-32描绘了P在各个状态之间进行流转的具体情况。

{%}

图 6-32 P的状态转换

我们可以看到,除了Pdead之外的其他状态的P都会在运行时系统欲进行垃圾回收的时候被置于Pgcstop状态。但是,等到垃圾回收结束之后,它们并不会被恢复至原有状态,而会被统一地转换为Pidle状态。这就意味着它们会被重新调度。这是合理的。因为对于原有状态各异的众多P来说,这样做是最简单和有效的,当然也是最公平的。另一方面,除了Pgcstop状态,处于其他状态的P都可能由于全局P列表的缩小而被认为是多余的并被置于Pdead状态。不过,我们并不用担心其中的G会失去归宿。因为,在P被转换为Pdead状态之前,它的可运行G队列中的G都会被转移至调度器的可运行G队列中,而它的自由G列表(马上就会讲到)中的G也都会被转移到调度器的自由G列表中。

我们已经知道,每个P中都有一个可运行G队列。不过正如我们刚才所述,它们还都包含了一个自由G列表(gfree)。其中包含了一些已经被运行完成的G。随着被运行完成的G的增多,该列表可能会很长。如果它增长到了一定的程度,运行时系统会把其中的部分G转移到调度器的自由G列表(runtime·sched.gfree)中。另一方面,当我们使用go语句欲启用一个G的时候,运行时系统会先试图从相应P的自由G列表中获取一个现成的G来封装我们提供的函数,仅当获取不到这样一个G的时候才有可能会去创建一个新的G。考虑到由于相应P的自由G列表为空而获取不到自由G的情况,运行时系统若在这个过程中发现其中的自由G太少,则会先尝试从调度器的自由G列表中转移过来一些G。这样,只有在调度器的自由G列表也弹尽粮绝的时候才会有新的G被创建。这在很大程度上提高了G的复用率。顺便提一句,当一个P被运行时系统认为不会再被使用(会被置于Pdead状态)的时候,其中的自由G列表中的所有G会都被转移至调度器的自由G列表中。

在P的结构中,可运行G队列和自由G列表是最重要的两个成员。至少对于我们这些Go语言的使用者来说是这样。它们间接地体现了运行时系统对相应的G的调度情况。下面我们就对模型中离我们最近的G进行介绍。

3. G

一个G就相当于一个Goroutine(或称Go程),也与我们使用go语句欲并发执行的一个匿名或命名的函数相对应。我们作为编程人员只是使用go语句向Go语言的运行时系统告知了(或提交了)一个并发执行任务,而Go语言的运行时系统则会按照我们的要求并发地执行并完成这一任务。

Go语言的编译器会把我们编写的go语句(go关键字和其后的函数的统称)变成对一个运行时系统中的函数调用,并把go语句中的那个函数(以下简称go函数)及其参数都作为参数传递给这个运行时系统中的函数。这也是我们应该了解的第一件与go语句有关的事。其实它并不神奇,只是代表了我们向运行时系统递交的一个并发任务而已。

运行时系统在接到这样一个调用之后,会先检查一下go函数及其参数的合法性,紧接着会试图从本地P的自由G列表和调度器的自由G列表获取可用的G(我们刚刚讲过)。如果没有获取到则只好新建一个G了。与M和P相同,运行时系统也持有一个G的全局列表(runtime·allg)。新建的G会在第一时间被加入到该列表中。类似地,该列表的主要作用也是集中存放当前运行时系统中的所有G的指针。无论将会封装当前的这个go函数的G是否是新的,运行时系统都会对它进行一次初始化。其中包括了关联go函数以及设置该G的状态和ID等步骤。在初始化完成后,这个G会被放入到本地P的可运行G队列中。如果时机成熟,调度会立即进行以使这个G尽快被运行。不过,即使这里不立即调度,我们也无需担心该G不能被及时运行,因为运行时系统总是在不停地为了及时运行各个可运行的G而忙碌着。

每个G都会由运行时系统根据其实际状况设置不同的状态,其可能的状态如下。

  • Gidle:在当前G被创建但还完全未被初始化的时候会处于此状态。

  • Grunnable:表示当前G是可运行的,并且正在等待被运行。

  • Grunning:表示当前G正在被运行。

  • Gsyscall:表示当前G正在进行系统调用。

  • Gwaiting:表示当前G正在因某个原因而等待。

  • Gdead:表示当前G已被运行完成。

我们之前讲过,在运行时系统想用一个G封装我们通过go语句递交的go函数的时候,会先对这个G进行初始化。其中的一步就是初始化这个G的状态,而这个状态总会是Grunnable。也就是说,一个G真正开始被使用是在其状态被设置为Grunnable之后。图6-33展示了G在其生命周期内的状态流转情况。

{%}

图 6-33 G的状态转换

一个G在被运行的过程当中,是否会等待某个事件以及会等待什么样的事件,完全由其封装的go函数决定。例如,如果这个函数中包含了对通道类型值的操作,那么在执行到对应的代码的时候这个G就有可能进入Gwaiting状态。这可能是在等待从通道类型值中接收值,也可能是在等待向通道类型值发送值。又例如,涉及网络I/O的时候也会导致相应的G进入Gwaiting状态。此外,操纵定时器(time.Timer)和调用time.Sleep函数同样会造成相应G的等待。在事件到来之后,G会被“唤醒”并被转换至Grunnable状态。待时机到来时,它会再次被运行。

G在退出系统调用的时候的状态转换要比上述情况复杂一些。运行时系统先会尝试直接运行这个G,仅当无法直接运行的时候,才会把它转换为Grunnable状态并放入到调度器的自由G列表中。显然,对这样一个G来说,在其退出系统调用之时就被立即继续运行是再好不过的了。运行时系统当然会为此做出一些努力。不过,即使努力失败了,该G也还是会在实时的调度过程中被发现并运行。

最后,值得一提的是,进入死亡状态(Gdead)的G是可以被重新初始化并使用的。相比之下,P在进入死亡状态(Pdead)之后则只能面临被销毁的结局。由此也可以说明Gdead状态与Pdead状态所表达的含义是截然不同的。处于Gdead状态的G会被放入本地P或调度器的自由G列表,这为它们的重用提供了条件。

至此,我们基本了解到一个G在运行时系统中的流转方式和时机,这也展现了一条go语句背后所蕴藏的玄机。

4. 核心元素的容器

我们刚刚讲述的是Go语言的线程实现模型中的3个核心元素——M、P和G。同时我们也多次提到了承载这些元素的实例的容器——各种队列和列表。我们现将这些容器归纳一下,如表6-5所示。

表6-5 M、P和G的容器

中文名称源码中的名称作用域简要说明
全局M列表runtime.allm运行时系统被用于存放所有M的列表
全局P列表runtime.allp运行时系统被用于存放所有P的列表
全局G列表runtime.allg运行时系统被用于存放所有G的列表
调度器的空闲M列表runtime·sched.midle调度器被用于存放空闲M的列表
调度器的空闲P列表runtime·sched.pidle调度器被用于存放空闲P的列表
调度器的可运行G队列runtime·sched.runq调度器被用于存放可运行G的队列
调度器的自由G列表runtime·sched.gfree调度器被用于存放自由G的列表
P的可运行G队列runq本地P被用于存放当前P中的可运行G的队列
P的自由G列表gfree本地P被用于存放当前P中的自由G的列表

在这些容器中,全局的那3个列表存在的主要目的都分别是为了统计运行时系统中的所有M、P或G。相比之下,最应该值得我们关注的是那些非全局的容器,尤其是与G相关的那4个容器。

与G有关的非全局容器有可运行G队列、调度器的自由G列表、本地P的可运行G队列以及本地P的自由G列表。运行时系统创建出的任何G都会存在于全局G列表中。而其余的4个列表则只会存放在当前作用域内的具有特定状态的G。注意,这里的两个可运行G列表中的G都拥有几乎平等的运行机会。在运行时系统调度的过程中会先后对它们进行检查,并会立即运行第一个被发现的可运行的G。由于这种平等性的存在,所以我们无需关心哪类可运行的G会进入到哪一个队列中。不过,可以顺便提一下,从Gsyscall状态和Ggcstop状态转出的G,都会被放入调度器的可运行G队列,而被运行时系统初始化的G,都会被放入本地P的可运行G队列。至于从Gwaiting状态转出的G,除了因进行网络I/O而陷入等待的G之外,都会被放到本地P的可运行G队列中。此外,我们之前说过,对runtime.GOMAXPROCS函数的调用,可能会导致运行时系统清空调度器的可运行G队列。其中的所有G都会被均匀地放入到全局P列表中的各个P的可运行G队列当中。另一方面,在G转入Gdead状态之后,首先会被放入本地P的自由G列表,而在运行时系统需要用自由G封装go函数的时候,也会先尝试从本地P的自由G列表中获取。调度器的自由G列表只是起到了一个暂存自由G的作用。这方面内容我们在前面已有所描述。

与M和P相关的非全局容器分别是调度器的空闲M列表和调度器的空闲P列表。这两个列表都被用于存放暂时不被使用的元素的实例。在运行时系统有需要的时候,会从中获取相应元素的实例并重新启用它。

在本小节中,我们一直把实现和操纵Go语言的线程实现模型的内部程序统称为运行时系统。实际上,我们应该把它叫作调度器。调度器拥有自己的结构,也依此提供了一些很重要的功能。其实,其中的大部分功能我们在本小节都已经接触到了。比如,对各类元素实例之间的关联的管理、对各个元素实例状态的转换以及它们在不同核心元素容器间的转移,等等。只不过,我们是围绕着各种模型元素(M、P、G以及各种容器)来对它们进行介绍的。这可能会让读者对调度器负责执行的流程依然感到有些模糊。不过别担心,在下一小节,我们就对调度器的结构以及与之相关的几个重要流程进行介绍。

6.6.2 调度器

我们在上一节讲过,两级线程模型中的一部分调度任务会由操作系统内核之外的程序承担。在Go语言中,其运行时系统中的调度器会负责这一部分调度任务。调度的主要对象是M、P和G的实例,调度的辅助设施是我们在上一小节的最后讲到的的各种容器。其实每个M(即每个内核线程)在运行过程中都会按需执行一些调度任务。不过为了更加容易理解,我们把这些调度任务统称为调度器的调度行为。在本节,我们会了解到这些调度行为的核心流程。

1. 基本结构

调度器有它自己的数据结构。这一数据结构的主要目的就是为了更加方便地管理和调度各个核心元素的实例。在这些字段中,有我们已经熟知的空闲M列表、空闲P列表、可运行G队列和自由G列表。下面,我们再来讲解另外的一些字段,如表6-6所示。

表6-6 调度器的字段(部分)

字段名称数据类型用途简述
gcwaitinguint32作为垃圾回收任务被执行期间的辅助标记、停止计数和通知机制
stopwaitint32
stopnoteNote
sysmonwaituint32作为系统监测任务被执行期间的停止计数和通知机制
sysmonnoteNote

在这张表中,我们只罗列了几个比较重要的字段。它们都与运行时系统执行的垃圾回收任务有关。

字段gcwaitingstopwaitstopnode都是运行时系统中的垃圾回收器在进行垃圾回收时的辅助协调手段之一。由调度器的字段gcwaiting的值,我们可以知道垃圾回收器是否已经开始准备或正在进行垃圾回收。stopwait字段是为了对还未被停止调度的P进行计数。当该计数为0的时候,就说明调度工作已被完全停止。这时,垃圾回收器会立即开始执行垃圾回收任务。而stopnode字段就是被用来向垃圾回收器告知调度工作已经完全被停止的通知机制的重要部分。

这些辅助协调手段存在的意义在于保证所有的P在垃圾回收期间都处于Pgcstop状态。这样有助于最大化垃圾回收的效果。更明确地说,Go语言的垃圾回收器的做法是:先停止一切调度工作(包括停止对M和P的调度,等等),然后进行垃圾回收,最后待垃圾回收完成之后再重启调度工作。这意味着Go语言的垃圾回收任务是在“Stop the world”的环境下被执行的。“Stop the world”即指运行时系统要放下手头所有工作并专心(无其他并发任务)执行垃圾回收任务。

垃圾回收器在准备执行垃圾回收任务的时候会先把调度器的gcwaiting字段的值设置为1。这是为了要告诉调度器,它已经开始准备执行垃圾回收任务。垃圾回收器会利用stopnode字段将自身阻塞住,以等待调度器完全停止调度。调度器在发现gcwaiting字段的值被设置为1之后,会积极响应,并陆续停止正在进行的调度工作。待所有的调度工作均已停止(此时作为计数器的stopwait字段的值变为0)之后,调度器会利用stopnode字段向垃圾回收器发送通知。后者在收到通知后才会真正开始垃圾回收。我们无需关心stopnode字段的数据类型Node到底是怎样的类型。不过,顺便提一句,此通知机制在底层是由信号灯实现的。

现在,我们再来看调度器的另外两个字段——sysmonwaitsysmonnote。它们与前面那一组字段的用途类似,只不过它们针对的是系统监测任务(我们稍后会介绍它)。在垃圾回收器进行垃圾回收的时候,被持续执行的系统监测任务也需要被暂停。而这两个字段的作用就是及时地暂停和恢复系统监测任务的执行。sysmonwait字段是表示系统监测任务是否已被暂停的标记,而sysmonnote字段则是被用来向执行系统监测任务的程序发送通知的。

系统监测任务是被持续执行的。更确切地说,它被置于了无尽的循环之中。在每次迭代之初,相关程序(或称系统监测器)会先检查调度器的gcwaiting字段的值。若发现其值为1,则说明垃圾回收器已经开始准备或正在执行垃圾回收任务。这时,系统监测器会先将调度器的sysmonwait字段的值设置为1以表示系统监测任务已被暂停。然后利用sysmonnote字段阻塞自身以等待垃圾回收的完成。在调度工作被重启之后,调度器若发现其sysmonwait字段值为1则会利用sysmonnote字段向系统监测器发送通知。系统监测器在收到该通知之后会立即执行当次迭代的后续流程并继续进行之后的迭代。

我们看到,调度器的这5个字段都是为了辅助垃圾回收的执行而存在的。垃圾回收任务被执行期间的“Stop the world”环境是由它们帮助构建的。

2. 一轮调度

我们已经知道,引导程序会为Go程序的运行建立必要的环境。在引导程序完成它的工作之后,Go程序的main函数才会被真正地执行。引导程序会在最后让调度器进行一轮调度,这样才能够让main函数所在的G马上有机会被运行(封装main函数的G总是Go语言运行时系统创建的第一个G)。我们现在就来看看调度器在一轮调度中都做了哪些工作。为了让读者对此能够先有一个宏观的了解,我们根据调度器在进行一轮调度的时候所执行的流程绘制了一幅流程图,如图6-34所示。

{%}

图 6-34 一轮调度的总体流程

注意,为了突出重点,此流程图中只描绘了其中的一些重要步骤。本小节后续出现的流程图也会是如此。

在调度器的一轮调度总体流程中,一共有5个子流程。如,调度器在从它自己的可运行G队列中获取G的时候,并不只是查找那么简单。如果找到了一个可运行G,调度器还会把该G转移至本地P的可运行G队列中。又如,在发现垃圾回收器已经准备开始垃圾回收的时候,调度器会积极响应并停止本地P和当前M。停止这两者的工作都需要通过几个步骤来完成。我们会在解释一轮调度的总体流程的过程中适当地描述这些子流程。

作为响应垃圾回收任务的执行而进行的停止调度工作的一部分,调度器在开始进行一轮调度的时候会先检查它的gcwaiting字段的值。如果发现该值为1,那么调度器会立即停止本地P和当前M。在这里,停止本地P的工作需要两步,即断开本地P与当前M之间的关联和把这个(曾经的)本地P的状态置为Pgcstop。与其他停止P的流程一样,在调度器停止当前的P之后,总是要检查一下是否所有的P都已被停止。如果答案是肯定的,那么它就会立刻利用它的stopnode字段向垃圾回收器发送通知。这使得通知总会非常及时地被送达。无论怎样,当前M也是需要被停止的。停止当前M的步骤会稍稍复杂一些,如下所示。

  1. 重置当前M的一些属性。

  2. 把当前M放入到调度器的空闲M列表中。

  3. 阻塞当前M。被阻塞的M会等待调度器的唤醒。

在垃圾回收结束之后,调度器会从它的空闲M列表中取出M并将它们唤醒。被唤醒的M会立即与一个可运行G队列不为空的P进行关联。随后,调度器会再为它进行新一轮的调度。正如图6-34所示。

只要错开了垃圾回收任务的执行时期,调度器就会试图在本地P的可运行G列表中查找可以被运行的G。有时候为了公平性,调度器也会先检查它自己的可运行G队列,并仅当该队列中无可运行的G的时候再从本地P的可运行G队列中获取。倘若从队列中找不到可运行的G,那么调度器就会进入全力查找可运行G的子流程。鉴于这个子流程的复杂性,我们稍后会专门对它进行讲解。可以肯定的是,如果经过一番努力之后还是无法找到任何可运行G,该子流程就会被暂停,并且直到有可运行G出现才会继续下去。也就是说,这个全力查找可运行G的子流程的结束就意味着当前M抢到了一个可运行的G。

在得到一个可运行G之后,调度器在让当前M运行它之前还会判断一下该G是否已与某个M锁定。这里所说的锁定的含义是G中的一个标记被设置为某个M以表示它必须由该M运行。在M的结构中实际上也存在一个对应的标记并被用来表示某个G只能由它运行。这两个标记一定会被一起设置或重置。这样,在设置它们之后,相应的M和G就等于被捆绑在了一起。在Go程序中,我们可以通过调用runtime.LockOSThread函数,把当前的Goroutine与当时执行它的那个M捆绑在一起,也可以通过调用runtime.UnlockOSThread函数,把与当前Goroutine有关的捆绑解除。一个M只能与一个G捆绑,反之亦然。所以,如果我们多次调用runtime.LockOSThread函数,那么仅有最后一次调用是有效的。也就是说,之前的调用所产生的结果会被最后一次调用覆盖掉。另一方面,即使当前的Goroutine没有与任何M捆绑,我们调用runtime.UnlockOSThread函数也不会产生任何副作用。它会直接返回。

那么在什么时候才有必要把一个G和某个M捆绑在一起呢?答案是大多数情况下是完全没有必要的。如果我们没有编写过使用了某些C语言函数库(通过cgo)的Go语言程序的话,可能无法体会到其中的真正含义。有些C语言的函数库(比如OpenGL)会用到线程本地存储技术。也就是说,它们会把一些数据存储在当前的内核线程的私有缓存中。所以,如果我们让调度器任意选择运行它们的M(内核线程)的话,就意味着会丢失掉其存储在之前运行它的那个内核线程的私有缓存中的那些数据。这样往往会造成不可预估的问题。因此,让包含了此类操作的G只被同一个M运行是非常有必要的。

回到刚才的话题。如果调度器发现它找到的这个可运行G已经与某个M锁定在了一起,那么它就会让与该G锁定的那个M去运行这个G。然后,停止当前的M(即让当前M继续等待其他可运行G)。在这之后的某个时刻,这个M会被唤醒。调度器同样会为它重新进行一轮调度。另一种情况,如果调度器发现它找到的可运行G未与任何M锁定,那么它就会直接让当前M去运行这个G。至此,调度器在一个M中的一轮调度才真正完成。

一轮调度是调度器中的核心流程。运行时系统在调度过程中会经常使用到它。比如,调度器在让某个G等待之后会进行一轮调度。又比如,在垃圾回收结束之时一轮调度流程也会被执行。再比如,在某个G退出系统调用的时候,运行时系统也会启动一轮调度的流程。除此之外,我们对runtime代码包中的一些函数的调用也会导致该流程的执行。例如,我们在为了让其他Goroutine有机会被运行而调用runtime.Gosched函数的时候,就相当于手动地让调度器进行了新一轮的调度。这也是其他Goroutine能够得到运行机会的真正原因。又例如,runtime.Goexit函数会终结调用它的那个Goroutine。调度器在结束那个Goroutine的运行之后,会立即进行一轮调度以使其他等待运行的Goroutine获得机会。

我们下面介绍的一些流程也会与一轮调度有关。请读者继续往下看。

3. 全力查找可运行的G

我们刚才提到,调度器在没有从相关队列中找到可运行G的时候,会进入全力查找可运行G的子流程。我们现在就来简要介绍一下这个子流程。该子流程会做如下的获取可运行G的尝试。

  1. 从本地P的可运行G队列中获取G。

  2. 从调度器的可运行G队列中获取G。

  3. 从网络I/O轮询器(netpoller)处查找已经就绪的G。这样的G可以被当作可运行的G。

  4. 在条件许可的情况下,从另一个P的可运行G队列中偷取可运行的G。

  5. 再次尝试从调度器的可运行G队列中获取G。

  6. 尝试从所有P的可运行G队列中获取G。

  7. 再次尝试从网络I/O轮询器处查找已经就绪的G。

其中,从网络I/O轮询器处查找已经就绪的G是一个较复杂的过程。简单地说,网络I/O轮询器是Go语言为了在操作系统提供的异步I/O接口之上实现自己的阻塞式I/O而编写的一个子程序。它所选用的异步I/O接口都是可以对网络I/O的状态进行高效轮询的利器(比如epoll和kqueue)。当一个Goroutine试图在一个网络连接上进行读或写操作的时候,底层程序会让网络I/O轮询器在它们准备好之后通知该Goroutine。在这之前,这个Goroutine会被迫转入等待状态(即Gwaiting状态),然后调度器会使它与运行它的那个M分离。在网络I/O轮询器从底层程序那里得知准备就绪的消息之后,会立即通知为此等待的Goroutine。因此,这里所说的从网络I/O轮询器处查找已经就绪的G的意思就是,获取这些已经接收到通知的Goroutine。它们既然已经可以进行网络读写操作了,那么调度器理应让它们从等待状态转出并调度某些M去运行它们。

无论在进行哪一次尝试的时候找到了可运行的G,调度器都会立即中止这个子流程并把找到的G返回给父流程。而万一不幸的事情发生了,即在做出如此多的尝试之后依然找不到可运行的G,调度器就会停止当前的M。当有可运行G出现时,这个M会被唤醒。随后调度器会重新执行该子流程的全部或部分。假如永远找不到一个可运行的G或者即使有可运行的G出现,也都被其他M中执行的调度程序抢走了,那么该M中的这个全力查找可运行G的子流程就会被一直执行下去,永远不会退出。这就是我们在前面所说的:全力查找可运行G的子流程的结束就意味着当前的M抢到了一个可运行的G。

4. 启用或停止M

我们在本节中多次提到调度器有时会停止当前M。至于停止当前M的原因,我们也提到过一些,例如垃圾回收任务的执行和等待新的可运行G的到来,等等。在调度器停止某个M之前一定会把它放入到自己的空闲M列表中,而调度器准备唤醒的M一定是从它的空闲M列表中取出的。调度器只会停止当前M,但却可以根据需要启用其他M。这一停一启充分体现了调度器对M的调度行为,如图6-35所示。

{%}

图 6-35 启用或停止M

图6-35所描绘的流程是以调度器全力查找可运行G为背景的。我们放大了其中的一些部分。比如,若在全力查找之后仍未找到可运行的G,调度器会停止当前的M。又比如,在全力查找可运行G的过程中,调度器会从网络I/O轮询器处查找已经就绪的G。是否找到了G以及找到了多少个G决定了其之后的走向。总之,图6-35向我们展示了调度器停止当前M的一般方法以及该M被唤醒的时机。注意,这并不是调度器启停M的唯一场景,而只是众多类似的调度场景中的一例。

下面,我们对此图所展示的流程进行一些必要的解释。如图6-35所示,停止当前M总体来说需要两个步骤。在运行在m7中的调度程序发现无论如何也找不到可运行的G的时候,会把m7放入到调度器的空闲列表中,然后阻塞它以等待在其他M中运行的调度程序在发现多个可运行的G的时候向m7发送的通知。

另一方面,在m3中运行的调度程序在全力查找可运行G的过程中发现网络I/O轮询器处有多个已就绪的G。于是它把这些G作为可运行的G放入到了调度器的可运行G队列中。然后,调度程序会在有空闲的P(或者说有可用的上下文环境)的前提下从调度器的空闲M列表中取出一个M,并在预联这个M和那个空闲的P之后利用通知机制唤醒这个刚刚被取出的空闲M。如果这个M恰好是m7,那么m3中的调度器程序就会向m7发送通知以唤醒它。在m7被唤醒后,调度程序会立即关联m7和在m3中已与它预联的那个空闲的P。这时,m7已经有了新的上下文环境。随后,m7中的调度程序会重新全力查找可运行的G。当然,重新全力查找并不意味着一定会查找到可运行的G。因为它们可能已经被在其他M上运行的调度程序抢走了。实际上,这种情况并不是偶尔才发生的。也正因为如此,这个全力查找可运行G的流程才会如此往复,直至抢到一个可运行的G为止。

我们在讲解调度器的一轮调度流程的时候说过,如果调度器发现找到的可运行G已经与某个M锁定,那么调度器就会让那个M来运行这个G。也就是说,调度器会唤醒与这个可运行的G锁定在一起的那个M。为什么说唤醒而不说启用?这是因为,调度程序在发现当前M已与某个G锁定的时候会停掉当前M,并等待那个与之锁定的G变得可被运行。相关的流程如图6-36所示。

为了突出重点,该图隐藏了一些非关键的步骤。我们以调度程序处理一个刚刚退出系统调用的G的流程为例。这其中包含了我们已经知道的一些步骤和流程,比如一轮调度流程、停止M的流程,等等。如图所示,这个流程是在m2中被执行的。而在m6中,我们放大了一轮调度流程中的末尾部分,即根据判断被找到的可运行G是否已与某个M锁定的结果决定后续的操作(启用被锁定的M或者直接运行那个可运行的G)。

启用或停止被锁定的M的流程要比普通的M启停流程稍微复杂一些。我们先来看在m2中被执行的流程。在得到一个退出系统调用的G之后,运行在m2中的调度程序会立即尝试继续运行它。若存在空闲的P,那么调度程序就会直接运行这个G。否则,调度程序就会判断m2是否已被锁定。若结果是肯定的,则进入停止被锁定的M的子流程。如果答案是否定的,由于找不到一个空闲的P(或者说没有一个可用的上下文环境),调度器只能停止m2以等待一个空闲的P。还记得吗?一个代表了内核线程的M在与一个代表了上下文环境的P结合之后才能去运行一个G。

{%}

图 6-36 启用或停止被锁定的M

我们继续关注m2已被锁定的情况。在停止被锁定的M的子流程中,调度程序会先断开与m2关联的P并促使它与其他M产生关联(如果存在这样一个P的话)。这样,即使停止了m2也不会浪费相关的上下文环境。随后,m2会被停止以等待运行与之锁定的G的时机。另一方面,如果m6中的调度程序发现它找到的可运行G已与某个M锁定了,它就会进入到启用被锁定的M的流程中。调度程序会先获取与该可运行G锁定在一起的那个M。然后,它会断开与m6关联的P,并把该P与这个被锁定的M预联。这既是为了拿掉当前M(即m6)的上下文环境,也是为了预设被锁定的M(这里是m2)的上下文环境。其背后的原因是,调度器会在唤醒m2之后停掉m6,m6不应再与任何P有关联。在m2被唤醒之后,其中的调度程序会把它与那个曾经属于m6的P关联在一起。至此,运行在不同的M中的调度程序共同完成了对上下文环境(也就是P)的转移。这一步非常关键。在这之后,m2就可以运行与它锁定的那个G了。

在高并发的Go程序中,启停M的流程在调度器中经常会被执行。因为并发量越大,调度器对M、P和G的调度就越频繁。各个Goroutine总是会通过这样或那样的途径使用到操作系统的提供的各种接口,也会经常使用到Go语言本身提供的各种组件(比如Channel和Timer,等等)。这些操作都直接或间接的涉及了启停M的流程。由此可见,此流程可以算得上是调度器乃至运行时系统中的核心流程了。

5. 系统监测任务

我们在讲解调度器的字段的时候提到过系统监测任务。那时我们着重解释了系统监测任务是怎样配合垃圾回收任务而执行的。现在我们来专门介绍这一任务。此任务本身并不复杂,而且其中涉及的一些子流程我们在前面已经详细的说明过了。当然,其余的部分也是值得介绍的。我们先来了解一下系统监测任务的总体流程,如图6-37所示。

{%}

图 6-37 系统监测任务的总体流程

概括来说,这个系统监测任务做了如下3件事。

  • 在必要的时候,从网络I/O轮询器处查找已就绪的G,并把它们放入调度器的可运行G队列。

  • 抢夺符合条件的P和G。

  • 在必要的时候,进行调度器跟踪并打印出相关信息。

该任务做第一件事是为了帮助调度器从网络I/O轮询器那里及时的找回一个可以被运行的G。做第二件事的目的是周期性地为调度工作查缺补漏。而做第三件事则完全是为了调试(它会打印出一些调度过程的信息)。当然,除此之外,系统监测任务中还包括了一些其他操作。比如,根据上次的监测情况(由空闲计数和间隔时间代表)决定本次监测的延迟时间。又比如,在垃圾回收正在进行或所有P都处于空闲状态的时候暂停,并在接收到通知后继续执行。这其中最值得一提的当属抢夺符合条件的P和G这个子流程了。

在抢夺P和G的子流程中,所有的P都会被检查。程序会先查看P的状态。如果它的状态为Psyscall或Prunning,那么程序会对它进行进一步检查并在必要时进行相应的调度。图6-38展示了程序对一个P的检查和处理的流程。

{%}

图 6-38 抢夺P和G的流程

从图6-38中我们可以看出,当P的状态为Psyscall且处于该状态已超过20微秒的时候,程序会尝试拿走该P并让其他的M与它关联。这是为了让该P的可运行G队列中的G能够尽快被运行。不过,这还需要以其他条件的满足为前提。如果这个P的可运行G队列已经空了,那么让其他M与它关联也就没有什么意义了。不过,若此时已经没有空余的M和P了,那么还是应该对这个P进行调度。这里所说的空余的M是指M未被停止但它还没找到可运行的G,而空余的P即是指空闲的P。那么没有空余的M和P意味着什么呢?实际上,这就意味着现有的未被停止的M和所有的P都已有事可做。这时使用这个处于Psyscall状态的P作为补充是合理的。

另一方面,如果P的状态为Prunning且处于该状态已超过10毫秒,那么程序会尝试停止正在该P代表的上下文环境之上运行的那个G。也就是说,程序会阻止一个G被某个M运行过长的时间。这也是为了公平起见。注意,这里的运行过长的时间指的是,G持续的被M运行(中途既没有进入系统调用也没有被阻塞)超过了10毫秒。不过,即使G被持续的运行了10毫秒,并且程序也向这个G发送了通知,这个G也不一定会停止运行。且不说这个通知不一定能够被正确地传递给这个G,就算这个G及时地得到了这个通知,它也可能会将该通知忽略掉。因此,程序仅会也仅能履行告知义务,而既不保证通知的正确达到也不保证作为目标的G会做出响应。这也是该程序仅能作为辅助调度手段的原因之一。

至此,我们已经全面地了解了系统监测任务中的主要流程和重要细节。此任务既是调度程序的有力补充,也是我们了解调度过程的主要手段。此外,细心的读者可能会发现,此系统监测任务永远不会结束(在流程图中也没有“结束”节点)。它会一直被循环地运行下去。它就像调度器的守护者一样,实时地监测着调度过程。最后,值得一提的是,系统监测任务是在一个单独的M中被运行的。但是,调度器并没有把它封装在G中。

6. 变更P的最大数量

我们在上一小节中其实已经介绍过与此变更操作相关的一些步骤,比如,调整全局P队列的大小、把多余的P的状态置为Pdead,以及重新分配可运行的G给全局P队列中的所有P,等等。我们现在按照实际的顺序把这些操作步骤串接起来,使读者能够看到这一变更操作的全貌。

当我们在Go程序中调用runtime.GOMAXPROCS函数的时候,它会先进行下面两项检查以确保变更的合法和有效。

  • 如果我们传入的参数值(以下简称新值)比运行时系统对此设定的硬性上限值(即256)大,那么前者会被后者替代。也就是说,无论我们传入的新值有多大,最终的值也不会超过256。这是运行时系统对自身的保护。

  • 如果新值不是正整数或者与存储在运行时系统中的P最大数量值(以下简称旧值)相同,那么该函数就会忽略此变更而直接返回旧值。

如果通过了这两项检查,该函数会先通过调度器停止一切调度工作,然后暂存新值、重启调度工作,最后将旧值作为结果返回。在调度工作真正被重启之前,调度器如果发现有新值被暂存,那么就会进入到P最大数量的变更流程中。

在此变更流程中,旧值也会先被获取。如果发现旧值或新值不合法,那么调度器就会发起一个运行时恐慌,流程也会随即终止。不过由于runtime.GOMAXPROCS函数中的前期检查,此流程中的这个分支在这里永远不会被执行到。在通过对旧值和新值的检查之后,调度器会依据新值对全局P列表进行重新初始化。更确切地说,是对全局P列表中的前N个P进行重新初始化。这里的N即为新值。如果全局P列表中的P的数量不够,调度器则会新建相应数量的P并把它们追加到全局P列表中。新的P的状态为Pgcstop以表示它还不能被使用。顺带说一下,全局P列表中所有P的可运行G队列的初始长度都会是128。当前的策略是,此长度可以根据实际需要翻倍增长。但是,这种策略可能会在今后被改变。调度器也许会通过对其中的可运行G的适当调度来避免P的可运行G队列的无限增长。这会比当前的策略更安全,也更可控。

在对全局P列表的初始化完成之后,调度器会把全局P列表中的所有P(包括将要丢弃但还未丢弃的P)的可运行G队列中的G全部取出,并依次放入到调度器的可运行G队列中。然后,调度器的可运行G队列中的所有可运行G会被均匀地依次放入到已被重新初始化的那些P的可运行G队列中。至此,所有可运行G的重新分配工作完成。这也是在为丢弃多余的P做准备。对于这些多余的P,调度器会释放它们的本地缓存、将它们的自由G列表中的所有G都转移到调度器的自由G列表中,最后把它们的状态都置为Pdead。之所以不能直接销毁它们,是因为它们可能会被正在进行系统调用的M引用。如果某个P被这样的M引用但却被销毁了,那么就会在该M完成系统调用的时候造成错误。

然后,调度器会把当前M与全局P列表中的第一个P关联(别忘了,调度程序也是在M中被运行的),并把剩余的P全部放入到调度器的空闲P列表中。正如我们前面所讲的,在P与M关联或被放入空闲P列表之前,它的状态都会先被置为Pidle。最后,存储在运行时系统中的P最大数量的值会被变更为新值。

图6-39展示了此变更流程。

6.6 Go语言的并发编程 - 图11

图 6-39 P最大数量的变更流程

从图6-39中我们也可以看到,此变更流程内含了3类操作,即重建、废弃和更新。这3类操作都是由调度器来完成的。

在本小节中,我们介绍了调度器中的5个非常关键的流程,即一轮调度、全力查找可运行的G、启用或停止M、系统监测任务和变更P的最大数量。一轮调度流程是调度器中最核心的流程,没有之一。而全力查找可运行的G则是专属于一轮调度流程的子流程。它非常重要,以至于我们为它专门设置了一个标题。启用或停止M的流程也与前两者有着千丝万缕的联系。它让我们了解到了调度器启停M的方法和规律。系统监测任务的本质是为调度器的调度工作查缺补漏以使其对M、P和G的调度更加合理和高效。我们可以通过调用runtime.GOMAXPROCS函数改变运行时系统中的P的最大数量。它是我们对Go程序的性能进行调优的最直接的方法之一。不过需要注意,对runtime.GOMAXPROCS函数的调用会引起调度工作的短暂停止。对于对响应时间敏感的Go程序来说,即使是如此短暂的停止,也可能会给程序的性能带来影响。所以,我们需要知道和记住使用此函数的正确方式(在上一小节有介绍)。希望对这些流程的讲解能够使读者对Go语言程序的并发运行机制有更深的理解。

6.6.3 更多的细节

在本小节,我们会对与调度任务有关的一些细节进行简短的介绍。了解这些细节可以让我们对调度器及其运行过程的认识更加清晰一些。

1. g0和m0

运行时系统中的每个M都会拥有一个特殊的Goroutine——g0。它不是由Go程序中的代码(确切地说是go语句)间接生成的,而是运行时系统在初始化M期间创建并分配给该M的。g0内含了各种调度、垃圾回收和栈管理等程序。

除了g0之外,其他由M运行的G都可以被视作用户级别的G。用户级别的G可以被简称为用户G,而g0则可以被称为系统G。在通常情况下,M会运行用户G。不过,g0也会时不时地被切换和运行以执行前面说到的那些管理性质的任务。这就是我们在前面提到的每个M都会运行调度程序的根本原因。与用户G不同,g0不会被阻塞,也不会被包含在任何G队列或列表中。同时,它的栈也不会在垃圾回收进行期间被扫描。由此也可见g0的特殊性。

除了每个M都有属于它自己的g0之外,还存在一个runtime.g0。runtime.g0被用于执行引导程序。它是在Go程序所间接拥有的第一个内核线程中被运行的。这个内核线程也被称为runtime.m0。runtime.m0和runtime.g0都是被静态分配的,因此引导程序也无需为它们分配内存。

2. 调度器锁和原子操作

其实,在我们本节介绍的很多流程中都用到了调度器锁。但是为了描述的简洁,我把对调度器锁的加锁和解锁操作从流程中去掉了。但是这并不意味着这类操作不重要。

我们已经知道,每个M都有可能执行调度任务。这些任务的执行在时间上可能会重叠,即称并发的调度。因此,调度器会在查询或更改它自己的字段以及运行时系统中的全局变量的之前和之后分别对调度器锁进行加锁和解锁操作。它所涉及的代码非常多,以至于遍及绝大多数的调度流程。不过,从其源码上看,各个临界区的数量的大小都已被合理地控制。除此之外,调度器在必要时会对它的字段或全局变量进行原子的查询或更改操作,无论这些操作是否已在临界区中。

调度器在自身的并发执行上做了很多有效的约束和控制,兼顾正确性与可伸缩性。这也是我们在上一节讲多线程编程的时候所提倡的。这非常值得我们学习。

3. 调度器跟踪

我们在上一小节说过,系统监测器会在必要的时候打印出调度器跟踪信息。实际上,我们可以通过设置操作系统的一个环境变量来对此进行控制。这个环境变量的名字是GODEBUG。它控制着调试信息的输出。这其中包含了调度器跟踪信息。我们如果想让Go程序在被运行的同时打印出调试跟踪信息,就需要在此之前设置好这个环境变量。

环境变量GODEBUG的值可以由若干个键值对组成。键和值之间需要用等号“=”分隔,而多个键值对之间需要用英文逗号“,”分隔。目前,可以出现在此环境变量中的键有3个,其中两个是与调度器跟踪信息有关的。它们是schedtracescheddetail

当我们设置键schedtrace的值为X的时候,就意味着系统监测器会每X毫秒打印一个单行信息到操作系统的错误输出上。这一行信息中包含了调度器状态的概要。如果我们在有效设置schedtrace(即其值X大于0)的前提下将scheddetail的值设定为1,那么系统监测器就会每X毫秒向操作系统的错误输出上打印一个多行信息。其中包括了调度器以及所有现存的M、P和G的状态。

我们现在来举例说明,操作系统依然为Ubuntu 12.10 32bit。下面是一个极其简单的命令源码文件:

  1. package main
  2. import (
  3. "time"
  4. )
  5. func main() {
  6. for i := 0; i < 10; i++ {
  7. go func() {
  8. time.Sleep(5 * time.Second)
  9. }()
  10. time.Sleep(time.Second)
  11. }
  12. }

我们把这个命令源码文件就命名为schedtrace.go。存放它的目录无关紧要。它的目的非常明确,就是要创建出10个Goroutine并让它们并发的运行一段时间。我们在运行它之前,需要先设置好环境变量。若要让系统监测器每2秒打印出一个单行信息,环境变量GODEBUG应该这样被设置:

  1. export GODEBUG=schedtrace=2000

好了,现在可以运行这个源码文件了。执行的命令及其输出如下:

  1. hc@ubt:~$ go build schedtrace.go
  2. hc@ubt:~$ ./schedtrace
  3. SCHED 0ms: gomaxprocs=1 idleprocs=0 threads=4 idlethreads=0 runqueue=1 [0]
  4. SCHED 2010ms: gomaxprocs=1 idleprocs=1 threads=5 idlethreads=2 runqueue=0 [0]
  5. SCHED 4019ms: gomaxprocs=1 idleprocs=1 threads=5 idlethreads=2 runqueue=0 [0]
  6. SCHED 6026ms: gomaxprocs=1 idleprocs=1 threads=5 idlethreads=2 runqueue=0 [0]
  7. SCHED 8030ms: gomaxprocs=1 idleprocs=1 threads=5 idlethreads=2 runqueue=0 [0]
  8. SCHED 10032ms: gomaxprocs=1 idleprocs=1 threads=5 idlethreads=2 runqueue=0 [0]

每一个单行的调度跟踪信息的格式都是相同的。我们以最后一行为例解释一下其中的内容。SCHED表示此行信息是调度器跟踪信息,紧随其后的是10032ms。它说明该行信息是在Go程序运行后10.032秒的时候被生成出来的。在冒号“:”之后的内容即概括的描述了调度器内部的情况。这些内容其实就是以空格“ ”分隔的5个键值对。其中的5个键gomaxprocsidleprocsthreadsidlethreadsrunqueue分别代表了P最大数量(也就是P的总数量)、空闲P的数量、M的总数量、空闲M的数量,以及调度器的可运行G队列中的G的数量。而在单行信息最后的、以方括号“[”和“]”括起来的数字则表示了唯一的那个P的可运行G队列中的G的数量。为什么只有一个P?那是因为默认情况下的P最大数量为1。如果P最大数量大于1,那么在这个方括号中的就会是以空格“ ”分隔的多个数字。数字的数量与P最大数量的值相同。它们出现的顺序则与它们被生成的顺序一致。例如,若P最大数量为3则一个单行的调度跟踪信息会像这样:

  1. SCHED 10020ms: gomaxprocs=3 idleprocs=3 threads=6 idlethreads=3 runqueue=0 [0 0 0]

请注意,它与前面展示的调度跟踪信息是不同的。如果我们想深究调度器内部的具体运作情况,那么就需要修改一下环境变量GODEBUG的值,像这样:

  1. export GODEBUG=schedtrace=2000,scheddetail=1

在这之后,当我们再次运行Go程序的时候就会看到比上面多得多的输出内容。我们依然以最后一个调度跟踪信息为例,如下:

  1. SCHED 10017ms: gomaxprocs=3 idleprocs=3 threads=6 idlethreads=3 runqueue=0 gcwaiting=0 nmidlelocked=1 nmspinning=0 stopwait=0 sysmonwait=0
  2. P0: status=0 schedtick=10 syscalltick=4 m=-1 runqsize=0/128 gfreecnt=2
  3. P1: status=0 schedtick=12 syscalltick=4 m=-1 runqsize=0/128 gfreecnt=0
  4. P2: status=0 schedtick=6 syscalltick=8 m=-1 runqsize=0/128 gfreecnt=1
  5. M5: p=-1 curg=-1 mallocing=0 throwing=0 gcing=0 locks=0 dying=0 helpgc=0 spinning=0 lockedg=-1
  6. M4: p=-1 curg=-1 mallocing=0 throwing=0 gcing=0 locks=0 dying=0 helpgc=0 spinning=0 lockedg=-1
  7. M3: p=-1 curg=2 mallocing=0 throwing=0 gcing=0 locks=0 dying=0 helpgc=0 spinning=0 lockedg=-1
  8. M2: p=-1 curg=-1 mallocing=0 throwing=0 gcing=0 locks=0 dying=0 helpgc=0 spinning=0 lockedg=-1
  9. M1: p=-1 curg=-1 mallocing=0 throwing=0 gcing=0 locks=1 dying=0 helpgc=0 spinning=0 lockedg=-1
  10. M0: p=-1 curg=4 mallocing=0 throwing=0 gcing=0 locks=0 dying=0 helpgc=0 spinning=0 lockedg=-1
  11. G1: status=4(sleep) m=-1 lockedm=-1
  12. G2: status=3() m=3 lockedm=-1
  13. G10: status=4(sleep) m=-1 lockedm=-1
  14. G4: status=3(stack grouth) m=0 lockedm=-1
  15. G11: status=4(sleep) m=-1 lockedm=-1
  16. G6: status=6(sleep) m=-1 lockedm=-1
  17. G7: status=6(sleep) m=-1 lockedm=-1
  18. G8: status=6(sleep) m=-1 lockedm=-1
  19. G9: status=4(sleep) m=-1 lockedm=-1
  20. G12: status=4(sleep) m=-1 lockedm=-1
  21. G13: status=4(sleep) m=-1 lockedm=-1

乍一看,如此多的信息让人有些无所下手。但实际上,它们却是非常规整和有规律的。

在以SCHED开始的第一行内容中多出了5个键值对。这些额外的键的含义如下。

  • gcwaiting:表示垃圾回收任务是否正在被准备或执行。0代表否,1代表是。

  • nmidlelocked:表示已被锁住且空闲的M的数量。

  • nmspinning:表示正在自旋的M的数量。简单来讲,未被停止且还没有找到可运行G的M都被认为是正在自旋的M。

  • stopwait:表示在垃圾回收准备期间还未被停止的P的数量。

  • sysmonwait:表示系统监测器是否正在被阻塞。0代表否,1代表是。

可以看到,其中的大部分键所代表的状态都与垃圾回收任务相关。但实际上,它们与所有的在被执行期间对调度工作进行停止(“Stop the world”)和重启(“Start the world”)的任务都是相关的。只不过垃圾回收任务是其中最主要的一个任务。其他任务还有改变P最大数量的任务,等等。这一点请读者注意。

从第二行开始,每一行都表示了一个核心元素(M、P或G)的内部状态。

P0P1P2开始的内容分别表示了3个P的内部状态。在每一行内容中都包含了6个键值对。它们的含义如下。

  • status:代表了当前P的状态。我们知道,P的状态共用5个。这里用正整数04分别代表Pidle、Prunning、Psyscall、Pgcstop和Pdead状态。

  • schedtick:表示当前P中的G被运行的次数。

  • syscalltick:表示当前P中的G完成系统调用的次数。

  • m:表示与当前P关联的M的ID。若未关联则值为-1。

  • runqsize:表示当前P的可运行G队列中G的数量及其长度。例如,0/128代表该队列的长度为128但其中没有G。

  • gfreecnt:表示当前P的自由G队列中G的数量。

M开始的内容分别代表了现存的6个M的内部状态。每行的键都有10个。它们的含义如下。

  • p:表示与当前M关联的P的ID。若未关联则值为-1

  • curg:表示正在当前M上运行的G的ID。若没有则值为-1

  • mallocing:表示是否正在为当前M上运行的程序分配内存。0代表否,1代表是。

  • throwing:表示在当前M上运行的运行时系统是否抛出了异常。0代表否,1代表是。另外,-1代表有异常被抛出但不会打印运行时的栈信息。

  • gcing:表示垃圾回收任务是否正在被执行。0代表否,1代表是。如前文所述,更广泛地讲,此值代表了调度工作是否已被停止。

  • locks:表示在当前M上运行的运行时系统持有的(象征性的)锁的数量,被用于一些内部的关键任务的执行计数和协调。

  • dying:表示在当前M上运行的程序是否已经引发了运行时恐慌。0代表否,1代表是。

  • helpgc:表示当前M是否需要执行垃圾回收任务。0代表不需要,否则代表需要。其中,-1代表当前M是调度器专为进行垃圾回收而创建的,而大于0的整数则表示当前M即将或已经开始执行垃圾回收任务且此整数即为专用序号(并发执行此任务的M可能有多个)。

  • spinning:表示当前M是否正在自旋。

  • lockedg:与当前M锁定的G的ID。若没有则值为-1

最后,以G开始的内容则分别代表了现存的某个G的内部状态。其中的3个键的含义如下。

  • status:代表了当前G的状态。这里用从06的正整数表示G的7种状态,即Gidle、Grunnable、Grunning、Gsyscall、Gwaiting、Gmoribund_unused和Gdead。其中,Gmoribund_unused状态至今未被使用,所以我们在前面也没有对它进行介绍。另外,紧随在状态之后的圆括号“(”和“)”会包含该G处于此状态的原因。该原因会以一个短语的方式呈现。

  • m:表示正在运行当前G的M的ID。若没有则值为-1

  • lockedm:表示与当前G锁定的M的ID。若没有则值为-1

至此,我们已经对调度跟踪信息中的所有键都做了必要的说明。此后,我们就可以轻松地看懂这些内容了。

当我们想了解Go程序中的各个Goroutine的运作情况的时候,就可以设置环境变量GODEBUG、运行Go程序并查看其输出的调度跟踪信息。这些信息可以让我们清楚地看到Go语言的调度器的实时调度操作。通过对这些信息的分析,我们就能够发现Go程序本身以及其运行过程中的一些问题。另外,让Go程序在被运行的同时打印调度跟踪信息,只需要设置一下环境变量,而并不需要对程序本身进行任何修改。这样的好处是显而易见的。

在本小节,我们对Go语言的并发编程模型及其调度器的一些细节进行了进一步的介绍。显然,在编程过程中,对我们最有用处的应该是对调度跟踪信息的解读。不过,对m0和g0的简短说明也让我们更深入地了解了Go语言的运行时系统引导Go程序运行以及执行一些管理任务(包括调度、垃圾回收,等等)的方式方法。由于运行时系统一般会使用多个M并发的执行这些任务,所以它自己也会用到各种操作系统提供的同步方法。我们在前面也已对此进行了强调。