Go调度器

0 阅读14分钟

调度器有自己的数据结构.形成此结构的主要目的是更加方便的管理和调度各个元素的实例.其中就有已经熟知的空闲的M列表 空闲的P列表 可运行G队列和自由G列表.

调度器源码位置:src/runtime/runtime2/schedt.go

image.png 这张表中的字段都与需要停止调度的任务有关.在Go运行时系统中.一些任务在执行前是需要暂停调度的.例如垃圾回收任务中的某些子任务.又如发起运行时恐慌的任务.方便描述.称为串行运行时任务.

字段gcwaiting stopwait和stopnote都是串行运行时任务执行前后的辅助协调手段.gcwaiting字段值用于表示是否需要停止调度.在停止调度前.该值会被设置为1.在恢复调度前.该值会被设置为0.这样做的作用是.一些调度任务在执行时只要发现gcwaiting的值为1.就会把当前P的状态设置为Pgcstop.然后自减stopwait字段的值.如果发现自减后的值为0.就说明所有P的状态都已为Pgcstop.这时就可以利用stopnote字段.唤醒因等待调度停止而暂停的串行运行时任务.

字段sysmonwait和sysmonnote与前面一组字段用途类似.只不过它们针对的是系统监测任务.在串行运行时任务执行之前.系统监测任务也要暂停,sysmonwait字段的作用就是表示是否已暂停.0表示未暂停.1表示已暂停.系统监测任务是持续执行的.更确切的说.它处在无尽的循环之中.每次迭代之初.系统监测任务都会检查调度情况.一旦发现调度停止(gcwaiting字段的值不为0或所有的P都已闲置).就会把sysmonwait字段的值设置为1.并利用sysmonnote字段暂停自身.另一方面.在恢复调度之前.调度器若发现sysmonwait字段的值不为0.就会把它置为0.并利用sysmonnote字段恢复系统监测任务的执行.

一轮调度:

引导程序会为Go程序的运行建立必要的环境.在引导程序完成一系列初始化工作之后.Go程序的mian函数才会真正的执行.引导程序会在最后让调度器进行一轮调度.这样才能够让封装了main函数的G马上就有机会运行.封装main函数的G总是Go运行系统时创建的第一个用户G.用户G因Go程序中的代码而生.用于封装用户级别的片段.用于封装运行时任务的G称为运行时G.

一轮调度由Go标准库代码包runtime中的schedule函数代表.它的总体流程不复杂.调度器会先从一些比较容易找到的可运行G的地方入手.即:全局的(或称调度器)可运行G队列和本地P的可运行G队列.如果从这些地方找不到可运行的G.调度器程序就会进入强力查找模式.如果经过一番强力查找还是无法找到任何可运行的G.该子流程就会暂停.直到有可运行的G出现才会继续下去.也就是说.这个全力查找可运行G的子流程的结束.就意味着当前M抢到了一个可运行的G.

如果调度器在一轮调度之初发现当前M与某个G锁定.就会立即停止调度并停止当前M(或者说让它暂时阻塞).一旦与它锁定的G处于可运行状态.它就会被唤醒并继续运行那个G.停止当前M意味着相关内核线程不能再去做其他事情了.调度器也不会为当前M寻找可运行的G.相应的.当调度器为当前M找到一个可运行的G.但却发现该G与某个M约定.它就会唤醒那个与之锁定的M运行该G.并重新与当前M寻找可运行的G.

如果调度器判断当前M未与任何G锁定.那么一轮调度的主流程就会继续进行.调度器会检查是否有运行时任务正在等待执行.前面提到运行串行任务时.这类任务在执行时需要停止Go调度器.官方称这种停止操作为"Stop the World"简称STW.就是通过检查gcwaiting的值来判断的.如果gcwaiting字段的值不为0.那么下一轮调度流程又会走进另一个分支.即:停止并阻塞当前M以等待运行时串行任务执行完成.一旦串行任务执行完成.该M就会被唤醒.一轮调度也会再次开始.

最后.如果调度器在此关于锁定和运行时串行任务的判断都为假.就会开始真正的可运行G寻找之旅.一旦找到一个可运行的G.调度器就会在判定该G未与任何M锁定之后.立即让当前M运行.

全力寻找可运行的G:

