上一篇博客总结了Go调度器的设计以及go调度器解决如何解决了用户态线程典型问题,这一篇就跟踪下Goroutine的源码实现。Go1.5源码剖析 已经写的非常详细了,我只把我觉得重要的地方集中总结一下。这两篇写好挺久了,以后再补充。
Go程序初始化过程
C程序的入口地址通常是C运行库的_start函数,它完成了初始化堆栈、命令行参数设置、环境变量初始化、IO初始化、线程相关初始化或者还有全局构造。Go的入口函数整个初始化过程也完成了类似的工作。
1 | runtime.args // 整理命令行参数 设置环境变量 |
初始化过程之后进入了runtime.main
。这时启动了后台监控线程sysmon
。执行了runtime包和用户包所有初始化init函数之后进入用户的main
函数。
1 | func main() { |
需要注意的是runtime.main是通过newproc
和mstart
创建的。也就是说main对应的是goroutine而不是线程,所以它的地位还没有sysmon
高啊。
1 | runtime.newproc |
P与G的创建
schedinit
schedinit中与调度器相关的操作包括,设置最大的M数量10000;初始化当前的m;初始化P的数目默认为CPU核数,可以通过环境变量GOMAXPROCS设置;最后调整P的大小。
1 | func schedinit() { |
调整P大小是因为P保存在全局数组allp
中,它在.data段就分配了空间 [_MaxGomaxprocs + 1]*p
,这对应着256+1个指针空间。在schedinit
中通过procresize
将这个空间里的nprocs
个指针初始化,其余的删除。
- freeUnused P时要处理P里面原始的G队列,将他们放到全局schedt中。当然schedinit时不存在这个操作,这个逻辑是startTheWorld修改P数目准备的。
- 如果当前的P是被释放的那一拨,则将当前P与M分离,将M与allp[0]绑定。
- 处理allp[0-nprocs],将没有本地G的P放入schedt的全局idleP链表,将有本地G队列的作为runnalblePs链表返回。
1 | func procresize(nprocs int32) *p { |
newproc
go编译器将go func()
翻译成runtime.newproc()
。为了go func的执行,从右到左入栈了调用方的PC寄存器,返回值数目,参数数目,第一个参数的地址以及函数地址。
1 | func newproc(siz int32, fn *funcval) { |
newproc1
创建了G实例。从gfget()
获取空闲的G对象,若获取失败则malg
新建G对象。设置栈空间和保存现场的sched域以及初始状态Grunnable,runqput放入待运行队列,如果有空闲的P则尝试唤醒它来执行。
gfget
是从p的gfree列表或全局sched的gfree链表中获取可以复用的G对象。当goroutine结束时调用goexit0
时会将当前的G对象gfput到p本地的gfree队列中。malg
用默认的2KB栈空间来将new(g)
创建的新G对象初始化。主要是通过stackalloc
初始化newg.stack。- go func指定的执行参数会被拷贝到G的栈空间,因为它跟main所在的栈不再有任何关系,各自使用独立的栈空间。
- 创建好的G优先放入P.runnext,或者放入数组实现的循环队列P.runq,若本地队列
runq [256]*g
已满则加锁放入全局队列Sched.runq。 - 如果本地队列满会通过
runqputslow
将P本地一半的任务G放到全局队列中。使得别的P可以去执行,这也是最后wakeP去唤醒其他M/P执行任务的原因。
1 | func newproc1(fn *funcval, argp *uint8, narg int32, |
M的创建和G的执行
从上一节可见runtime.newproc
只是创建了G并放入当前P的G队列或全局G队列。如果是main goroutine,则显示调用mstart
;其他goroutine则尝试wakeP去启动M的创建和G的执行。
wakeP+startm
首先G创建后会尝试通过pidleget
去Sched.pidle链表获取空闲的P来执行,若没有的话就继续排队等待现有的P执行。获取到P后需要绑定M来执行,这时可以从shec.midle中获取可复用的m,通过notewakeup
唤醒M;若没有空闲的M则重建newm
。
1 | func wakep() { |
newM
allocm
主要就是初始化了m自带的名为g0的栈,默认8KB栈内存。它的栈内存地址会被传给newosproc
,作为系统线程默认的栈空间。mcommoninit
检查M数目是否超过默认的10000,然后将m添加到allm
链表且不会释放。newosproc
表示创建OS线程,Linux调用的是clone
,并指定了以下flags表示哪些进程资源可以共享,最后CLONE_THREAD表示clone出来的是线程,与当前进程显示同一个pid。同时指定了OS线程对应的启动函数是mstart
。
CLONE_VM| CLONE_FS | CLONE_FILES| CLONE_SIGHAND | CLONE_THREAD
1 | func newm(fn func(), _p_ *p) { |
mstart
无论是main goroutine还是其他goroutine,最终G执行的起点都是mstart。mstart主要设置了G的stack空间边界以及将m与它的nextp进行绑定。绑定过程acquirep
,即m获取p的mcache并设置P的状态为prunning。
1 | func mstart() { |
schedule
总结G的执行过程:从各种渠道获取G任务+执行execute这个G任务。执行G时需要从当前g0栈切换到G的栈执行,返回时执行goexit清理现场,然后重新进入schedule。
- 获取G任务优先从本地P队列中runqget获取,另外每处理n个任务就要去全局获取G任务,如果本地G和全局G,甚至网络任务netpoll都没有,则从其它的P队列steal。
- execute任务是最终调用的是
gogo
函数。它完成了g0栈道G栈的切换,JMP到G任务函数代码执行。 - G任务返回时执行的是
goexit
,因为在newproc1初始化G时,它的栈空间入栈的返回地址是goexit。goexit
完成了G状态的清理,将G放回复用链表重新进入调度循环。
1 | func schedule() { |
1 | func goexit0(gp *g) { |
状态变迁
P与G的状态变迁
P创建于schedinit程序初始化时,除了当前对应main goroutine的P,其他npcrocs-1个P都放进空闲P链表中等待使用,状态为Pidle。当m与p绑定时调用acquirep
会将P状态设置为Prunning。Psyscall只有进入系统调用时发生。Pdead只有调整prosize大小时用到。
1 | const ( |
G创建于newproc即通过go关键字调用函数时初始为Gidle。在给G分配栈空间之前G为Gdead,初始化后放进队列之前状态改为Grunnable。m真正执行到G后状态才是Grunning。
1 | const ( |
gopark+goready
Gwaiting只有park_m
才会出现,这时除非发生runtime.ready
否则G永远不会执行。因为Gwaiting并不出现在待运行队列中。channel操作 定时器 网络poll都有可能park goroutine。
1 | func park_m(gp *g) { |
midle与gsyscall
当陷入系统调用的G返回时,首先要dropg与原始的M分开,因为原始的M已经没有P给它提供内存了。之后G要重新pidleget找到一个空闲的P入队,若没有则入队全局队列。最后stopm停止当前m并继续schedule。
1 | func exitsyscall0(gp *g) { |
当M从系统调用退出时exitsyscall0
会调用stopm
把m放进空闲m链表,陷入休眠等待被唤醒。startm时所谓空闲的M的来源就是从系统调用中恢复的M。startm发生在两个时候:有新的G加入wakeP时,handOffP时P还有别的G任务。这时都会触发空闲M重用,对应notewakeup
。
1 | func stopm() { |