场景1:G1创建G3

P拥有G1,M1获取P后开始运行G1,G1使用go func()创建了G3,为了局部性,G3优先加入到P1(而不是P2)的本地队列。(被淘汰的旧版调度器没有本地队列,无法像现在一样维护局部性)

image-20230712173421681

场景2:G1执行完毕

G1执行完成后会执行goexit()函数退出,然后M上运行的goroutine切换为G0,G0负责调度时协程的切换,执行函数schedule()切换G。然后G0切换到G3,然后M开始运行G3,执行G3的excute()函数,这里实现了M的复用。

image-20230712174027407

场景3:G2开辟过多的G

假设每个P的本地队列只能存4个G。G2要创建了6个G,前4个G(G3,G4, G5,G6)已经加入p1的本地队列,p1本地队列满了。

image-20230712175026093

G2在创建G7的时候,发现P1的本地队列已满,会将P1本地队列的前一半G取出,次序打乱,将G7和这一半G一同放入全局队列

image-20230712180625282

现在G2再创建G8,队列已经不满了,就可以正常进入p1队列了

image-20230712180739985

场景4:唤醒正在休眠的M

在创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行。

image-20230712182308965

如果G2成功唤醒一个M。这个M绑定了P2,运行G0,但P2的本地队列没有G,此时M2会不断寻找可执行的G,此时M这种状态就是自旋状态,M此时为自旋线程。

image-20230712183243443

场景5:获取全局队列的G

自旋线程M寻找的G的过程可以看上一篇文章,窃取机制章节,我对go1.20的源码src/runtime/proc.gofindRunnable()方法流程的解释

现在我们先把这个过程简化为先全局后偷取,所以自旋线程M现在要从全局队列获取一批次的G。具体获取多少的G,可以查看src/runtime/proc.goglobrunqget()方法,go1.21版本目前算法简化一下是:

  • min(sched.runqsize/gomaxprocs + 1,len(pp.runq)/2)

  • 也就是min(全局队列长度/P的上限 + 1,P的本地队列长度/2)

默认情况下P的默认容量是256(runq [256]guintptr),也就是说默认情况下最多只能取128个G

image-20230712192236957

场景6:窃取其他P队列中的G

假设上面的M已经全局获取的G4,G3,G7放到P2并执行完成,此时全局队列和P2的本地队列都空,此时M会重新变回自旋状态,M会尝试从其他P的本地队列中窃取尾巴一半的G放入自己绑定的P2的本地队列中。

这方面的源码在src/runtime/proc.gostealWork()可以找到,会尝试偷取4次(const *stealTries* = 4

image-20230712200732130

场景7:自旋线程的最大限制

假如我们的GOMAXPROCS=4,这时已经有两个M在执行任务了,两个的M无事可做,但是他们的队列都为空,全局队列也为空。此时有两个M无事可做,但是此时调度器仍然会让另外的两个M处于自旋状态,而不会直接让这两个M进入休眠状态。自旋本质是在运行,线程在运行却没有执行G,就变成了浪费CPU,为什么不销毁现场,来节约CPU资源?这是因为创建和销毁CPU也会浪费资源和时间,调度器希望当有新goroutine创建时,立刻能有M运行它,如果销毁再新建就增加了时延,降低了效率。

image-20230713114521672

自旋线程+执行线程<=GOMAXPROCS:显而易见,如果因为没有空闲的P了,M就没法唤醒去绑定P去变成自旋线程,更别说变成执行线程了。

此时多余的M会进入休眠状态,或者说变成了空闲的M(idle M)

场景8:G发生系统调用/阻塞

系统调用可能会引起阻塞,但不是所有阻塞都是由系统调用引起的

在一般情况下,系统调用是阻塞的,这是因为系统调用需要将控制权交给操作系统来执行相应的内核操作,直到操作完成并返回结果。同样的,G发生系统调用会阻塞这个G所在整个M。

我们在场景7的情况下,假设一种情况,此时P2的G8创建了一个G9,然后G8进行系统调用进入阻塞状态(或者其他原因进入阻塞状态),此时P2的整个本地队列(虽然此时只有一个G9)就会就会一直处于等待状态。

此时P2会执行以下判断(相关源码可以在src/runtime/proc.gohandoffp()中找到):

  • 如果P2本地队列有G或者全局队列有G而且有空闲的M,P2都会立刻唤醒1个M与它绑定
  • 否则P2则会加入到空闲P列表,等待M来获取可用的P。本场景中,P2本地队列有G9,可以和其他空闲的线程M6绑定

我们的例子中P2还有一个G9不会进入空闲P的队列(idle P queue),然后它会唤醒M6和它绑定,然后继续执行,这也是就是所为的hand off(交接)机制。

image-20230714172044210

但是可能有人会问,M4和M5都在自旋,为什么不让M4和M5去执行P2呢?这是因为自旋线程M已经和一个P绑定了,他们是去抢占G的,而不是抢占P的。

场景9:G结束系统调用/非阻塞

在场景8的基础上,假如此时G8此时系统调用就结束进入非阻塞状态了,但是此时M2已经没有P该怎么办呢?M2会进行以下一些判断:

  1. M2首先会尝试去寻找原来P2,如果可以就会和P2重新绑定,但是此时P2已经M6绑定了,M2无法获取P2,下一步
  2. 然后M2会尝试的寻找空闲的P,如果可以就与空闲的P绑定,继续执行G8,但是我们图中也没有空闲的P,下一步
  3. 然后G8会被标记为可运行状态被放入全局队列中去,M2会进入休眠状态(变成空闲的M)

image-20230714173315321

参考链接

  1. Golang的协程调度器原理及GMP设计思想
  2. Go 语言调度器与 Goroutine 实现原理 | Go 语言设计与实现 (draveness.me)