54

Go语言下的线程模型

 5 years ago
source link: https://studygolang.com/articles/16126?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

阅读Go并发编程对go语言线程模型的笔记,解释的非常到,好记性不如烂笔头,忘记的时候回来翻一番,在此做下笔记。

Go语言的线程实现模型,又3个必知的核心元素,他们支撑起了这个线程实现模型的主要框架:

1>M:Machine的缩写。一个M代表一个内核线程。

2>P:Procecssor的缩写。一个P代表了M所在的上下文环境。

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

简单的来说,一个G的执行文件需要M和P的支持。一个M在与一个P关联形成一个有效的G运行环境(内核线程+上下文环境)。

每个P都会包含一个可运行的G的队列(runq)。该队列的G会被依次传给与本地P关联的M并获得运行时机。在这里,

我们把运行当前程序的那个M称为当前M,而把与当前M关联的那个P称为本地P。

M(Machine)与KSE(Kernel Schedule Entity)之间总一对一的。一个M能且仅代表一个内核线程。Go语言的运行

时系统(runtime system)用它来代表一个内核调度系统。

1.M(Machine)

一个M代表了一个内核线程。大多数情况下,创建一个M的原因都是由于没有足够的M来关联P(Process)

并运行其中的可运行的G。不过,在运行时系统执行系统监控或垃圾回收等任务的时候也会

导致新的M的创建。M(Machine)的数据结构包括(curg p mstartfn nextp)。

M3qQ7rj.png!web

image.png

M(Machine)结构中的字段众多。我们在这里只是挑选了对于我们的初步认识M(Machine)最重要的4个字段。其中字段

curg会存放当前M正在运行的那个G(goroutine)的指针,字段p会指向与当前M相关联的那个P,而字段mstartfm则代表

我们马上会讲到的M(Machine)的起始函数。在M被调度的过程中,这三个字段最能体现他的即使情况。而另外的字段nextp则

会被用于暂存与当前M(Machine)又潜在关系的P。我们可以把调度器将某个P(Process)赋值给某个M的nextp字段的操作称为

M和P的预联。在有些时候,运行时系统给会把刚刚被重启新启用的M(Machine)和它预联的那个P关联在一起,这就是nextp字段的所起到的作用。

M被创建之初会被加入全局的M(Machine)列表(runtie.allm)中。紧接着,它的起始函数和准备关联的P(Process)(大多数

情况下导致次M(Machine)创建操作的那个P(Process))会被设置。最后,运行时系统会为它专门创建一个新的内核线程并与之

关联。这样,这个新的M(Machine)就为执行G(Goroutine)做好了准备。而这里的全局M(Machine)列表其实并没有什么特殊的意义。

运行时系统在需要的时候会通过它获取所有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程序的时候,一个引导程序先会被启动。

这个引导程序先会被启动,这个初始值是1w。也就是说,一个Go程序最多可以使用1w个M。

这就以为着。在最理想的情况下,同时可以有1w个内核线程同时被执行。请注意,这里说的是最理想的i情况下的。

由于操作系统的内核对进程的虚拟内存的布局的控制以及大小的限制,如此量级的线程很难共存。从这个角度看。

Go语言本身对线程的线程数量几乎可以被忽略。

出了上述设置外,我们也可以在Go程序中对该限制进行设置。为了达到此目的,我们需要调用标准库的代码包runtime/debug包

中的SetMaxThreads函数并且对提供新的M最大数量。runtime/debug.SetMaxThreads函数在执行后,会把旧的M最大数量作为结果

值返回。非常重要的一点是,如果我嫩在调用runtime/debug.seMaxThreads函数时给定的新值比当时M的实际数量还要小的话,

运行时系统就会发起一个运行时恐慌。所以,我们要小心使用这个函数。请记住,如果我们真的需要设置M的最大数量。

那么也早调用runtime/debug.SetMaxThreads函数就也好,对于它的设定值,我们也要仔细斟酌。

2.P(Process)

P(Process)是使G能够在M中运行的关键。Golang的运行时系统会实时地让P与不同的M建立或断开关联,以使P中的那些可运行的

G能够在需要的时候及时获得运行时机。这与操作系统内核在CPU之上切换不同的进程或者线程类似。

通过调用函数runtime.GOMAXPROCS,我们可以改变单个Go程序可以间接拥有的P的最大数量。初除此自外,我们还可以在运行Go程序

之前设置环境变量GOMAXPROCS的值对Go程序的可以用的P最大的数量做出预先设定。P的最大数量相当于是对可以被并发运行的用户

级别的G的数量做出限制。我们已经知道,每个P都需要关联一个M(Machine)才能使其中的可运行的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的队列中,当

环境变量GOMAXPROCS的值的有效就会被这个硬性限制取代,也就是说,最终的P最大数量值绝对不会比引导程序中的这个硬性