调度器如果没有找到可运行的G.就会进入"全力查找可运行的G"的子流程.这个子流程会多次尝试从各处搜索可运行的G.甚至还会从别的P(非本地P)那里偷取可运行的G.它由runtime.findrunnable函数代表.该函数会返回一个处于Grunnable状态的G.搜索步骤如下:

1).获取执行终结器的G:

一个终结器(或称"终结函数")可以与一个对象关联.通过调用runtime.SetFinalizer函数产生这种关联.当一个对象不可达(即未被任何其他对象引用)时.垃圾回收器在回收该对象之前.就会执行与之相关联的终结函数(如果有的话).所有终结函数的执行都会由一个专用的G负责.调度器会在判定这个专用G已完成任务之后试图获取它.然后把它置为Grunnable状态并放入本地P的可运行队列.

2).从本地P的可运行G队列获取G.

调度器会尝试从该处获取一个G.并把它作为结果返回.

3).从调度器的可运行G队列获取G.

调度器会尝试从该处获取一个G.并把它作为结果返回.

4).从网络I/O轮询器(或称netpoller)处获取G.

如果netpolle已被初始化且已有过网络I/O操作.那么调度器会试着从netpoller那里获取一个G列表.并把作为表头的那个G当作结果返回.同时把剩余的G都放入调度器的可运行队列.如果netpoller还未被初始化或还未有过网络的I/O操作.这一步会跳过.注意.这里的获取只是浅尝辄止.即使没有获取成功也不会阻塞.

5).从其他P的可运行G队列获取G.

在条件允许的情况下.调度器会用一种伪随机算法在全局P列表中选取P.然后试着从它们可运行G队列中盗取(或者说转移).一半的G到本地P的可运行G队列.选取P和盗取G的过程会重复很多次.成功即停止.如果成功.那么调度器就会把盗取的一个G返回.否则.搜索的第一阶段结束.

6).获取执行GC标记任务的G.

在搜索的第二阶段.调度器会先判断是否正处在GC阶段.以及本地P是否可用于GC标记任务.如果结果都是true.调度器就会把本地P持有的GC标记专用G置为Grunnable状态并作为结果返回.

7).从调度器的可运行G队列获取G.

调度器再次尝试从该处获取一个G.并把它作为结果返回.如果依然找不到可运行的G.就会解除本地P与当前M的关联.并把该P放入调度器的空闲P列表.

8).从全局P列表中每个P的可运行G队列获取G.

遍历全局P列中的P.并检查它们的可运行G队列.只要发现某个P的可运行G队列不是空的.就从调度器的空闲P列表中取出一个P.并在判定其可用后与当前M关联在一起.然后再返回第一阶段重新搜索可运行的G.如果所有P的可运行G队列都是空的.那就只能继续后面的搜索.

9).获取执行GC标记任务的G.

判断是否正处于GC的标记阶段.以及与GC标记任务相关的全局资源是否可用.如果结果都是true.调度器就会从其它空闲P列表中拿出一个P.如果这个P持有一个GC标记专用G.就关联该P与当前M.然后在执行第二个阶段(从6开始).

10).从网络I/O轮询器处获取G.

如果netpoller已被初始化.并且有过网络I/O操作.那么调度器会再次试着从netpoller那里获取一个G列表.这个步骤与第四步基本相同.有一个区别是.这里的获取是阻塞的.只有当netpoller那里有可用的G.阻塞才会解除.如果netpoller还未被初始化.或还未有过网络I/O操作.这一步会跳过.

如果经过上面十个步骤还没有找到可运行的G.调度器就会停止当前M.在之后的某个时刻.该M被唤醒后.会重新进入这个流程.

启用或停止M:

stopm():

停止当前M的执行.直到有新的G变得可运行而被唤醒.

gcstopm():

为串行运行时任务的执行让路.停止当前M的执行.串行运行时任务执行完毕后会被唤醒.

stoplockedm():

停止已于某个G锁定的当前M的执行.直到因这个G变得可运行而被唤醒.

startlockedm(gp *g):

唤醒与gp锁定的那个M.并让该M去执行gp;

startm(p_ *p,spinning bool):

唤醒或创建一个M去关联_p_并开始执行.

1).调度器在执行调度流程的时候.会先检查当前M是否已于某个G锁定.如果锁定存在.调度器就会调用stoplockedm函数停止当前M.stoplockedm函数会解除当前M与本地P之间的关联.并通过调用另一个名为handoffp的函数把这个P转手给其他M.在这个转手P的过程中会间接调用startm函数.一旦这个P被转手.stoplockedm函数就会停止当前M的执行.并等待唤醒.

