Go的线程实现模型

Go的线程实现模型

{%}

作者/郝林

从业12年有余的软件工匠,国内知名的Go语言技术布道者,Go语言北京用户组和GoHackers社群的发起人和组织者,多套免费在线Go语言教程的作者,深信Go语言在人工智能时代和机器人时代也能大放异彩的科技信徒。

Go不推荐使用共享内存的方式传递数据,而推荐使用channel(或称“通道”)在多个goroutine之间传递数据,同时保证整个过程的并发安全性。不过,作为可选方法,Go依然提供了一些传统的同步方法(比如互斥量、条件变量等)。

在操作系统提供的内核线程之上,Go搭建了一个特有的两级线程模型。我们可以将goroutine看作是Go特有的应用程序线程。但是,goroutine背后的支撑体系远没有这么简单。

说起Go的线程实现模型,有三个必知的核心元素,它们支撑起了这个模型的主框架。

  • M:machine的缩写。一个M代表一个内核线程,或者“工作线程”。

  • P:processor的缩写。一个P代表执行一个Go代码片段所必需的资源(或称“上下文环境”)。

  • G:goroutine的缩写。一个G代表一个Go代码片段。前者是对后者的一种封装。

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

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

{%}

图2-1

{%}

图2-2

可以看到,M与KSE之间总是一对一的关系,一个M能且仅能代表一个内核线程。Go的运行时系统(runtime system)用M代表一个内核调度实体。M与KSE之间的关联非常稳固,一个M在其生命周期内,会且仅会与一个KSE产生关联。相比之下,M与P、P与G之间的关联都是易变的,它们之间的关系会在实际调度的过程中改变。此外,M与G之间也会建立关联,因为一个G终归会由一个M来负责运行;它们之间的关联会由P来牵线。注意,由于M、P和G之间的关系在实际调度过程中多变,图2-2中的可能关联仅能作为一般性的示意。

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

M(内核线程)

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

{%}

图2-3 M的结构(部分)

M结构中的字段众多,这里只挑选了几个最重要的字段。列表中,每一行都展示了一个字段,左边是字段名,右边是字段类型。其中,字段g0表示一个特殊的goroutine。这个goroutine是Go运行时系统在启动之初创建的,用于执行一些运行时任务。字段mstartfn表示M的起始函数,这个函数其实就是我们在编写go语句时携带的那个函数。字段curg会存放当前M正在运行的那个G的指针,而字段p的值则会指向与当前M相关联的那个P。mstartfncurgp最能体现当前M的即时情况。此外,字段nextp用于暂存与当前M有潜在关联的P。让调度器将某个P赋给某个M的操作由nextp字段控制,称为对M和P的预联。运行时系统有时候会把刚刚重新启用的M和已与它预联的那个P关联在一起,这也是nextp字段的主要作用。字段spinningbool类型的,它用于表示这个M是否正在寻找可运行的G。在寻找过程中,M会处于自旋状态。这也是该字段名的由来。Go运行时系统可以把一个M和一个G锁定在一起。一旦锁定,这个M就只能运行这个G,这个G也只能由该M运行。标准库代码包runtime中的函数LockOSThreadUnlockOSThread,也为我们提供了锁定和解锁的具体方法。M的字段lockedg表示的就是与当前M锁定的那个G(如果有的话)。

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

在新M被创建之后,Go运行时系统会先对它进行一番初始化,其中包括对自身所持的栈空间以及信号处理方面的初始化。在这些初始化工作都完成之后,该M的起始函数会被执行(如果存在的话)。注意,如果这个起始函数代表的是系统监控任务的话,那么该M会一直执行它,而不会继续后面的流程。否则,在起始函数执行完毕之后,当前M将会与那个预联的P完成关联,并准备执行其他任务。M会依次在多处寻找可运行的G并运行它。这一过程也是调度的一部分。有了M,Go程序的并发运行基础才得以形成。

运行时系统管辖的M(或者说runtime.allm中的M)有时候也会被停止,比如在运行时系统执行垃圾回收任务的过程中。运行时系统在停止M的时候,会把它放入调度器的空闲M列表(runtime.sched.midle)。这很重要,因为在需要一个未被使用的M时,运行时系统会先尝试从该列表中获取。M是否空闲,仅以它是否存在于调度器的空闲M列表中为依据。

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

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

P(执行一个Go代码片段所必需的资源)

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

