地鼠宝宝的轶事奇闻之线程模型

编程入门 行业动态 更新时间:2024-10-28 03:22:43

<a href=https://www.elefans.com/category/jswz/34/1593092.html style=地鼠宝宝的轶事奇闻之线程模型"/>

地鼠宝宝的轶事奇闻之线程模型

Golang并发机制

线程模型

在操作系统提供的内核线程之上,go搭建了一个两级线程模型。毫无疑问,其中一级必然是内核线程,另一级便是goroutine。你可以将goroutine看作是应用程序线程。

goroutine一词是由go的开发者们专门创建的,它所表达的含义是:

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

三大核心

go的线程模型由三个核心元素支撑:

  • M(machine):一个M代表一个内核线程。

  • P(processor):一个P代表执行一个go代码片段所必须的资源(上下文环境)。

  • G(goroutine):一个G代表一个go代码片段,goroutine是对go代码片段的一种封装。

不要惊讶于这里出现的三大核心元素。它们并不是新概念,我们之前已经见过它们了。在golang并发初探一文中,我们提到了操作系统线程,逻辑处理器和goroutine三个词,它们和这里的M、P、G是一一对应的。所谓的逻辑处理器就是这里的P,也就是上下文环境,所以我说前文是很不负责的。

有关M、P、G的声明都在runtime包的runtime2.go文件中。

  1. 一个G的执行需要P和M的支持;

  2. 一个M和一个P关联后就形成了一个有效的G运行环境(内核线程+上下文环境);

  3. 每个P都会包含一个可运行的G的队列;

  4. 队列中的G会被依次传递给与本地P关联的M执行。

M、P、G、KSE(内核调度实体)之间的关系

  1. M与KSE之间总是一对一的关系,一个M能且仅能代表一个内核线程。M与KSE之间的关联非常稳固,一个M在其生命周期内,会且仅会与一个KSE产生关联。

  2. M与P之间也总是一对一的,但是M与P的关联可能在调度过程中改变。

  3. P与G之间是一对多的关系,因为每个P都包含一个可运行的G队列。同样,它们之间的关联也会在调度中改变。

  4. M与G之间也会建立关联,因为一个G终归由一个M来运行。它们之间的关联通过P来牵线。

M

 type m struct {             g0        *g        //一个特殊的goroutine,由go运行时系统在启动之初创建,用于执行运行时任务。            mstartfn  func()    //在新的M上启动某个特殊任务的函数,可能系统监控,GC辅助或M自旋。            curg      *g        //当前M正在运行的那个G(goroutine)的指针。            p         puintptr  //与当前M关联的P(上下文环境)。            nextp     puintptr  //暂存与当前M有潜在关联的P,将P赋给M的nextp字段称为M和P的预联。             spinning  bool      //当前M是否正在寻找可运行的G。寻找过程中,M处于自旋状态。             lockedg   *g        //与当前M锁定的G。一旦锁定,这个M只能运行这个G,这个G也只能由该M运行。            thread    uintptr   //线程句柄。真正用来执行go代码的系统线程。 
}

 

字段p的作用是当前M与一个P关联,字段nextp的作用是将当前M与一个P预联。我们知道,一个M和一个P关联以后才是一个G的运行环境。那么预联有什么用呢?运行时系统有时会把刚重新启动的M和已与它预联的P关联在一起,也就是将nextp字段的值赋给p字段,这就是nextp字段的主要作用。

 

除了lockedg字段可以将一个M和一个G锁定之外,runtime包中的函数LockOSThresdUnlockOSThread也提供了锁定和解锁的方法。

以上列出的只是结构体m中的一部分字段

M的创建

M创建之初会做如下3件事:

  1. 将创建的M加入全局M列表(runtime.allm)中。

  2. 设置它的起始函数(mstartfn字段)和预联的P(nextp字段)。其中,起始函数仅当运行时系统要用此M执行系统监控或垃圾回收等任务时才会被设置。

  3. 运行时系统创建一个新的内核线程并与之关联,也就是赋给thread字段。