上线值打。该硬性上限值是2的8次方。即256.这个硬性上限值为256的原因是Go语言目前还不能保证在数量比256更多的P同时存在的

情况下Go程序仍能保持高效。也就是说,这个硬行上线并不是永久的,它在以后可能会被改变

[ https://stackoverflow.com/questions/40943065/golang-why-runtime-gomaxprocs-is-limited-to-256] 现在是1024了。

注意,虽然我们可以在程序中随意地调用runtime.GOMAXPROCS函数,但是它的执行会暂时使所有的P都相继进入停止状态并试图

阻止任何用户级别的G的运行。只有在新的P最大数量被设定完成后,运行时系统才会开始陆续恢复它们。对于程序的性能是

非常大的损耗。所以,我们只好在Go程序的main函数的开始处调用runtime.GOMAXPROCS函数。当然,在Go程序中不对它进行

调用而只预先设置环境变量GOMAXPROCS是最好不过的了

在确定P的最大数量之后,运行时系统会根据这个数值初始化全局的P列表(runtime.allp)与全局M列表类似,该列表包含了当前

运行时的系统创建的所有P。随后,运行时系统会把调度器的可运行G队列(runtime.sched.runq)中的所有G均匀的放入到全局

列表中。至此,运行时系统需要用到的所有P都已就绪

与空闲M列表类似,所运行时系统中也存在一个调度器的空闲P列表(runtime.sched.pidle)。当一个P不再与任何M关联的时候,

运行时系统就会把它放入到该列表,当前运行时系统需要一个空闲的P关联某个M的话,会从次列表取一个出来,由此我们也可知道

空闲P列表的准入条件,注意,即使P进入到了空闲P列表中,它的运行G列表也不一定是空的,两者之间没有必然的联系。

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

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

2>Prunning:此状态表明当前P与某个M关联。

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

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

5>Pdead:此状态表明当前P已经不会再被调用。当我们Go程序运行的过程中通过调用。

a2aiia7.png!web

image.png

runtime.GOMAXPROCS函数减少P最大数量的时候,其余的P就会被运行时系统置于此状态。P的初始状态是Pgcstop,

虽然运行时系统并不会再这时进行垃圾回收。不过,P处于这一初始状态的时间会非常短暂。紧接着的初始化和填充P中的可

运行G队列之后,运行时系统会被其状态设置未Pidle并放入到调度器的空闲列表中。此空闲P列表中的所有P都有调度器根据实际

情况经进行取用。

3.G(Goroutine)

一个G就相当于一个Goroutine(或称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都会由运行时系统根绝其实际状态情况设置不同的状态,其可能的状态如下。

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

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

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

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

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

6>Gdead:表示当前G已经被运行完成

在运行时系统想用一个G封我们通过go语句递交的go函数的时候,会对这个G进行初始化。其中的一步就是初始化这个G的

状态,而这个状态总会是Grunnable。也就是说,一个G真正的开始被使用是在其状态被设置Grunnable之后。

UvQBJ3e.png!web

image.png

一个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语句的背后所蕴含的玄机。

核心元素容器

fmqmimy.png!web

image.png

在这些容器中,全局的那个3个列表存在的主要目的都分别是为了统计运行时系统中的所有M,P或G。

相比之下。最应该值得我们关注的是那些非全局的容器,尤其是与G相关的那4个容器。

与G有关的非全局容器有可运行G队列,调度器的自由G列表,本地P的可运行G队列以及本地P的自由G列表。

运行时系统创建出的任何G都回存在于全局G列表中,而其余的4个列表则只存放在当前作用域的所有特定

的状态的G。注意,这里的两个可运行G列表中的G都拥有几乎平等的运行机会。由于这种平等性的存在,所以

我们无需关心哪类可运行的G会进入到哪一个队列中,不过。可以顺便提一下,从Gsyscall状态和Ggstop状态转出

的G,都会被放入调度器的可运行G队列,而被运行时系统初始化的G,都会被放入本地P的可运行G队列。至于

从Gwaiting状态转出的G,除了因进行网络I/O而陷入等待的G之外,都会被存放到本地P的可运行G队列。此外,

我们之前说过,对runtine.GOMAXPROCS函数的调用,可能会导致运行时系统清空调度器的可运行G队列。其中的

所有G都会被均匀地放入到全局P列表这种的各个P的可运行G对了当中。另一方面在G转入Gdead状态后,首先会被

放入本地P的自由G列表,而在运行时系统需要用自由G封装go函数的时候,也会尝试从本地P的自由G列表中获取。

调度器的自由G列表只是起到了一个暂存自由G的作用。

与M和P相关的非全局容器分别是调度器的空闲M列表和调度器的空闲P列表。这两个列表都被用于存放暂时不被

使用的元素的实例。在运行时系统有需要的时候,会从中获取i相应的元素的实例重新启动该它。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK