Go语言调度器
1、创建main goroutine
schedinit完成调度系统初始化后,返回到rt0_go函数中开始调用newproc() 创建一个新的goroutine用于执行mainPC所对应的runtime·main函数,看下面的代码:
runtime/asm_amd64.s : 197
# create a new goroutine to start program MOVQ $runtime·mainPC(SB), AX # entry,mainPC是runtime.main # newproc的第二个参数入栈,也就是新的goroutine需要执行的函数 PUSHQ AX # AX = &funcval{runtime·main}, # newproc的第一个参数入栈,该参数表示runtime.main函数需要的参数大小,因为runtime.main没有参数,所以这里是0 PUSHQ $0 CALL runtime·newproc(SB) # 创建main goroutine POPQ AX POPQ AX # start this M CALL runtime·mstart(SB) # 主线程进入调度循环,运行刚刚创建的goroutine # 上面的mstart永远不应该返回的,如果返回了,一定是代码逻辑有问题,直接abort CALL runtime·abort(SB)// mstart should never return RET DATA runtime·mainPC+0(SB)/8,$runtime·main(SB) GLOB Lruntime·mainPC(SB),RODATA,$8
在后面的分析过程中我们会看到这个runtime.main最终会调用我们写的main.main函数,在分析runtime·main之前我们先把重点放在newproc这个函数上。
newproc函数用于创建新的goroutine,它有两个参数,先说第二个参数fn,新创建出来的goroutine将从fn这个函数开始执行,而这个fn函数可能也会有参数,newproc的第一个参数正是fn函数的参数以字节为单位的大小。比如有如下go代码片段:
func start(a, b, c int64) { ...... } func main() { go start(1, 2, 3) }
编译器在编译上面的go语句时,就会把其替换为对newproc函数的调用,编译后的代码逻辑上等同于下面的伪代码
func main() { push 0x3 push 0x2 push 0x1 runtime.newproc(24, start) }
编译器编译时首先会用几条指令把start函数需要用到的3个参数压栈,然后调用newproc函数。因为start函数的3个int64类型的参数共占24个字节,所以传递给newproc的第一个参数是24,表示start函数需要24字节大小的参数。
那为什么需要传递fn函数的参数大小给newproc函数呢?原因就在于newproc函数将创建一个新的goroutine来执行fn函数,而这个新创建的goroutine与当前这个goroutine会使用不同的栈,因此就需要在创建goroutine的时候把fn需要用到的参数先从当前goroutine的栈上拷贝到新的goroutine的栈上之后才能让其开始执行,而newproc函数本身并不知道需要拷贝多少数据到新创建的goroutine的栈上去,所以需要用参数的方式指定拷贝多少数据。
了解完这些背景知识之后,下面我们开始分析newproc的代码。newproc函数是对newproc1的一个包装,这里最重要的准备工作有两个,一个是获取fn函数第一个参数的地址(代码中的argp),另一个是使用systemstack函数切换到g0栈,当然,对于我们这个初始化场景来说现在本来就在g0栈,所以不需要切换,然而这个函数是通用的,在用户的goroutine中也会创建goroutine,这时就需要进行栈的切换。
runtime/proc.go : 3232
// Create a new g running fn with siz bytes of arguments. // Put it on the queue of g's waiting to run. // The compiler turns a go statement into a call to this. // Cannot split the stack because it assumes that the arguments // are available sequentially after &fn; they would not be // copied if a stack split occurred. //go:nosplit func newproc(siz int32, fn *funcval) { //函数调用参数入栈顺序是从右向左,而且栈是从高地址向低地址增长的 //注意:argp指向fn函数的第一个参数,而不是newproc函数的参数 //参数fn在栈上的地址+8的位置存放的是fn函数的第一个参数 argp := add(unsafe.Pointer(&fn), sys.PtrSize) gp := getg() //获取正在运行的g,初始化时是m0.g0 //getcallerpc()返回一个地址,也就是调用newproc时由call指令压栈的函数返回地址, //对于我们现在这个场景来说,pc就是CALLruntime·newproc(SB)指令后面的POPQ AX这条指令的地址 pc := getcallerpc() //systemstack的作用是切换到g0栈执行作为参数的函数 //我们这个场景现在本身就在g0栈,因此什么也不做,直接调用作为参数的函数 systemstack(func() { newproc1(fn, (*uint8)(argp), siz, gp, pc) }) }
系统栈切换