全局M列表除了可以通过它获取到所有M的信息以及防止M被当作垃圾回收掉之外,并没有特殊意义。

M的初始化

新的M创建之后,go运行时系统会对它进行初始化。包括自身所持栈空间以及信号处理方面的初始化。

初始化完成后,如果有起始函数,那么该M的起始函数就会执行。如果这个起始函数代表的是系统监控任务,那么该M会一直执行它,而不会继续后面的流程。

起始函数执行完毕以后,当前M会与那个预联的P完成关联,也就是将nextp字段的值赋给p字段。

然后M会依次在多处寻找可运行的G并运行之。这也是调度的一部分。

M的停止

运行时系统管辖的M(runtime.allm中的M)有时也会停止。比如在运行时系统执行垃圾回收任务的过程中。

运行时系统停止M后会将它放入调度器的空闲M列表(runtime.sched.midle)中。这很重要,因为当需要一个未被使用的M时,运行时系统会首先尝试从该列表中获取。M是否空闲,仅以它是否存在于调度器的空M列表中为依据。

M的限制

go程序运行前会先启动一个引导程序,用来为程序运行建立必要的环境。引导程序在初始化调度器时,会对M的最大数量进行初始化,初始值是1000。也就是说最多能创建一万个M为当前的go程序服务。

同时runtime/debug包的SetMaxThreads函数可以用来修改这一限制。该函数会返回旧的M数量的最大值。需要注意的是,如果你给定的新值比当时已有的M的数量小,运行时系统会立即引发一个运行时恐慌。所以调用这个函数一定要慎重,而且如果真的有必要,那么越早调用越好。因为调整的过程中会损耗部分性能。

P

P是goroutine的上下文环境,也是G能在M中运行的关键。go运行时系统会适时的让P与不同的M建立或断开连接,以使P中那些可运行的G能即使获得运行时机。

在go中,P也是一个结构体。

 type p struct {             lock      mutex             //互斥锁             status    uint32            //P的状态            m         muintptr          //和当前P绑定的M             runqhead  uint32            //可运行G队列的队头            runqtail  uint32            //可运行G队列的队尾            runq     [256]guintptr     //可运行G队列,固定长度为256             runnext   guintptr          //下一个要运行的G             gfree     *g                //自由G队列             gfreecnt  int32             //自由G队列长度 
}

 

上面只是p的部分字段,后面你会看到他们都有什么用。

 

设置P的最大数量

go提供了两种方法来设置程序拥有的P的最大数量:

  1. 调用runtime.GOMAXPROCS函数并将新值作为参数传入。该函数会返回旧值。

  2. 在go程序运行前,设置环境变量GOMAXPROCS的值。

还记得前面的引导程序吗?它在初始化调度器时,也会设置P的最大数量。默认与CPU核心数相同。但是如果设置了GOMAXPROCS环境变量,引导程序就会在检查该环境变量的有效性后将P的最大数量设置为该值。有效的最大P数量必须大于0,小于等于256。256是硬性的上限值,因为go目前无法保证在多于256个P同时存在的情况下,go程序还能保持高效。这个值不是永久的,也许将来会提高。P的最大值的作用是限制并发G的规模。

一个G在被启用后,会先被追加到某个P的可运行G队列中,以等待运行时机。当M运行的G进入系统调用而阻塞时,运行时系统会将该M和与之关联的P分离开。如果这个P的可运行G队列中还有未被运行的G,那么运行时系统会找一个空闲的M或创建一个新的M,并与该P关联,以运行这些G。因此M的数量常常多于P,从他们的最大值的限制上也能看出这一点。

runtime.GOMAXPROCS函数的调用会会暂时让所有P都脱离运行状态,并试图阻止任何用户G的运行。新的P最大数量设置完毕后,运行时系统才开始陆续恢复它们。这对程序性能是非常大的损耗。因此最好不要去调用它,万不得已的时候也也该尽量在main函数的最前面去调用。

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

当一个P不再与任何M关联并且该P的可运行G列表为空时,运行时系统就会把它放入调度器的空闲P列表(runtime.sched.pidle)。当运行时系统需要一个空闲M的时候也会首先从此列表中获取。

