文章目录
GoLang之go Hello Goroutine的执行过程底层GMP(一)
本文章请结合GMP 原理与调度 · Go语言中文文档 (topgoer.com)进行阅读
1.println(“Hello World!”)
一个HelloWorld程序,编译后成为一个可执行文件,执行时,可执行文件被加载到内存。对于进程虚拟地址空间中的代码段来说,我们感兴趣的是程序执行入口,它并不是我们熟悉的main.main,不同平台下程序执行入口不同。
在进行一系列检查与初始化等准备工作后,
会以runtime.main为执行入口,创建main goroutine,main goroutine执行起来以后,才会调用我们编写的main.main(是我们自己定义的main包里的那个函数)
再来看数据段,这里有几个重要的全局变量不得不提:
我们知道Go语言中协程对应的数据结构是runtime.g,工作线程对应的数据结构是runtime.m;
而全局变量g0就是主协程对应的g,与其它协程有所不同,g0的协程栈是在主线程栈上分配的,全局变量m0就是主线程对应的m;
g0持有m0的指针,m0里也记录着g0的指针,而且一开始m0上执行的协程正是g0(指curg=&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()函数结束进程
2.go Hello(一个p)
接下来我们把这个hello world程序改造一下,如果在main.main中不直接输出,而是通过一个协程输出,那么到main.main被调用执行时,就会创建一个新的goroutine,我们把它记为“hello goroutine”
我们通过go关键字创建协程,会被编译器转换为newproc函数调用
main goroutine也是由newproc函数创建的。
创建goroutine时我们只负责指定入口、参数,而newproc会给goroutine构造一个栈帧,目的是让协程任务结束后,返回到goexit函数中,进行协程资源回收处理等工作,这很合理,一个协程任务完成后,是放到空闲G队列里备用,还是该释放,总要有个出路
回到newproc(0,hello),如果我们设置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得以调用,进程退出。
3. go Hello(多个p)
以上是有一个P的情况,如果创建了多个P,hello gorouine创建之后,虽然默认会添加到当前P的本地队列中,但是在有空闲P的情况下,就可以启动新的线程关联到这个空闲的P。并把hello goroutine放到它的本地队列中了。
同样的,可以使用time.Sleep,或者是等待一个Channel,或者是WaitGroup,反正只要main.main不马上返回,hello goroutine就有时间得以执行了
4.总结
这一次我们通过hello world程序了解了一个go程序启动的大致过程
认识了g0,m0等非常重要的全局变量
也初步了解了GPM三者的关系
接下来就学习协程创建,调度,以及监控线程等关键内容展开学习一下,逐步加深对go语言中GMP模型的理解
4.附
所有的函数都必须直接或者间接的被 main 函数调用才可以运行
Go 语言程序的运行,是从 main 函数开始的
Go 语言的 main 函数,是一个没有任何 参数 和 返回值 的函数
main函数所在的文件main.go里的package必须得声明名字为“main”,否则运行不了