/ systemstack_switch is a dummy routine that systemstack leaves at the bottom // of the G stack. We need to distinguish the routine that // lives at the bottom of the G stack from the one that lives // at the top of the system stack because the one at the top of // the system stack terminates the stack walk (see topofstack()). // The frame layout needs to match systemstack // so that it can pretend to be systemstack_switch. TEXT runtime·systemstack_switch(SB), NOSPLIT, $0-0 UNDEF // Make sure this function is not leaf, // so the frame is saved. CALL runtime·abort(SB) RET // func systemstack(fn func()) TEXT runtime·systemstack(SB), NOSPLIT, $0-8 MOVQ fn+0(FP), DI // DI = fn 将调用的函数保存到DI 寄存器 get_tls(CX) // 调用get_tls 获取当前线程存储中的g MOVQ g(CX), AX // AX = g 将当前g 放入AX 寄存器 MOVQ g_m(AX), BX // BX = m 将与当前g绑定的m 放入BX寄存器, CMPQ AX, m_gsignal(BX) 比较g 和m.gsignal JEQ noswitch // 相等时代表是当前g,跳转到不需要切换到g0() MOVQ m_g0(BX), DX // DX = g0 把g0 地址放到DX CMPQ AX, DX // 比较当前g 和 g0 JEQ noswitch // 如果g就是g0 就不用切换 CMPQ AX, m_curg(BX) // 比较g 和 m绑定的curg JNE bad // 不是同一个就跳转bad // Switch stacks. // The original frame pointer is stored in BP, // which is useful for stack unwinding. // Save our state in g->sched. Pretend to // be systemstack_switch if the G stack is scanned. CALL gosave_systemstack_switch<>(SB) // 调用 gosave_systemstack_switch // switch to g0 MOVQ DX, g(CX) // 把DX也就是g0 赋值给g MOVQ DX, R14 // set the g register MOVQ (g_sched+gobuf_sp)(DX), SP //把g.sched.gobuf.sp (此处g即是g0)放到 SP寄存器(使用g0 的栈帧执行fn) // call target function MOVQ DI, DX // 把DI 也就是 fn 放到DX寄存器 MOVQ 0(DI), DI CALL DI // 调用fn (使用g0 的栈帧执行fn) // switch back to g 将当前栈帧恢复成调用者goroutine 的栈帧 get_tls(CX) // 获取当前线程g MOVQ g(CX), AX // 把g地址放到AX MOVQ g_m(AX), BX // 把g.m 放到BX MOVQ m_curg(BX), AX // 把g.m.curg 放到AX MOVQ AX, g(CX) // 把AX 放到g MOVQ (g_sched+gobuf_sp)(AX), SP // 恢复g的现场 g.sched.gobuf.sp 放到SP MOVQ (g_sched+gobuf_bp)(AX), BP // 恢复g的现场 g.sched.gobuf.bp 放到BP MOVQ $0, (g_sched+gobuf_sp)(AX) // 恢复g的现场 g.sched.gobuf.bp 赋值为0 MOVQ $0, (g_sched+gobuf_bp)(AX) // 恢复g的现场 g.sched.gobuf.bp 赋值为0 RET // 结束调用 noswitch: // already on m stack; tail call the function // Using a tail call here cleans up tracebacks since we won't stop // at an intermediate systemstack. MOVQ DI, DX MOVQ 0(DI), DI // The function epilogue is not called on a tail call. // Pop BP from the stack to simulate it. POPQ BP JMP DI bad: // Bad: g is not gsignal, not g0, not curg. What is it? MOVQ $runtime·badsystemstack(SB), AX CALL AX INT $3
newproc1函数的第一个参数fn是新创建的goroutine需要执行的函数,注意这个fn的类型是funcval结构体类型,其定义如下:
type funcval struct { fn uintptr // variable-size, fn-specific data here }
newproc1的第二个参数argp是fn函数的第一个参数的地址,第三个参数是fn函数的参数以字节为单位的大小,后面两个参数我们不用关心。这里需要注意的是,newproc1是在g0的栈上执行的。该函数很长也很重要,所以我们分段来看。
runtime/proc.go : 3248
/ Create a new g running fn with narg bytes of arguments starting // at argp. callerpc is the address of the go statement that created // this. The new g is put on the queue of g's waiting to run. func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) { //因为已经切换到g0栈,所以无论什么场景都有 _g_ = g0,当然这个g0是指当前工作线程的g0 //对于我们这个场景来说,当前工作线程是主线程,所以这里的g0 = m0.g0 _g_ := getg() ...... _p_ := _g_.m.p.ptr() //初始化时_p_ = g0.m.p,从前面的分析可以知道其实就是allp[0] newg := gfget(_p_) //从p的本地缓冲里获取一个没有使用的g,初始化时没有,返回nil if newg == nil { //new一个g结构体对象,然后从堆上为其分配栈,并设置g的stack成员和两个stackgard成员 newg = malg(_StackMin) casgstatus(newg, _Gidle, _Gdead) //初始化g的状态为_Gdead //放入全局变量allgs切片中 allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack. } ...... //调整g的栈顶置针,无需关注 totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize // extra space in case of reads slightly beyond frame totalSize += -totalSize & (sys.SpAlign - 1) // align to spAlign sp := newg.stack.hi - totalSize spArg := sp //...... if narg > 0 { //把参数从执行newproc函数的栈(初始化时是g0栈)拷贝到新g的栈 memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg)) // ...... }
这段代码主要从堆上分配一个g结构体对象并为这个newg分配一个大小为2048字节的栈,并设置好newg的stack成员,然后把newg需要执行的函数的参数从执行newproc函数的栈(初始化时是g0栈)拷贝到newg的栈,完成这些事情之后newg的状态如下图所示:
我们可以看到,经过前面的代码之后,程序中多了一个我们称之为newg的g结构体对象,该对象也已经获得了从堆上分配而来的2k大小的栈空间,newg的stack.hi和stack.lo分别指向了其栈空间的起止位置。
接下来我们继续分析newproc1函数。
runtime/proc.go : 3314
//把newg.sched结构体成员的所有成员设置为0 memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched)) //设置newg的sched成员,调度器需要依靠这些字段才能把goroutine调度到CPU上运行。 newg.sched.sp = sp //newg的栈顶 newg.stktopsp = sp //newg.sched.pc表示当newg被调度起来运行时从这个地址开始执行指令 //把pc设置成了goexit这个函数偏移1(sys.PCQuantum等于1)的位置, //至于为什么要这么做需要等到分析完gostartcallfn函数才知道 newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function newg.sched.g = guintptr(unsafe.Pointer(newg)) gostartcallfn(&newg.sched, fn) //调整sched成员和newg的栈
这段代码首先对newg的sched成员进行了初始化,该成员包含了调度器代码在调度goroutine到CPU运行时所必须的一些信息,其中sched的sp成员表示newg被调度起来运行时应该使用的栈的栈顶,sched的pc成员表示当newg被调度起来运行时从这个地址开始执行指令,然而从上面的代码可以看到,new.sched.pc被设置成了goexit函数的第二条指令的地址而不是fn.fn,这是为什么呢?要回答这个问题,必须深入到gostartcallfn函数中做进一步分析。
// adjust Gobuf as if it executed a call to fn // and then did an immediate gosave. func gostartcallfn(gobuf *gobuf, fv *funcval) { var fn unsafe.Pointer if fv != nil { fn = unsafe.Pointer(fv.fn) //fn: gorotine的入口地址,初始化时对应的是runtime.main } else { fn = unsafe.Pointer(funcPC(nilfunc)) } gostartcall(gobuf, fn, unsafe.Pointer(fv)) }
gostartcallfn首先从参数fv中提取出函数地址(初始化时是runtime.main),然后继续调用gostartcall函数
// adjust Gobuf as if it executed a call to fn with context ctxt // and then did an immediate gosave. func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) { sp := buf.sp //newg的栈顶,目前newg栈上只有fn函数的参数,sp指向的是fn的第一参数 if sys.RegSize > sys.PtrSize { sp -= sys.PtrSize *(*uintptr)(unsafe.Pointer(sp)) = 0 } sp -= sys.PtrSize //为返回地址预留空间, //这里在伪装fn是被goexit函数调用的,使得fn执行完后返回到goexit继续执行,从而完成清理工作 *(*uintptr)(unsafe.Pointer(sp)) = buf.pc //在栈上放入goexit+1的地址 buf.sp = sp //重新设置newg的栈顶寄存器 //这里才真正让newg的ip寄存器指向fn函数,注意,这里只是在设置newg的一些信息,newg还未执行, //等到newg被调度起来运行时,调度器会把buf.pc放入cpu的IP寄存器, //从而使newg得以在cpu上真正的运行起来 buf.pc = uintptr(fn) buf.ctxt = ctxt }
gostartcall函数的主要作用有两个:
-
调整newg的栈空间,把goexit函数的第二条指令的地址入栈,伪造成goexit函数调用了fn,从而使fn执行完成后执行ret指令时返回到goexit继续执行完成最后的清理工作;
-
重新设置newg.buf.pc 为需要执行的函数的地址,即fn,我们这个场景为runtime.main函数的地址。
调整完成newg的栈和sched成员之后,返回到newproc1函数,我们继续往下看
newg.gopc = callerpc //主要用于traceback newg.ancestors = saveAncestors(callergp) //设置newg的startpc为fn.fn,该成员主要用于函数调用栈的traceback和栈收缩 //newg真正从哪里开始执行并不依赖于这个成员,而是sched.pc newg.startpc = fn.fn ...... //设置g的状态为_Grunnable,表示这个g代表的goroutine可以运行了 casgstatus(newg, _Gdead, _Grunnable) ...... //把newg放入_p_的运行队列,初始化的时候一定是p的本地运行队列,其它时候可能因为本地队列满了而放入全局队列 runqput(_p_, newg, true) ...... }
newproc1函数最后这点代码比较直观,首先设置了几个与调度无关的成员变量,然后修改newg的状态为_Grunnable并把其放入了运行队列,到此程序中第一个真正意义上的goroutine已经创建完成。
这时newg也就是main goroutine的状态如下图所示:
这个图看起来比较复杂,因为表示指针的箭头实在是太多了,这里对其稍作一下解释。
-
首先,main goroutine对应的newg结构体对象的sched成员已经完成了初始化,图中只显示了pc和sp成员,pc成员指向了runtime.main函数的第一条指令,sp成员指向了newg的栈顶内存单元,该内存单元保存了runtime.main函数执行完成之后的返回地址,也就是runtime.goexit函数的第二条指令,预期runtime.main函数执行完返回之后就会去执行runtime.exit函数的CALL runtime.goexit1(SB)这条指令;
-
其次,newg已经放入与当前主线程绑定的p结构体对象的本地运行队列,因为它是第一个真正意义上的goroutine,还没有其它goroutine,所以它被放在了本地运行队列的头部;
-
最后,newg的m成员为nil,因为它还没有被调度起来运行,也就没有跟任何m进行绑定。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
2020-06-13 ubuntu 更换国内镜像源