改变单个Go程序间接拥有的P的最大数量有两种方法。第一种方法,调用函数runtime.GOMAXPROCS并把想要设定的数量作为参数传入。第二种方法,在Go程序运行前设置环境变量GOMAXPROCS的值。P的最大数量实际上是对程序中并发运行的G的规模的一种限制。P的数量即为可运行G的队列的数量。一个G在被启用后,会先被追加到某个P的可运行G队列中,以等待运行时机。一个P只有与一个M关联在一起时,才会使其可运行G队列中的G有机会运行。不过,设置P的最大数量只能限制住P的数量,而对G和M的数量没有任何约束。当M因系统调用而阻塞(更确切地说,是它运行的G进入了系统调用)的时候,运行时系统会把该M和与之关联的P分离开来。这时,如果这个P的可运行G队列中还有未被运行的G,那么运行时系统就会找到一个空闲M,或创建一个新的M,并与该P关联以满足这些G的运行需要。因此,M的数量在很多时候也都会比P多。而G的数量,一般取决于Go程序本身。

在Go程序启动之初,引导程序会在初始化调度器时,对P的最大数量进行设置。这里的默认值会与当前CPU的总核心数相同。一旦发现环境变量GOMAXPROCS的值大于0,引导程序就会认为我们想要对P的最大数量进行设置。它会先检查一下此值的有效性:

如果不大于预设的硬性上限值(256),就认为是有效的,否则就会被这个硬性上限值取代。也就是说,最终的P的最大数量值绝不会比硬性上限值大。硬性上限值是256的原因是,Go目前还不能保证在256多个P同时存在的情形下仍然保持高效。不过,这个硬性上限值并不是永久的,它可能会在未来改变。

注意,虽然Go并未对何时调用runtime.GOMAXPROCS函数作限制,但是该函数调用的执行会暂时让所有的P都脱离运行状态,并试图阻止任何用户级别的G的运行。只有在新的P最大数量设定完成之后,运行时系统才开始陆续恢复它们。这对于程序的性能是非常大的损耗。所以,你最好只在Go程序的main函数的最前面调用runtime.GOMAXPROCS函数。当然,不在程序中改变P的最大数量最好不过,实际上在大多数情况下也无需改变。

确定P的最大数量之后,运行时系统会根据这个数值重整全局的P列表(runtime. allp)。与全局M列表类似,该列表中包含了当前运行时系统创建的所有P。运行时系统会把这些P中的可运行G全部取出,并放入调度器的可运行G队列中。这是调整全局P列表的一个重要前提。被转移的那些G,会在以后经由调度再次放入某个P的可运行G队列。

与空闲M列表类似,运行时系统中也存在一个调度器的空闲P列表(runtime.sched.pidle)。当一个P不再与任何M关联的时候,运行时系统就会把它放入该列表;而当运行时系统需要一个空闲的P关联某个M的话,会从此列表中取出一个。注意,P进入空闲P列表的一个前提条件是它的可运行G列表必须为空。例如,在重整全局P列表的时候,P在被清空可运行G队列之后,才会被放入空闲P列表。

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

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

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

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

  • Pgcstop 此状态表明运行时系统需要停止调度。例如,运行时系统在开始垃圾回收的某些步骤前,就会试图把全局P列表中的所有P都置于此状态。

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

P在创建之初的状态是Pgcstop,虽然这并不意味着运行时系统要在这时进行垃圾回收。不过,P处于这一初始状态的时间会非常短暂。在紧接着的初始化之后,运行时系统会将其状态设置为Pidle,并放入调度器的空闲P列表。图2-4描绘了P在各个状态之间进行流转的具体情况。

{%}

图2-4 P的状态转换

可以看到,非Pdead状态的P都会在运行时系统欲停止调度时被置于Pgcstop状态。不过,等到需要重启调度的时候(如垃圾回收结束后),它们并不会被恢复至原有状态,而会被统一地转换为Pidle状态。也就是说,它们会被放到同一起跑线上,并公平地接受再次调度。另一方面,非Pgcstop状态的P都可能因全局P列表的缩小而被认为是多余的,并被置于Pdead状态。不过,我们并不用担心其中的G会失去归宿。因为,在P被转换为Pdead状态之前,其可运行G队列中的G都会被转移到调度器的可运行G队列,而它的自由G列表中的G也都会被转移到调度器的自由G列表中。

