GolangGMP模型 GMP(一):HelloWorld程序的执行过程
前言
GolangGMP模型 GMP(一):HelloWorld程序的执行过程
GolangGMP模型 GMP(二):goroutine的创建,运行与恢复
GolangGMP模型 GMP(三):协程让出,抢占,监控与调度
GMP总结
—>深入理解GMP模型
概念理解
G:goroutine 协程
P:processor 处理器
M:thread 内核线程
一个HelloWorld程序,编译后成为一个可执行文件,执行时,可执行文件被加载到内存。对于进程虚拟地址空间中的代码段,我们感兴趣的是程序执行入口,它并不是我们熟悉的main.main,不同平台下程序执行入口不同。
在进行一系列检查与初始化等准备工作后,会以runtime.main为执行入口,创建main goroutine。main goroutine执行起来以后,才会调用我们编写的main.main。
再来看数据段,这里有几个重要的全局变量不得不提。我们知道Go语言中协程对应的数据结构是runtime.g,工作线程对应的数据结构是runtime.m。而全局变量g0就是主协程对应的g,与其它协程有所不同,g0的协程栈是在主线程栈上分配的。全局变量m0就是主线程对应的m,g0持有m0的指针,m0里也记录着g0的指针,而且一开始m0上执行的协程正是g0,m0和g0就这样联系了起来。全局变量allgs记录所有的g,allm记录所有的m。
最初Go语言的调度模型里只有M和G。所以待执行的G排排做,等在一个地方,每个M来这里获取一个G时都要加锁,多个M分担这多个G的执行任务,就会因频繁加锁解锁而发生等待,影响程序并发性能。
所以后来在M和G以外又引入了P,P对应的数据结构是runtime.p,它有一个本地runq,这样只要把一个P关联到一个M上,这个M就可以从P这里直接获取待执行的G,不用每次都和众多M从一个全局队列中争抢任务了。也就说是,虽然P有一个本地runq,但是依然有个全局runq。
它保存在全局变量sched中,这个全局变量代表的是调度器,对应的数据结构是runtime.schedt,这里记录着所有空闲的m,空闲的p等等许多和调度相关的内容,其中就有一个全局的runq。
如果P的本地队列已满,那么等待执行的G就会被放到这个全局队列里,而M会先从关联P持有的本地runq中获取待执行的G,没有的话,再到调度器持有的全局队列里取,如果全局队列没有了,就会去别的P那里“偷”一些G过来。
同G和M一样,也有一个全局变量allp用于保存所有的P
在程序初始化过程中会进行调度器初始化,这时会按照GOMAXPROCS这个环境变量,决定创建多少个P,保存在全局变量allp中,并且把第一个P(allp[0])与m0关联起来,简单来说,G,M,P就是这样的合作关系。现在就可以更清晰的理解这个经典的示意图了。
在main goroutine创建之前,GPM的情况是这样的,main goroutine创建之后,新的G被加入到当前P的本地队列中。
然后通过mstart函数开启调度循环,这个mstart函数,是所有工作线程的入口,主要就是调用schedule函数,也就是执行调度循环,其实对于一个活跃的m而言,不是在执行某个G,就是在执行调度程序获取某个G,我们暂且不展开循环调度的具体逻辑,目前面临的调度场景很简单,队列里只有main goroutine等待执行。
所以m0切换到main goroutine,执行入口自然是runtime.main,它会做很多事,包括创建监控线程,进行包初始化等等,其中也包括调用我们熟悉的main.main函数,终于可以输出“Hello World!”了。值得一提的是,在main.main返回之后,runtime.main会调用exit()函数结束进程。
接下来我们把这个hello world程序改造一下,如果在main.main中不直接输出,而是通过一个协程输出,那么到main.main被调用执行时,就会创建一个新的goroutine,我们把它记为“hello goroutine”。
我们通过go关键字创建协程,会被编译器转换为newproc函数调用,main goroutine也是由newproc函数创建的。
创建goroutine时我们只负责指定入口,参数,而newproc会给goroutine构造一个栈帧,目的是让协程任务结束后,返回到goexit函数中,进行协程资源回收处理等工作,这很合理,一个协程任务完成后,是放到空闲G队列里备用,还是该释放,总要有个出路。
回到newproc,如果我们设置GOMAXPROCS=1,那么就只会创建一个P。那么新创建的hello goroutine会被添加到当前P的本地runq队列中,然后main.main就结束返回了,再然后exit函数被调用,进程就结束了,然后。。就没有然后了,所以hello groutine它没能执行,问题就在于main.main返回后exit函数就会被调用,直接把进程结束掉,没给hello goroutine空出调度执行的时间,所以想让hello goroutine执行,就要在main.main返回之前拖延下时间。
如果使用time.Sleep,实际上会调用gopark函数,把当前协程的状态从_Grunning修改为_Gwaiting。然后main goroutine不会返回到当前P的runq中,而是在timer中等待,继而调用schedule()进行调度,hello goroutine得以执行。
等到sleep的时间到达后,timer会把main goroutine重新置为_Grunnable状态,放回到runq中。再然后,main gorouting被m0执行,main.main结束,exit得以调用,进程退出。
这是只有一个P的情况,如果创建了多个P,hello gorouine创建之后,虽然默认会添加到当前P的本地队列中,但是只有空闲P的情况下,就可以启动新的线程关联到这个空闲的P。并把hello goroutine放到它的本地队列中了。
同样的,可以使用time.Sleep,或者是等待一个Channel,或者是WaitGroup,反正只要main.main不马上返回,hello goroutine就有时间得以执行了。
这一篇博文,通过了解了一个go程序启动的大致过程,认识了g0,m0等非常重要的全局变量,也初步了解了GPM三者的关系,接下来就学习协程创建,调度,以及监控线程等关键内容展开学习一下(GMP(二),GMP(三)),逐步加深对Go语言中GPM模型的理解。