Loading

浅析Golang的线程模型与调度器

文章目录

  • Go并发特色
  • Go线程模型
    • GMP模型
    • Go运行时系统的核心元素容器
  • Go调度器
    • 调度器基本数据结构
    • 调度器的一整轮调度
    • 一整轮调度子流程(全力查找可运行的G)
    • 一整轮调度子流程(启用/停止M) 
  • 系统监测任务

Go并发特色

  Go在内核线程之上,搭建了一个特有的两级线程模型。除了内核对内核线程的调度之外,Go语言运行时还通过调度器对非内核的goroutine进行调度。

  Go不推荐用共享内存方式来通信,推荐使用通信的方式来共享内存。这里涉及到两个数据结构,一个是goroutine,另一个channel。channel用于多个goroutine之间传递数据,且保证整个过程的并发安全性。

  goroutine 是Go的应用程序级别线程;channel是Go特有一种数据结构,可以理解为一个管道。

Go线程模型

  Go的线程模型由三个核心元素做支撑,它们分别是“G”【goroutine】,“M”【内核线程】,“P”【G的上下文环境】。一个G的执行需要P和M的支持,一个M与一个P关联后,就形成了G的运行环境(内核线程+上下文环境)。

  M(machine)

    M代表一个操作系统内核线程。它的数据结构如下:

 g0 一个特殊的goroutine,在Go运行时系统启动之初创建,执行一些运行时任务 
 mstartfn函数  新创建的M上启动某个特殊任务(系统监控,GC辅助,M自旋)
 curg  M正在运行的G的指针
 p  指向与M关联的P
 nextp  与M预关联的P
 spinning  M是否正在寻找可运行的G,这个过程,M自旋
 lockedg  与当前M锁定的G

 

   

 

 

 

 

 

 

 

    M初始化流程如下:

 

  P(processor)

    P代表执行Go 代码片段所需要的资源,或者称之为上下文环境,P与M建立连接后,使P中可运行的G获得运行时机并执行。

    调用runtime.GOMAXPROCS 函数并传入期望设置的P的数量 或者 go程序运行前设置环境变量 GOMAXPROCS。P的数量即可运行G队列的数量。(P默认数量与CPU总核心数相同,最大数量可设置为256)

    P存在如下的状态:

Pidle 当前P未与任何M关联
Prunning 当前P正在与某个M关联
Psyscall 当前P运行的G在进行系统调用
Pgcstop Go运行时系统要求停止调度,P被设置为此状态
Pdead 当前P不会再被使用(等待GC回收)

 

 

 

 

 

 

    P的内部结构:

可执行G队列(runtime.p.runq) 待调度的G会在这里
自由G列表(runtime.p.gfree) 已经执行完成的G会存到这里(待运行的任务会优先从这里获取G,提高G的复用率)

 

    

 

    P的运作流程如下:

  G(goroutine)

    G代表一个Go的代码片段,使用“go”语句向Go运行时系统提交一个并发任务,Go运行时并发的执行这个任务。

    G存在如下的状态:

Gidle
当前G刚被新分配,未初始化
Grunnale
当前G在可运行队列中等待运行
Grunning
当前G正在被运行
Gsycall
当前G在执行某个系统调用
Gwating
当前G正在阻塞
Gdead
当前G正在闲置
Gcopystack
当前G正在被移动(G的栈扩容或者缩容)

 

 

 

 

 

 

 

 

    G的初始化流程如下:

    

    G的运作流程如下:

   

    在M的生命周期内,一个M关联一个内核线程(下图KSE),M和P相互引用,一个P可对应多个G。P中可运行G队列依次传递给与P关联的M,并且获得运行时机,执行任务。在Go线程模型中,三者之间的关系如下图所示:

                 

  

  Go运行时系统的核心元素容器

     Go运行时系统通过G,M,P三个核心元素支撑线程模型,在这基础之上,还有针对这三个核心元素的容器,来对这些元素进行管理,以及供调度器的使用,如下所示:

