GolangGMP模型 GMP(二):goroutine的创建,运行与恢复
前言
GolangGMP模型 GMP(一):HelloWorld程序的执行过程
GolangGMP模型 GMP(二):goroutine的创建,运行与恢复
GolangGMP模型 GMP(三):协程让出,抢占,监控与调度
GMP总结
—>深入理解GMP模型
理解
还是这个hello goroutine的例子,只不过给协程入口增加了一个参数,我们已经知道main goroutine执行起来会创建一个hello goroutine,而创建的任务,就交由newproc函数来负责。
我们通过函数栈帧看一下newproc函数的调用过程,main函数栈帧自然分配在main goroutine的协程栈中 ,还记得go语言函数栈帧布局吧,call指令入栈的返回地址之后 ,是调用者栈基,然后是局部变量区间,以及调用其他函数时,传递是返回值和参数的区间,main函数这里有一个局部变量name。接下来要调用newproc函数,从newproc函数签名来看,要接收两个参数,第一个是传递给协程入口函数的参数占多少字节,第二个是协程入口函数对应的funcval指针,所以在参数空间这里要入栈两个参数,先入栈fn,也就是协程入口函数hello对应的function val指针,再入栈参数大小,一个string类型的参数在64为的情况下占16字节,实际上者个要传递给协程入口函数的参数name,也会被放到第二个参数之后,所以要将局部变脸name拷贝到栈里(由右到左,实际上是第一个入栈的),这样就好像给newproc函数传递了三个参数一样。
而这下面就是Call指令入栈的返回地址,然后是调用者bp,而newproc函数这里主要做的就是切换到g0栈去调用newproc1函数,至于为什么要切换到g0栈,简单来说是因为g0的栈空间它大!因为runtime中很多函数都有no-split标记,意味着这个函数不支持栈增长,也就是说编译器不会在这个函数中插入栈增长相关的检测代码,协程栈本来就比线程栈小的多,这些个函数自己要消耗栈空间,却又不支持栈增长,那在普通协程上执行它们,万一栈溢出了就不好了,而g0的栈直接分配在线程栈上,栈空间足够大。所以直接切换到g0栈来执行这些函数,就不用担心栈溢出的问题了。
再来看它调用newproc1时,都传递了些什么参数,fn和siz不用多说,就是newproc自己接收的那两参数,这个argp赋值时,使用参数fn的地址加上一个指针大小,那就正好是参数name,所以argp用来定位到协程入口函数的参数,第四个参数是当前协程的指针,在这个例子中,newproc函数执行在main goroutine中,所以gp就是main goroutine的g指针,最后一个参数pc,在newproc函数中调用getcallerpc(),得到的是newproc函数调用结束后的返回地址,也就是return addr(由call指令入栈的那个返回地址)。
于newproc1而言,它的任务是创建一个协程,而目前已经知道新创建协程入口在哪里了 ,参数在哪,参数大小,父协程,以及创建完协程后要返回到哪去。
- newproc1首先通过acquirem静止当前m被抢占,为什么呢?因为接下来要执行的程序中,可能会把当前p保存到局部变量中,若此时m被抢占,p关联到别的m,等到再次恢复时,继续使用这个局部变量里保存的p ,就会造成问题,所以为了保持数据一致性,会暂时禁止m被抢占。
- 接下来会尝试获取一个空闲的G,如果当前p和调度器中都没有空闲的G,就创建一个,并添加到全局变量allgs中,我们依然把这个新创建的协程记为hello goroutine。此时它的状态是_Gdead,而且已然拥有自己的协程栈。
- 如果协程入口函数有参数,就把参数移动到协程栈上,对hello goroutine而言,就要把参数name拷贝到hello goroutine stack上
- 接下来会把goexit函数的地址加1,压入协程栈
再把hello goroutine对应的g这里,startpc置为协程入口函数起始地址(fn),gopc置为父协程调用newproc后的返回地址,g.sched这个结构体用于保存现场,此时会把g.sched.sp置为协程栈指针,g.sched.pc指向协程入口函数的起始地址fn。
现在我们来看hello goruotine的协程栈,name是参数,&goexit+1是返回地址,下面是hello函数的栈帧。==其实就“伪装”成了在goexit函数中调用了协程入口函数hello(name),并传递了用户传递的参数,而指令指针刚刚跳转到hello函数的入口处,却还没有开始执行时的状态,所以经这一通伪装,待到这个协程得到调度执行的时候,通过g.sched,就会从hello函数入口处开始执行了,而hello函数结束后便会返回到goexit函数中,执行协程资源回收等收尾工作。==这样一来协程该如何出场,该如何收场,就都有了着落,甚是巧妙。
不过这还没完,newproc1还会给新建的goroutine赋予一个唯一id,给g.goid赋值前,会把协程的状态置为_Grunnable,这个状态意味着这个G可以进入到run queue里了,最后与一开始的acquirem相呼应,调用releasem允许当前m被抢占。所以接下来会调用runqput把这个G放到P的本地队列中。
如果当前有空闲的p,而且没有处于spinning状态(线程自旋)的M,也就是说所有M都在忙,同时主协程以及开始执行了,那么就调用wakep函数,启动一个m并把它置为spinning状态。spinning状态的M启动后,忙不迭的执行调度循环寻找任务,从本地runq,到全局runq,再到其他p的runq,只为找到一个待执行的G,却也敌不过另一边,main goroutine早早的结束了进程。
上一次我们使用time.Sleep,拖延了一个main.main返回的时间,这一次我们通过等待一个channel,看一下协程如何让出又是如何恢复的。channel对应的数据结构是runtime.hchan,里面有channel缓冲区地址,大小,读写下标,也记录着元素类型,大小,及channel是否已关闭,还记录着等待channel的那些G的读队列和写队列,自然也少不了保护channel线程安全的锁。我们的例子中创建的channel是无缓冲的。
<-ch,尝试读取 会被编译器转换成runtime.chanrecv1函数调用,而它实际上会调用runtime.channrecv函数。这个函数的具体逻辑我们到介绍channel时再展开,目前只关注我们的例子会涉及到的一小部分,ch没有缓冲区,目前也没有哪个G等在sendq中,简言之,没数据可读。所以当前goroutine会阻塞在ch这里等待数据,当前goroutine自然是main goroutine,而channel的读等待队列,是一个sudog类型的链表,链表项会记录哪个G等在这里,读到数据后要放到哪里等等。
然后chanrecv通过gopark函数使用当前的goroutine挂起,让出CPU。gopark首先会调用acquirem静止当前M被抢占,然后把main goroutine的状态从_Grunning修改为_Gwaiting,main goroutine不再是执行中的状态了,接下来调用releasem解除M的禁止抢占。最后通过调用mcall(park_m),它主要负责保存当前协程的执行现场,然后切换到g0栈,调用由mcall的参数传入的这个函数,对应到这里就是park_m函数,park_m会根据g0找到当前M,把m.curg置为nil,所以当前m正在执行的G,便不再是mian goroutine了,最后会调用schedult()寻找下一个待执行的G,经过上面这一番折腾,hello goroutine无论是得到当前m的调度,还是被其他m抢了去,总之是可以执行了。
等到hello goroutine完成执行,关闭main goroutine等待的channel时,不只会修改channel的closed状态,还会处理等待队列里的这些G。我们这个例子,只在读队列里有一个main goroutine在等待,所以会把它接收的数据置为nil,并最终调用goready函数结束这个G的等待状态,而goready函数会切换到g0栈,并执行runtime.ready函数,目前待ready的协程自然是main goroutine,此时它的状态是_Gwaiting,接下来被修改为_Grunnable,表示它又可以被调度执行了,然后它会被放到当前P的本地runq中,同协程创建时一样,接下来也会检查是否有空闲的P,并且没有spinning状态的m,是的话也会调用wakep()函数启动新的m,接下来hello goroutine结束,main goroutine得到调度执行,最终结束进程。
这样看来,time.Sleep和channel底层都会调用gopark来实现协程让出,都会使用goready把协程恢复到runnable状态放回到runq中
这一次,我们了解了协程创建的基本步骤,以及协程让出,与恢复到可运行状态的大致过程,接下来看看这个调度循环schedule()开启以后会做什么,又该如何把一个可运行的协程真正运行起来。