P的状态:

  • Pidle:空闲状态,表明当前P未与任何M关联。

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

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

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

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

P在创建之初的状态是Pgcstop,但这并不意味着运行时系统要进行垃圾回收。P出于这一状态的时间会非常短暂,在紧接着的初始化后,运行时系统会将其状态设置为Pidle并放入调度器的空闲P列表。

Pdead状态的P在运行时系统停止调度时都会被置于Pgcstop状态。重启调度时(如垃圾回收结束后),所有P都会被置于Pidle状态,而不是他们原来的状态。

Pgcstop状态的P都会因最大P数量的减小而被认为是多于的,并被置于Pdead状态。当P进入Pdead状态之前,该P的可运行G队列会被转移到调度器的可运行G队列,它的自由G列表会被转移到调度器的自由G列表。

每个P中有一个可运行的G队列,以及一个自由G列表。自由G列表中包含了已运行完成的G。随着已运行完成的G越来越多,该列表会不断增长。如果它增长到一定程度,运行时系统会把其中部分G转移到调度器的自由G列表。同样,当调度器发现其中的自由G太少时,会预先尝试从调度器的自由G列表中转移一些G过来。

当使用go语句启用一个G时,运行时系统会先从相应P的自由G列表中获取一个G来封装这个go语句的函数。仅当获取不到的时候,也就是调度器的自由G列表也空了,才会创建一个新的G。

G

一个G代表一个goroutine,也与go函数相对应。如你所想,G也是一个结构体,其中封装了一个go函数。

go编译器会把go语句变成对内部函数newProc的调用,并把go函数及其参数作为参数传递给newProc函数。它的作用就是创建一个G。该函数的调用会触发下面这一系列事情的发生:

  1. 检查go函数及其参数的合法性;

  2. 从本地P的自由G列表和调度器的自由G列表获取可用的G,如果没有就新建一个G;

  3. 新建的G会在第一时间加入到运行时系统的全局G列表(runtime.allgs),该列表用于存放当前运行时系统中所有G的指针;

  4. 无论用于封装当前go函数的G是否是新的,运行时系统都会对它进行一次初始化,包括关联go函数以及设置G的状态和ID等;

  5. 初始化完成后,这个G会立即被存储到本地P的runnext字段中。该字段用于存放新鲜出炉的G,以求尽早运行它;

  6. 如果runnext字段中已有一个G,那么这个已有的G会被移到该P的可运行G队列的末尾。如果该队列已满,就移到调度器的可运行G队列。

G的状态:

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

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

  • Grunning:当前G正在运行。

  • Gsyscall:当前G正在执行系统调用。

  • Gwaiting:当前G正在阻塞。

  • Gdead:当前G正在闲置。

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

  • Gidle

  • Gscanrunnable:当前G正等待运行,同时它的栈正被扫描。扫描原因一般是GC(垃圾回收)任务的执行。

  • Gscanrunning:当前G正在运行,同时它的栈正被扫描。

  • Gscansyscall:当前G正在执行系统调用,同时它的栈正被扫描。

  • Gscanwaiting:当前G正在阻塞,同时它的栈正被扫描。

  1. 一个G在创建之初是Gidle状态。只有被初始化之后,其状态才变成Grunnable。一个G真正开始被使用是在其状态设置为Grunnabel之后。

  2. 一个G在运行过程中是否会等待某个事件以及等待什么事件,完全由其封装的go函数决定。涉及通道操作,网络I/O以及操纵定时器和调用time.sleep函数会使G进入Gwaiting状态。

  3. 事件到来之后,等待的G会被唤醒,并置于Grunnable状态,等待运行。

  4. G在退出系统调用时,运行时系统会首先尝试直接运行这个G。仅当无法直接运行时,才会把它转换为Grunnable状态并放入调度器的可运行G队列。那么为什么不是放入本地P的可运行G队列呢?因为在G进入系统调用之后,本地P就与当前M分离开了。当G退出系统调用时,本地P已经不在了,也就是说这个G没有本地P,所以只能让调度器去接纳它了。

  5. 进入死亡状态(Gdead)的G会被放入本地P或调度器的自由G列表,可以在需要的时候重新初始化并使用。相比之下,P在进入死亡状态(Pdead)之后,只能面临销毁的结局。同样是“死”,但一个去往轮回,一个魂飞魄散。

