- Created by 郭强, last modified on Aug 27, 2024
一、P、M、G关系
在了解Go的运行时的scheduler
之前,需要先了解为什么需要它,因为我们可能会想,OS内核不是已经有一个线程scheduler
了嘛?
熟悉POSIX API
的人都知道,POSIX
的方案在很大程度上是对Unix process
进程模型的一个逻辑描述和扩展,两者有很多相似的地方。 Thread
有自己的信号掩码、CPU affinity
等。但是很多特征对于Go
程序来说都是累赘。 尤其是context
上下文切换的耗时。另一个原因是Go
的垃圾回收需要所有的goroutine
停止,使得内存在一个一致的状态。垃圾回收的时间点是不确定的,如果依靠OS
自身的scheduler
来调度,那么会有大量的线程需要停止工作。
单独的开发一个Go
的调度器,可以是其知道在什么时候内存状态是一致的,也就是说,当开始垃圾回收时,运行时只需要为当时正在CPU
核上运行的那个线程等待即可,而不是等待所有的线程。
用户空间线程和内核空间线程之间的映射关系有:N:1
、1:1
和M:N
N:1
是说,多个(N)用户线程始终在一个内核线程上跑,context
上下文切换确实很快,但是无法真正的利用多核。1:1
是说,一个用户线程就只在一个内核线程上跑,这时可以利用多核,但是上下文switch很慢。M:N
是说, 多个goroutine
在多个内核线程上跑,这个看似可以集齐上面两者的优势,但是无疑增加了调度的难度。
Go
的调度器内部有三个重要的结构:M,P,G
M:代表真正的内核OS
线程,和POSIX
里的thread
差不多,真正干活的人
G:代表一个goroutine
,它有自己的栈,instruction pointer
和其他信息(正在等待的channel
等等),用于调度。
P:代表调度的上下文,可以把它看做一个局部的调度器,使go
代码在一个线程上跑,它是实现从N:1
到N:M
映射的关键。
图中看,有2个物理线程M,每一个M都拥有一个context(P)
,每一个也都有一个正在运行的goroutine
。P的数量可以通过runtime.GOMAXPROCS()
来设置,它其实也就代表了真正的并发度,即有多少个goroutine
可以同时运行。图中灰色的那些goroutine
并没有运行,而是处于ready
的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue
),Go
语言里,启动一个goroutine
很容易:go function
就行,所以每有一个go
语句被执行,runqueue
队列就在其末尾加入一个goroutine
,在下一个调度点,就从runqueue
中取出(如何决定取哪个goroutine
?)一个goroutine
执行。
为何要维护多个上下文P?因为当一个OS线程被阻塞时,P可以转而投奔另一个OS线程!
图中看到,当一个OS线程M0陷入阻塞时,P转而在OS线程M1上运行。调度器保证有足够的线程来运行所有的context P
。
图中的M1可能是被创建,或者从线程缓存中取出。当M0返回时,它必须尝试取得一个context P
来运行goroutine
,一般情况下,它会从其他的OS
线程那里steal
偷一个context
过来,
如果没有偷到的话,它就把goroutine
放在一个global runqueue
里,然后自己就去睡大觉了(放入线程缓存里)。Contexts
们也会周期性的检查global runqueue
,否则global runqueue
上的goroutine
永远无法执行。
另一种情况是P所分配的任务G很快就执行完了(分配不均),这就导致了一个上下文P闲着没事儿干而系统却任然忙碌。但是如果global runqueue
没有任务G了,那么P就不得不从其他的上下文P那里拿一些G来执行。一般来说,如果上下文P从其他的上下文P那里要偷一个任务的话,一般就"偷"runqueue
的一半,这就确保了每个OS
线程都能充分的使用。
二、调度流程简述
我们都知道Go
语言是原生支持语言级并发的,这个并发的最小逻辑单元就是goroutine
。goroutine
就类似于Go
语言提供的一种“用户态线程”,当然这种“用户态线程”是跑在内核级线程之上的。当我们创建了很多的goroutine
,并且它们都是跑在同一个内核线程之上的时候,就需要一个调度器来维护这些goroutine
,确保所有的goroutine
都使用CPU
,并且是尽可能公平的使用CPU
资源。
这个调度器的原理以及实现值得我们去深入研究一下。支撑整个调度器的主要有4个重要结构,分别是P、M、G、Sched,前三个定义在runtime.h
中,Sched定义在proc.c
中。
- Sched结构就是调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。
- M代表内核级线程,一个M就是一个线程,
goroutine
就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)
、当前执行的goroutine
、随机数发生器等等非常多的信息。 - P全称是
Processor
,处理器,它的主要用途就是用来执行goroutine
的,所以它也维护了一个goroutine
队列,里面存储了所有需要它来执行的goroutine
,这个P的角色可能有一点让人迷惑,一开始容易和M冲突,后面重点聊一下它们的关系。 - G就是
goroutine
实现的核心结构了,G维护了goroutine
需要的栈、程序计数器以及它所在的M等信息。
理解M、P、G三者的关系对理解整个调度器非常重要,我从网络上找了一个图来说明其三者关系:
地鼠用小车运着一堆待加工的砖。M就可以看作图中的地鼠,P就是小车,G就是小车里装的砖。一图胜千言啊,弄清楚了它们三者的关系,下面我们就开始重点聊地鼠是如何在搬运砖块的。
启动过程
在关心绝大多数程序的内部原理的时候,我们都试图去弄明白其启动初始化过程,弄明白这个过程对后续的深入分析至关重要。在asm_amd64.s
文件中的汇编代码_rt0_amd64
就是整个启动过程,核心过程如下:
CALL runtime.args(SB) CALL runtime.osinit(SB) CALL runtime.hashinit(SB) CALL runtime.schedinit(SB) // create a new goroutine to start program PUSHQ $runtime.main.f(SB) // entry PUSHQ $0 // arg size CALL runtime.newproc(SB) POPQ AX POPQ AX // start this M CALL runtime.mstart(SB)
启动过程做了调度器初始化runtime.schedinit
后,调用runtime.newproc
创建出第一个goroutine
,这个goroutine
将执行的函数是runtime.main
,这第一个goroutine
也就是所谓的主goroutine
。我们写的最简单的Go程序hello,world
就是完全跑在这个goroutine
里,当然任何一个Go程序的入口都是从这个goroutine
开始的。最后调用的runtime.mstart
就是真正的执行上一步创建的主goroutine
。
启动过程中的调度器初始化runtime.schedinit
函数主要根据用户设置的GOMAXPROCS
值来创建一批小车(P),不管GOMAXPROCS
设置为多大,最多也只能创建256个小车(P)。这些小车(P)初始创建好后都是闲置状态,也就是还没开始使用,所以它们都放置在调度器结构(Sched)的pidle
字段维护的链表中存储起来了,以备后续之需。
查看runtime.main
函数可以了解到主goroutine
开始执行后,做的第一件事情是创建了一个新的内核级线程(地鼠M),不过这个线程是一个特殊线程,它在整个运行期专门负责做特定的事情——系统监控(sysmon)。接下来就是进入Go程序的main
函数开始Go程序的执行。
至此,Go程序就被启动起来开始运行了。一个真正干活的Go程序,一定创建有不少的goroutine
,所以在Go程序开始运行后,就会向调度器添加goroutine
,调度器就要负责维护好这些goroutine
的正常执行。
创建goroutine(G)
在Go程序中,时常会有类似代码:
go do_something()
go关键字就是用来创建一个goroutine
的,后面的函数就是这个goroutine
需要执行的代码逻辑。go关键字对应到调度器的接口就是 runtime.newproc
。runtime.newproc
干的事情很简单,就负责制造一块砖(G),然后将这块砖(G)放入当前这个地鼠(M)的小车(P)中。
每个新的goroutine
都需要有一个自己的栈,G结构的sched
字段维护了栈地址以及程序计数器等信息,这是最基本的调度信息,也就是说这个goroutine
放弃cpu
的时候需要保存这些信息,待下次重新获得cpu
的时候,需要将这些信息装载到对应的cpu
寄存器中。
假设这个时候已经创建了大量的goroutne
,就轮到调度器去维护这些goroutine
了。
创建内核线程(M)
Go程序中没有语言级的关键字让你去创建一个内核线程,你只能创建goroutine
,内核线程只能由runtime
根据实际情况去创建。runtime
什么时候创建线程?以地鼠运砖图来讲,砖(G)太多了,地鼠(M)又太少了,实在忙不过来,刚好还有空闲的小车(P)没有使用,那就从别处再借些地鼠(M)过来直到把小车(P)用完为止。这里有一个地鼠(M)不够用,从别处借地鼠(M)的过程,这个过程就是创建一个内核线程(M)。创建M的接口函数是:
void newm(void (*fn)(void), P *p)
newm
函数的核心行为就是调用clone
系统调用创建一个内核线程,每个内核线程的开始执行位置都是runtime.mstart
函数。参数p
就是一辆空闲的小车(P)。每个创建好的内核线程都从runtime.mstart
函数开始执行了,它们将用分配给自己小车去搬砖了。
调度核心
newm
接口只是给新创建的M分配了一个空闲的P,也就是相当于告诉借来的地鼠(M)——"接下来的日子,你将使用1号小车搬砖,记住是1号小车;待会自己到停车场拿车。",地鼠(M)去拿小车(P)这个过程就是 acquirep
。runtime.mstart
在进入 schedule
之前会给当前M装配上P,runtime.mstart
函数中的代码:
} else if(m != &runtime.m0) { acquirep(m->nextp); m->nextp = nil; } schedule();
if
分支的内容就是为当前M装配上P, nextp
就是newm
分配的空闲小车(P),只是到这个时候才真正拿到手罢了。没有P、M是无法执行goroutine
的,就像地鼠没有小车无法运砖一样的道理。对应acquirep
的动作是releasep
,把M装配的P给载掉;活干完了,地鼠需要休息了,就把小车还到停车场,然后睡觉去。
地鼠(M)拿到属于自己的小车(P)后,就进入工场开始干活了,也就是上面的 schedule
调用。简化schedule
的代码如下:
static void schedule(void) { G *gp; gp = runqget(m->p); if(gp == nil) gp = findrunnable(); if (m->p->runqhead != m->p->runqtail && runtime.atomicload(&runtime.sched.nmspinning) == 0 && runtime.atomicload(&runtime.sched.npidle) > 0) // TODO: fast atomic wakep(); execute(gp); }
schedule
函数被我简化了太多,主要是我不喜欢贴大段大段的代码,因此只保留主干代码了。这里涉及到4大步逻辑:
runqget
, 地鼠(M)试图从自己的小车(P)取出一块砖(G),当然结果可能失败,也就是这个地鼠的小车已经空了,没有砖了。findrunnable
, 如果地鼠自己的小车中没有砖,那也不能闲着不干活是吧,所以地鼠就会试图跑去工场仓库取一块砖来处理;工场仓库也可能没砖啊,出现这种情况的时候,这个地鼠也没有偷懒停下干活,而是悄悄跑出去,随机盯上一个小伙伴(地鼠),然后从它的车里试图偷一半砖到自己车里。如果多次尝试偷砖都失败了,那说明实在没有砖可搬了,这个时候地鼠就会把小车还回停车场,然后 睡觉休息了。如果地鼠睡觉了,下面的过程当然都停止了,地鼠睡觉也就是线程sleep
了。wakep
, 到这个过程的时候,可怜的地鼠发现自己小车里有好多砖啊,自己根本处理不过来;再回头一看停车场居然有闲置的小车,立马跑到宿舍一看,你妹,居然还有小伙伴在睡觉,直接给屁股一脚,“你妹,居然还在睡觉,老子都快累死了,赶紧起来干活,分担点工作。”,小伙伴醒了,拿上自己的小车,乖乖干活去了。有时候,可怜的地鼠跑到宿舍却发现没有在睡觉的小伙伴,于是会很失望,最后只好向工场老板说——”停车场还有闲置的车啊,我快干不动了,赶紧从别的工场借个地鼠来帮忙吧。”,最后工场老板就搞来一个新的地鼠干活了。execute
,地鼠拿着砖放入火种欢快的烧练起来。
注: "地鼠偷砖"叫 work stealing,一种调度算法。
到这里,貌似整个工场都正常的运转起来了,无懈可击的样子。不对,还有一个疑点没解决啊,假设地鼠的车里有很多砖,它把一块砖放入火炉中后,何时把它取出来,放入第二块砖呢?难道要一直把第一块砖烧练好,才取出来吗?那估计后面的砖真的是等得花儿都要谢了。这里就是要真正解决goroutine
的调度,上下文切换问题。
调度点
当我们翻看channel
的实现代码可以发现,对channel
读写操作的时候会触发调用 runtime.park
函数。goroutine
调用park
后,这个goroutine
就会被设置位waiting
状态,放弃CPU。被park
的goroutine
处于waiting
状态,并且这个goroutine
不在小车(P)中,如果不对其调用runtime.ready
,它是永远不会再被执行的。除了channel操作外,定时器、网络poll等都有可能park goroutine。
除了park可以放弃cpu外,调用 runtime.gosched
函数也可以让当前goroutine
放弃CPU,但和park
完全不同;gosched
是将goroutine
设置为runnable
状态,然后放入到调度器全局等待队列(也就是上面提到的工场仓库,这下就明白为何工场仓库会有砖块(G)了吧)。
除此之外,就轮到系统调用了,有些系统调用也会触发重新调度。Go语言完全是自己封装的系统调用,所以在封装系统调用的时候,可以做不少手脚,也就是进入系统调用的时候执行entersyscall
,退出后又执行exitsyscall
函数。 也只有封装了entersyscall
的系统调用才有可能触发重新调度,它将改变小车(P)的状态为syscall
。还记一开始提到的sysmon
线程吗?这个系统监控线程会扫描所有的小车(P),发现一个小车(P)处于了syscall
的状态,就知道这个小车(P)遇到了goroutine
在做系统调用,于是系统监控线程就会创建一个新的地鼠(M)去把这个处于syscall
的小车给抢过来,开始干活,这样这个小车中的所有砖块(G)就可以绕过之前系统调用的等待了。被抢走小车的地鼠等系统调用返回后,发现自己的车没,不能继续干活了,于是只能把执行系统调用的goroutine
放回到工场仓库,自己睡觉去了。
从goroutine
的调度点可以看出,调度器还是挺粗暴的,调度粒度有点过大,公平性也没有想想的那么好。总之,这个调度器还是比较简单的。
综上所述,goroutine
上下文切换的调度时机可分为以下几个条件:
goroutine
阻塞(waiting
)- 显式调用
runtime.gosched()
- 系统调用
system call
协程一般都是这样工作的,但是从1.2开始,为了避免饿死其它goroutine,就是在发生任意函数调用的时候,都有机会触发scheduler。所以从1.2开始如果你的goroutine中是纯计算,没有任何系统调用,scheduler仍然有机会介入,不会永远独占CPU。
现场处理
goroutine
在CPU上换入换出,不断上下文切换的时候,必须要保证的事情就是 保存现场 和 恢复现场,保存现场就是在goroutine
放弃CPU的时候,将相关寄存器的值给保存到内存中;恢复现场就是在goroutine
重新获得CPU的时候,需要从内存把之前的寄存器信息全部放回到相应寄存器中去。 Go 调度器 M 的栈保存在 G 对象上,只需要将 M 所需要的寄存器( SP、 PC 等) 保存到 G 对象上就可以实现现场保护。当这些寄存器数据被保护起来,就随时可以做上下文切换了,在中断之前把现场保存起来。如果此时G 任务还没有执行完, M 可以将任务重新丢到 P 的任务队列,等待下一次被调度执行。当再次被调度执行时, M 通过访问 G 的 vdsoSP
、 vdsoPC
寄存器进行现场恢复( 从上次中断位置继续执行) 。
goroutine
在主动放弃CPU的时候(park/gosched
),都会涉及到调用runtime.mcall
函数,此函数也是汇编实现,主要将goroutine
的栈地址和程序计数器保存到G结构的sched
字段中,mcall
就完成了现场保存。恢复现场的函数是runtime.gogocall
,这个函数主要在execute
中调用,就是在执行goroutine
前,需要重新装载相应的寄存器。
三、参考链接
- https://johng.cn/goroutine1-pmg/
- https://johng.cn/goroutine-scheduler-brief/
- https://www.zhihu.com/question/20862617
- http://morsmachine.dk/go-scheduler
- No labels
1 Comment
neo
"
POSIX
的方案在很大程度上是对Unix process
进场模型的一个逻辑描述和扩展" ,应该进程模型吧?