2).如果调度程序为当前M找到了一个可运行的G.却发现G已于某个M锁定了.那么就会调用startlockedm函数并把这个G作为参数传入.startlockedm函数会通过参数gp的lockedm字段找到与之锁定的那个M(以下简称已锁M).并把当前M本地P转手给它.这里的转手P过程要比1中的简单很多.startlockedm函数会先解除当前M与本地P之间的关联.然后把这个P赋给已锁M的nextp字段.

3).startlockedm函数的执行会使与其参数gp锁定的那个M(即已锁M)被唤醒.通过gp的lockedm字段可以找到已锁M.一旦已锁M被唤醒.就会和它预联的P产生正式的关联.并去执行与之关联的G.

4).startlockedm函数在最后会调用stopm函数.stopm函数会先把当前M放入调度器的空闲M列表.然后停止当前M.这里停止的M.可能会在之后因有P需要转手.或有G需要执行而被唤醒.

5).调度器在执行调度流程的时候.也会检查是否有串行运行时任务正在等待执行.如果有.调度器就会调用gcstopm函数停止当前M.gcstopm函数会先通过当前M的spinning字段检查它的自旋状态.如果其值为true.就把false赋值给它.然后把调度器中用于记录自旋M数量的nmspinning字段的值减一.如此一来就完全重置了当前M的自旋状态的标识.一个将要停止的M理应脱离自旋状态.在这之后.gcstopm函数会释放本地P.并将其状态设置为Pgcstop.然后在去自减并检查调度器的stopwait字段.并在发现stopwait字段的值为0时.通过调度器的stopnote字段唤醒等待执行的串行运行时任务.这实际上又是一个联动的调度操作.

6).gcstopm函数在最后会调用stopm函数.同样的.当前M会被放入调度器空闲的M列表并停止.只要有串行运行时任务准备执行."stop the world"就会开始.所有在调度过程中的M都会执行步骤5 6.其中步骤5决定了串行运行时任务是否能够被尽早的执行.

7).调度总有不成功的时候.如果经过一轮完整的一轮调度之后.仍然找不到一个可运行的G给当前M运行.那么调度程序就会通过调用stopm函数停止当前的M.换句话说.这时已经没有多余的工作可以做了.为了节省资源就要停掉一些M.一旦停掉的M被唤醒.stopm函数就会负责关联它和已于它关联的P.这也是在M的执行做的最后的准备.还有一种情况.如果stopm函数发现当前M是因为有可并发执行的GC任务而被唤醒的.那么就在执行完该任务之后再次停止当前M.

8).所有经由调用stopm函数停止的M.都可以通过调用startm函数唤醒.与步骤7对应.一个M被唤醒原因总是有新工作要做.比如.有了新的自由的P.或者有了新的可运行的G.有时候.传入startm函数的参数_p_为nil.这就说明在唤醒一个M的同时.需要从调度器的空闲P列表获取一个P作为M运行G的上下文环境.如果这个列表已经空了.那么startm函数也就无能为力.这时.startm函数会直接返回.一旦有了一个P.startm函数就会再从调度器的空闲M列表获取一个M.如果该列表已空就创建一个M.无论如何.startm函数都会把拿到的P和这个M预联.然后让该M做好准备.

系统监测任务:

概括来说.监测任务做了如下几件事.

在需要时抢夺符合条件的P和G.

在需要时进行强制GC.

在需要时清扫堆.

在需要时打印调度器跟踪信息.

计数器流程图:

变更P的最大数量:

在Go的线程实现模型中.P起到了承上启下的作用.P最大数量的变更意味着要改变G运行的上下文环境.这种变更直接影响这Go程序的并发性能.

默认情况下.P的最大数量等于正在运行当前Go程序的逻辑CPU(CPU核心)的数量.可以通过调用runtime.GOMAXPROCS函数改变最大值.但是会有一定的损耗.

语雀地址www.yuque.com/itbosunmian…?

《Go.》 密码:xbkk 欢迎大家访问.提意见.

明月多情应笑我.笑我如今.辜负春心.独自闲行独自吟.

近来怕说当时事.结遍兰襟.月浅灯深.梦里云归何处寻. 纳兰.

如果大家喜欢我的分享的话.可以关注我的微信公众号

念何架构之路