全局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 | runtime.sched.runqtail)
存放调度器上所有的可运行的G
调度器的自由G列表(runtime.sched.gfreeStack | runtime.sched.gfreeNoStack )
存放调度器上所有的空闲的G。G执行完毕放回自由G列表时,运行时系统检查G的栈空间大小是否时初始大小,不是的话,就释放掉,让G变为无栈的,为了节约资源。同时,从自由G列表获取G时,也会检查G是否有栈,没有的话就初始化栈空间。
P的可运行G列表(runtime.p.runq)
存放正在运行G的M所关联的P的可运行的G
P的自由G列表(runtime.p.gfree)
存放正在运行G的M所关联的P的空闲的G

 

 

 

 

 

 

 

 

 

 

 

Go的调度器

  调度器作用于两级线程模型中,非操作系统内核之外的调度任务。它主要的调度对象为M,P,G的实例,通过核心元素容器作为辅助设施。

  调度器基本数据结构

空闲M列表
 
空闲P列表  
可运行G列表  
自由G列表  
gcwaiting字段(uint32) 是否需要因一些任务而停止调度。在停止调度前,该字段被设置为1,恢复调度前,被设置为0。当字段为1时,调度任务执行时会把当前所有的P状态设置为Pgcstop
stopwait字段(int32) 需要停止但是仍未停止的P的数量。当gcwaiting为1时,调度任务将一个P状态设置为Pgcstop,同时将此字段的值减1。该字段值为0,则表示所有的P状态为Pgcstop
stopnote字段(note) 实现与stopwait相关的事件通知机制。当所有P状态为Pgcstop,根据该字段唤醒因等待调度停止而暂停的串行运行任务(Go运行时系统中的一些任务运行前需要调度器暂停调度,此类任务称为串行运行任务
sysmonwait字段(uint32) 停止调度期间,系统监测任务(稍后解释)是否在等待。0为未暂停,1为暂停
sysmonnote字段(note) 实现与sysmonwait相关的事件通知机制。调度器调度之前,根据sysmonwait字段状态,决定是否用sysmonnote字段恢复系统监测任务执行

 

 

 

 

 

 

 

 

 

 

 

 

  调度器的一整轮调度

    Go的引导程序会做一系列的初始化工作,初始化工作完成后,会让调度器进行一整轮的调度(执行封装了main函数的G)。

    一整轮调度的执行流程如下所示:

    一整轮调度的执行发生在用户程序启动时,一系列的初始化工作之后某个G的运行时阻塞、结束、退出系统调用、栈增长用户程序对某些标准库的函数调用(runtime.Gosched、runtime.Goexit)等。

    一整轮调度有两个比较重要的子流程,分别是全力查找可运行的G启用/停止M,下面对这两个子流程做做相关介绍。

  全力查找可运行的G

    全力查找可运行的G会多次尝试从其他地方获取G(runtime.findrunnable函数,包括从调度器的可运行G列表或者其他P的可运行G列表等),整个过程分为两个阶段,十个步骤。

    第一阶段:

    1. 获取执行终结器的G。Go的运行时有一个特殊的G负责执行终结函数(终结函数一般泛指那些占用了计算机本机资源对象变为垃圾时释放本机资源的函数),调度器会判断这个G已完成任务后,获取到它,放入本地P的可执行队列(同时把G状态设置为Grunnable)。
    2. 从本地P可运行G队列,获取G。
    3. 从调度器的可运行G队列,获取G。
    4. 从netpoller处获取G。当netpoller已被初始化且已有网络IO操作,会从netpoller拿到一个G列表,并把列表表头的G返回,其他的G放入调度器的可运行G队列。(这里即便拿不到G也会跳过,是非阻塞的
    5. 从其他P可运行G队列获取G。从其他P可运行G队列获取G时候,会遍历所有P,一次性拿这个P可执行G队列的一半G(拿到即返回,拿G时候会用到锁【原子操作】)。

    第二阶段:

    1. 获取执行GC标记的任务G。假如现在正处于GC标记阶段且本地P可以用于GC标记任务(gcMarkWorkAvailable函数),则GC标记专用G返回(G状态设置为Grunnable)。
    2. 从调度器的可运行G队列,获取G。这一次再获取不到G,就将解除本地P和M的关联,并把P放入调度器的空闲P列表。
    3. 从全局P列表的每个P的可运行G队列获取G。遍历全局P列表,并检查P的可运行G队列,假如可运行G列表不为空,就讲持有该G列表的P取出并与M关联,再返回第一阶段的第一个步骤搜索可运行的G。
    4. 获取执行GC标记的任务G。判断GC是否处于标记阶段,以及GC标记任务相关的全局资源(gcBlackenEnabled字段标示)是否可用,假如条件都达成,调度器会从空闲P列表拿到一个P且与当前M关联,将GC标记专用G返回(G状态设置为Grunnable)。
    5. 从netpoller处获取G。这里与第四部基本相同,假如netpoller已被初始化且已有网络IO操作,会从netpoller的G列表表头G返回,但是,这里是阻塞的(也就是说,会等待netpoller的可用G出现)。网络IO读取时候,操作系统内核会做一些处理,这时候,IO读取的G会被转入Gwaiting状态。内核处理完成,会返回相应的事件,此时netpoller会通知那些Gwaiting的G。接收到通知的G也就代表可以进行网络读写操作了,继而被调度器设置为Grunnable状态,等待执行。

     在经历过十个步骤后,仍然没有得到可用G,则当前M会被调度器停止,放入调度器的空闲M列表。

  启用/停止M

    Go运行时系统起停M的根据一系列的函数完成,如下所示:

stopm()  停止当前M的执行,直到因有新的G变得可运行而被唤醒
gcstopm() 为串行运行任务的执行让路,停止当前M的执行。串行运行任务执行完毕后唤醒该M
stoplockemd() 停止与某个G锁定的当前M的执行,直到G可运行时被唤醒
startlockedm(gp *g) 唤醒与gp锁定的M,让M去执行这个G(gp)
startm(_p_ *p, spinning bool) 唤醒或创建一个M去关联P(_p_)并执行

 

 

 

 

 

 

    在了解了Go运行时系统启停M的相关函数后,看下Go调度器对起停M的执行流程:

系统监测任务

  Go运行时系统使用sysmon函数实现一个系统的监测任务,它在在Go程序生命周期内根据周期时间,周而复始的运行。它主要做如下四件事:

  1. 抢夺符合条件的P和G
    抢夺有两种方式,第一种为从netpoller处获取可执行G,与一整轮调度的从netpoller处获取G方式类似;第二种为抢夺调度器符合条件的P和G。它会遍历全局P列表,假如P的状态为Psyscall,且P的可执行G队列里面存在可执行G(且没有spinning状态的M),就把这个P设置为Pidle状态,等待调度系统将该P与M关联。假如P状态为Prunning,且存在G运行时间过长,则系统监测任务告知调度器,当前G运行时间过长,期望停止该G运行其他G(调度器不一定停止,这里监测任务只负责告知)。
  2. 进行强制GC
    Go运行时系统在调度器初始化时会开启一个专用强制GC的G,它一般处于暂停状态。一旦GC当前未执行且距离上次执行时间已超过GC最大时间间隔,系统监测任务就会把这个G恢复并放入调度器的可运行G队列。
  3. 需要时清扫托管堆
  4. 打印调试器跟踪信息

  最后总结下,Go语言运行时,通过核心元素G,M,P 和 自己的调度器,实现了自己的并发线程模型。调度器通过对G,M,P的调度实现了两级线程模型中操作系统内核之外的调度任务。整个调度过程中会在多种时机去触发最核心的步骤 “一整轮调度”,而一整轮调度中最关键的部分在“全力查找可运行G”,它保证了M的高效运行(换句话说就是充分使用了计算机的物理资源),一整轮调度中还会涉及到M的启用停止。最后别忘了,还有一个与Go程序生命周期相同的系统监测任务来进行一些辅助性的工作。

 

  资料参考 :

  https://github.com/golang/go/blob/master/src/runtime/proc.go

  https://book.douban.com/subject/27016236/

posted @ 2021-04-01 00:47  3WLineCode  阅读(743)  评论(0编辑  收藏  举报