原文地址:非main goroutine的退出流程和调度循环
《简单聊聊调度器是如何将main goroutine调度到CPU去执行的~》一文聊到main goroutine退出时会直接调用exit系统调用退出进程,而非main goroutine退出时则会进入goexit完成最后的清理工作,本文就来聊聊非main goroutine是如何返回到goexit以及mcall如何从用户goroutine切到g0继续执行以及调度循环这些内容。
先看段简单的代码:
package main
import (
"fmt"
)
func g2(n int, ch chan int) {
ch <- n * n
}
func main() {
ch := make(chan int)
go g2(100, ch)
fmt.Println(<-ch)
}
上述代码逻辑非常简单,可以理解为main goroutine启动main后创建一个goroutine执行g2,可称之为g2 goroutine,接下来使用gdb中backtrace查看g2是被谁调用的,之后单步执行,看其执行完毕后能否返回goexit继续执行。
gdb调试过程如下:
(gdb) b main.g2 // 在main.g2函数入口处下断点
Breakpoint 1 at 0x4869c0: file /home/bobo/study/go/goexit.go, line 7.
(gdb) r
Starting program: /home/bobo/study/go/goexit
Thread 1 "goexit" hit Breakpoint 1 at /home/bobo/study/go/goexit.go:7
(gdb) bt //查看函数调用链,看起来g2真的是被runtime.goexit调用的
#0 main.g2 (n=100, ch=0xc000052060) at /home/bobo/study/go/goexit.go:7
#1 0x0000000000450ad1 in runtime.goexit () at /usr/local/go/src/runtime/asm_amd64.s:1337
(gdb) disass //反汇编找ret的地址,这是为了在ret处下断点
Dump of assembler code for function main.g2:
=> 0x00000000004869c0 <+0>:mov %fs:0xfffffffffffffff8,%rcx
0x00000000004869c9 <+9>:cmp 0x10(%rcx),%rsp
0x00000000004869cd <+13>:jbe 0x486a0d <main.g2+77>
0x00000000004869cf <+15>:sub $0x20,%rsp
0x00000000004869d3 <+19>:mov %rbp,0x18(%rsp)
0x00000000004869d8 <+24>:lea 0x18(%rsp),%rbp
0x00000000004869dd <+29>:mov 0x28(%rsp),%rax
0x00000000004869e2 <+34>:imul %rax,%rax
0x00000000004869e6 <+38>:mov %rax,0x10(%rsp)
0x00000000004869eb <+43>:mov 0x30(%rsp),%rax
0x00000000004869f0 <+48>:mov %rax,(%rsp)
0x00000000004869f4 <+52>:lea 0x10(%rsp),%rax
0x00000000004869f9 <+57>:mov %rax,0x8(%rsp)
0x00000000004869fe <+62>:callq 0x4046a0 <runtime.chansend1>
0x0000000000486a03 <+67>:mov 0x18(%rsp),%rbp
0x0000000000486a08 <+72>:add $0x20,%rsp
0x0000000000486a0c <+76>:retq
0x0000000000486a0d <+77>:callq 0x44ece0 <runtime.morestack_noctxt>
0x0000000000486a12 <+82>:jmp 0x4869c0 <main.g2>
End of assembler dump.
(gdb) b *0x0000000000486a0c //在retq指令位置下断点
Breakpoint 2 at 0x486a0c: file /home/bobo/study/go/goexit.go, line 9.
(gdb) c
Continuing.
Thread 1 "goexit" hit Breakpoint 2 at /home/bobo/study/go/goexit.go:9
(gdb) disass //程序停在了ret指令处
Dump of assembler code for function main.g2:
0x00000000004869c0 <+0>:mov %fs:0xfffffffffffffff8,%rcx
0x00000000004869c9 <+9>:cmp 0x10(%rcx),%rsp
0x00000000004869cd <+13>:jbe 0x486a0d <main.g2+77>
0x00000000004869cf <+15>:sub $0x20,%rsp
0x00000000004869d3 <+19>:mov %rbp,0x18(%rsp)
0x00000000004869d8 <+24>:lea 0x18(%rsp),%rbp
0x00000000004869dd <+29>:mov 0x28(%rsp),%rax
0x00000000004869e2 <+34>:imul %rax,%rax
0x00000000004869e6 <+38>:mov %rax,0x10(%rsp)
0x00000000004869eb <+43>:mov 0x30(%rsp),%rax
0x00000000004869f0 <+48>:mov %rax,(%rsp)
0x00000000004869f4 <+52>:lea 0x10(%rsp),%rax
0x00000000004869f9 <+57>:mov %rax,0x8(%rsp)
0x00000000004869fe <+62>:callq 0x4046a0 <runtime.chansend1>
0x0000000000486a03 <+67>:mov 0x18(%rsp),%rbp
0x0000000000486a08 <+72>:add $0x20,%rsp
=> 0x0000000000486a0c <+76>:retq
0x0000000000486a0d <+77>:callq 0x44ece0 <runtime.morestack_noctxt>
0x0000000000486a12 <+82>:jmp 0x4869c0 <main.g2>
End of assembler dump.
(gdb) si //单步执行一条指令
runtime.goexit () at /usr/local/go/src/runtime/asm_amd64.s:1338
1338CALLruntime·goexit1(SB)// does not return
(gdb) disass //可以看出来g2已经返回到了goexit函数中
Dump of assembler code for function runtime.goexit:
0x0000000000450ad0 <+0>:nop
=> 0x0000000000450ad1 <+1>:callq 0x42faf0 <runtime.goexit1>
0x0000000000450ad6 <+6>:nop
先在g2入口处下一个断点,程序暂停后查看函数调用栈可发现g2确实被goexit调用,之后再次使用断点,让程序停在g2返回前最后一条指令retq处,单步执行后可看到程序从g2返回到goexit第二条指令处,此处正是当初创建goroutine设置好的返回地址。
由上述推断可看出g2不是被goexit直接调用,是其执行完返回到goexit中。
来看goexit,runtime/asm_amd64.s文件1334行代码:
// The top-most function running on a goroutine
// returns to goexit+PCQuantum.
TEXT runtime·goexit(SB),NOSPLIT,$0-0
BYTE $0x90 // NOP
CALL runtime·goexit1(SB) // does not return
// traceback from goexit1 must hit code range of goexit
BYTE $0x90 // NOP
根据之前的分析,非main goroutine返回时直接返回到goexit第二条指令(CALL runtime.goexit1(SB))处,该指令继续调用goexit1。
来看runtime/proc.go文件2652行代码:
// Finishes execution of the current goroutine.
func goexit1() {
if raceenabled { //与竞争状态检查有关,不关注
racegoend()
}
if trace.enabled { //与backtrace有关,不关注
traceGoEnd()
}
mcall(goexit0)
}
goexit1通过调用mcall从g2切换到g0,然后在g0栈上调用g0.goexit0。
来看runtime/asm_amd64.s文件279行代码:
# func mcall(fn func(*g))
# Switch to m->g0's stack, call fn(g).
# Fn must never return. It should gogo(&g->sched)
# to keep running g.
# mcall的参数是一个指向funcval对象的指针
TEXT runtime·mcall(SB), NOSPLIT, $0-8
#取出参数的值放入DI寄存器,它是funcval对象的指针,此场景中fn.fn是goexit0的地址
MOVQ fn+0(FP), DI
get_tls(CX)
MOVQ g(CX), AX # AX = g,本场景g 是 g2
#mcall返回地址放入BX
MOVQ 0(SP), BX# caller's PC
#保存g2的调度信息,因为我们要从当前正在运行的g2切换到g0
MOVQ BX, (g_sched+gobuf_pc)(AX) #g.sched.pc = BX,保存g2的rip
LEAQ fn+0(FP), BX # caller's SP
MOVQ BX, (g_sched+gobuf_sp)(AX) #g.sched.sp = BX,保存g2的rsp
MOVQ AX, (g_sched+gobuf_g)(AX) #g.sched.g = g
MOVQ BP, (g_sched+gobuf_bp)(AX) #g.sched.bp = BP,保存g2的rbp
# switch to m->g0 & its stack, call fn
#下面三条指令主要目的是找到g0的指针
MOVQ g(CX), BX #BX = g
MOVQ g_m(BX), BX #BX = g.m
MOVQ m_g0(BX), SI #SI = g.m.g0
#此刻,SI = g0, AX = g,所以这里在判断g 是否是 g0,如果g == g0则一定是哪里代码写错了
CMPQ SI, AX# if g == m->g0 call badmcall
JNE 3(PC)
MOVQ $runtime·badmcall(SB), AX
JMP AX
#把g0的地址设置到线程本地存储之中
MOVQ SI, g(CX)
#恢复g0的栈顶指针到CPU的rsp积存,这一条指令完成了栈的切换,从g的栈切换到了g0的栈
MOVQ (g_sched+gobuf_sp)(SI), SP# rsp = g0->sched.sp
#AX = g
PUSHQ AX #fn的参数g入栈
MOVQ DI, DX #DI是结构体funcval实例对象的指针,它的第一个成员才是goexit0的地址
MOVQ 0(DI), DI #读取第一个成员到DI寄存器
CALL DI #调用goexit0(g)
POPQ AX
MOVQ $runtime·badmcall2(SB), AX
JMP AX
RET
mcall的参数是一个函数,Go函数变量不是直接指向函数代码的指针,而是一个指向funcval结构体对象的指针,func第一个成员fn才是指向函数代码的指针。
funcval的简单定义如下:
type funcval struct {
fn uintptr
// variable-size, fn-specific data here
}
由此可知,当前场景mcall的参数fn中fn成员存放的才是goexit0第一条指令的地址。
mcall主要功能如下:
-
先从当前运行的g(当前场景是g2)切换到g0,包括保存当前g的调度信息,将g0设置到TLS中,修改CPU的rsp使其指向g0的栈。
-
以当前运行的g(当前场景是g2)为参数调用fn(当前场景为goexit0)。
可以看到,mcall跟gogo完全相反,gogo实现从g0切换到某个g中运行,mcall则是从某个g切换到g0运行,也正是如此,mcall的代码跟gogo非常相似。
但mcall跟gogo在做切换时有个重要区别,就是gogo从g0切换到其它g时首先切换了栈,然后通过跳转指令从runtime代码切换到用户goroutine代码,mcall在从其它g切换到g0时,则只切换了栈,并未使用跳转至指令跳转到runtime代码去执行。
出现上述区别的原因就在于从g0切换到其它goroutine之前执行的是runtime的代码,使用的是g0的栈,所以切换时要先切换栈,之后再从runtime代码跳转到某个g代码中去执行(切换栈和跳转指令的顺序不能颠倒,原因就是跳转后执行的就是用户goroutine代码了,没有机会再切换栈了),而从某个g切换到g0,g使用的是call调用mcall,mcall本身就是runtime代码,所以call本身已经完成了从g代码到runtime代码的跳转,因此mcall自身代码就不需要跳转,只需切换栈就可实现了。
从g2跳转至g0,之后在g0执行goexit0,该函数完成清理工作,流程如下:
-
将g状态从_Grunning修改为_Gdead。
-
将g的一些字段清空为0值。
-
调用dropg解除g和m之间的关联,本质就是设置g->m = nil, m->currg = nil。
-
将g放入p的freeg队列缓存起来供下次创建g时快速获取,而不用从内存分配。
-
调用schedule再次进行调度。
freeg就是goroutine的对象池。
来看runtime/proc.go文件2662行代码:
// goexit continuation on g0.
func goexit0(gp *g) {
_g_ := getg() //g0
casgstatus(gp, _Grunning, _Gdead) //g马上退出,所以设置其状态为_Gdead
if isSystemGoroutine(gp, false) {
atomic.Xadd(&sched.ngsys, -1)
}
//清空g保存的一些信息
gp.m = nil
locked := gp.lockedm != 0
gp.lockedm = 0
_g_.m.lockedg = 0
gp.paniconfault = false
gp._defer = nil // should be true already but just in case.
gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data.
gp.writebuf = nil
gp.waitreason = 0
gp.param = nil
gp.labels = nil
gp.timer = nil
......
// Note that gp's stack scan is now "valid" because it has no
// stack.
gp.gcscanvalid = true
//g->m = nil, m->currg = nil 解绑g和m之关系
dropg()
......
gfput(_g_.m.p.ptr(), gp) //g放入p的freeg队列,方便下次重用,免得再去申请内存,提高效率
......
//下面再次调用schedule
schedule()
}
至此g的生命周期就结束了,工作线程再次调用schedule进入新一轮循环调度。
任何goroutine调度起来运行都是经过schedule()->execute()->gogo()函数调用链完成的,且在此调用链的函数中是没有返回的。
以刚聊完的g2为例,g2从调度起来执行到结束走的是【schedule()->execute()->gogo()->g2()->goexit()->goexit1()->mcall()->goexit0()->schedule()】,这就可看出,一轮调度从调用schedule开始,经过一系列代码执行,最后再次调用schedule进行新一轮的调度,这一过程可称之为调度循环,这里聊的调度循环指的是在某一工作线程的调度循环,Go程序可能同时运行多个工作线程,每个工作线程都有,也正在运行属于自己的调度循环。
在复杂程序中,调度会进行很多次循环,也就是很多次函数调用,每调用一次函数就会消耗一定的栈空间,如果一直是没有返回的调用,那g0栈空间是否会耗尽呢?
其实不会的,每次执行mcall切换到g0都是切换到g0.sched.sp所指的固定位置,因为从schedule开始的一系列函数都不会返回,所以重新使用这些函数上一轮调度的栈内存也是可以的,来看下工作线程的执行流程和调度循环,如下:
工作线程的执行流程总结如下:
-
初始化,调用mstart。
-
调用mstart1,在mstart1中调用save设置g0.sched.sp和g0.sched.pc等调度信息,其中g0.sched.sp指向mstart栈帧的栈顶。
-
依次调用schedule->execute->gogo执行调度。
-
运行用户goroutine代码。
-
用户goroutine代码执行过程中调用某些runtime函数,然后这些函数调用g0.sched.sp所指的栈,最终再次调用schedule进入新一轮调度,之后工作线程一直循环走第3到第5步,直到程序退出。
以上仅为个人观点,不一定准确,能帮到各位那是最好的。
好啦,到这里本文就结束了,喜欢的话就来个三连击吧。
扫码关注公众号,获取更多优质内容。