M、P、G的容器

名称源码作用域说明
全局M列表runtime.allm运行时系统存放所有M的一个单向链表
全局P列表runtime.allp运行时系统存放所有P的一个数组
全局G列表runtime.allgs运行时系统存放所有G的一个切片
调度器的空闲M列表runtime.sched.midle调度器存放空闲M的一个单向链表
调度器的空闲P列表runtime.sched.pidle调度器存放空闲P的一个单向链表
调度器的可运行G队列runtime.sched.runqhead调度器存放可运行G的一个队列
 runtime.sched.runqtail调度器 
调度器的自由G列表runtime.sched.gfreeStack调度器存放自由G的一个单向链表
 runtime.sched.gNoStack调度器存放自由G的一个单向链表
P的可运行G队列runtime.p.runq本地P存放当前P中可运行G的一个队列,长度为256
P的自由G列表runtime.p.gfree本地P存放当前P中自由G的一个单向链表

调度器和P都有可运行的G队列,它们拥有几乎平等的运行机会。

可运行G的去向:

  1. 任何G都会存在于全局G列表中。

  2. Gsyscall状态转出的G都会被放入调度器的可运行G队列。

  3. 刚被运行时系统初始化的G都会被放入本地P的可运行G队列。

  4. Gwaiting状态转出的G,有的会被放入本地P的可运行G队列,有的会被放入调度器的可运行G队列,还有的会直接运行(刚进行完网络I/O的G)。

  5. 调用runtime.GOMAXPROCS函数后调度器会把多于的P的可运行G队列中的G全部转移到调度器的可运行G队列。

  6. 本地P的可运行G队列已满时,其中一半的G都会被转移到调度器的可运行G队列中。注意P的可运行G队列长度固定为256。

调度器的可运行G队列有两个变量,runqhead代表队列头,runqtail代表队列尾。已入队的G总是从头部取走,一般情况下,新的G会追加到队列尾,但有时也会插入到队列头部,例如runtime.GOMAXPROCS函数的调用就间接执行了此操作。

自由G的去向:

  1. 一个G转入Gdead状态后,首先会被放入本地P的自由G列表。

  2. 运行时系统在需要空闲的G来封装go函数时,会先尝试从本地P的自由G列表中获取。如果本地P的自由G列表为空,运行时系统会先从调度器的自由G列表中转移一些过来。

  3. 本地P的自由G列表已满时,运行时系统也会转移一些到调度器的自由G列表。

  4. 调度器有连个自由G列表,区别其中存放的G是否有栈。在把G放入自由G列表之前,运行时系统会检查该G的栈空间是否为初始大小。如果不是,就释放掉,让该G变成无栈的,以节约资源。而从自由G列表取出G后,运行时系统会检查它是否拥有栈,如果没有就初始化一个新的栈给它。

  5. 自由G列表的数据结构本质是栈,所以所有的自由G列表都是先进后出的。

与自由G列表对应的是调度器的空闲M列表和空闲P列表。没错,它们的数据结构也是栈,所以调度器的空闲M列表和空闲P列表也是先进后出的。它们用来存放暂时不用的M和P,等到需要时会先从这里获取。

本集的主角是M、P、G,但是“运行时系统”和“调度器”这两个概念又贯穿全篇,却也没有丝毫对他们的解释。其实它们是同一个东西。当同一个东西有了两幅面具,往往让人迷惑。你可以这样理解:完成调度需要数据和函数,它们分别从不同的角度描述了同一个功能。

更多推荐

地鼠宝宝的轶事奇闻之线程模型

本文发布于:2023-06-20 11:24:20,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/800704.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:地鼠   轶事   线程   闻之   模型

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!