正如前面所述,每个P中除了一个可运行G队列外,还都包含一个自由G列表。这个列表中包含了一些已经运行完成的G。随着运行完成的G的增多,该列表可能会很长。如果它增长到一定程度,运行时系统就会把其中的部分G转移到调度器的自由G列表中。另一方面,当使用go语句启用一个G的时候,运行时系统会先试图从相应P的自由G列表中获取一个现成的G,来封装这个go语句携带的函数(也称go函数),仅当获取不到这样一个G的时候才有可能创建一个新的G。考虑到相应P的自由G列表为空而获取不到自由G的情况,运行时系统会在发现其中的自由G太少时,预先尝试从调度器的自由G列表中转移过来一些G。如此一来,只有在调度器的自由G列表也弹尽粮绝的时候,才会有新的G被创建。这在很大程度上提高了G的复用率。

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

G(Go代码片段)

一个G就代表一个goroutine(或称Go例程),也与go函数相对应。作为编程人员,我们只是使用go语句向Go的运行时系统提交了一个并发任务,而Go的运行时系统则会按照我们的要求并发地执行它。

Go的编译器会把go语句变成对内部函数newproc的调用,并把go函数及其参数都作为参数传递给这个函数。这也是你应该了解的第一件与go语句有关的事。其实它并不神秘,只是一种递送并发任务的方法而已。

运行时系统在接到这样一个调用之后,会先检查go函数及其参数的合法性,然后试图从本地P的自由G列表和调度器的自由G列表获取可用的G,如果没有获取到,就新建一个G。与M和P相同,运行时系统也持有一个G的全局列表(runtime.allgs)。新建的G会在第一时间被加入该列表。类似地,这个全局列表的主要作用是:集中存放当前运行时系统中的所有G的指针。无论用于封装当前这个go函数的G是否是新创建的,运行时系统都会对它进行一次初始化,包括关联go函数以及设置该G的状态和ID等步骤。在初始化完成后,这个G会立即被存储到本地P的runnext字段中;该字段用于存放新鲜出炉的G,以求更早地运行它。如果这时runnext字段已存有一个G,那么这个已有的G就会被“踢到”该P的可运行G队列的末尾。如果该队列已满,那么这个G就只能追加到调度器的可运行G队列中了。

在特定情况下,一旦新启用的G被存于某地,调度就会立即进行以使该G尽早被运行。不过,即使这里不立即调度,我们也无需担心,因为运行时系统总是在为及时运行每个G忙碌着。

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

  • Gidle 表示当前G刚被新分配,但还未初始化。

  • Grunnable 表示当前G正在可运行队列中等待运行。

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

  • Gsyscall 表示当前G正在执行某个系统调用。

  • Gwaiting 表示当前G正在阻塞。

  • Gdead 表示当前G正在闲置。

  • Gcopystack 表示当前G的栈正被移动,移动的原因可能是栈的扩展或收缩。

除了上述状态,还有一个称为Gscan的状态。不过这个状态并不能独立存在,而是组合状态的一部分。比如,GscanGrunnable组合成Gscanrunnable状态,代表当前G正等待运行,同时它的栈正被扫描,扫描的原因一般是GC(垃圾回收)任务的执行。又比如,GscanGrunning组合成Gscanrunning状态,表示正处于Grunning状态的当前G的栈要被GC扫描时的一个短暂时刻。简单起见,我不会在下面对这些组合状态进行说明。你只要知道这些组合状态会在GC扫描发生时出现就可以了。

之前讲过,在运行时系统想用一个G封装go函数的时候,会先对这个G进行初始化。一旦该G准备就绪,其状态就会被设置成Grunnable。也就是说,一个G真正开始被使用是在其状态设置为Grunnable之后。图2-5展示了G在其生命周期内的状态流转情况。

{%}

图2-5 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运行时系统中的流转方式和状态转换时机。

我一直把实现和操纵Go的线程实现模型的内部程序笼统地称为“运行时系统”。实际上,它可以更明确地称为“调度器”。一个Go程序中只会存在一个调度器实例。它拥有自己的结构,同时依次提供了很多重要的运行时功能。其中一些功能其实已经讲到了。只不过,我是围绕着各种模型元素(M、P、G以及各种容器)讲解的。

 

{%}

《Go并发编程实战2》首先介绍了Go语言的优秀特性、安装设置方法、工程结构、标准命令和工具、语法基础、数据类型以及流程控制方法,接着阐述了与多进程编程和多线程编程有关的知识,然后重点介绍了goroutine、channel以及Go提供的传统同步方法,最后通过一个完整实例——网络爬虫框架进一步阐述Go语言的哲学和理念,同时分享作者在多年编程生涯中的一些见解和感悟。

与上一版相比,本书不仅基于Go 1.8对上一版进行了全面更新,而且更深入地描绘了Go运行时系统的内部机理,并且大幅改进了示例代码。