在前几篇文章中,我们介绍了Go语言中main goroutine的创建过程。本文将继续探讨非main goroutine(使用go关键字创建的goroutine)的运行机制,并解开前文中提到的main goroutine和非main goroutine之间的区别。
首先,我们来看一个示例:
func g2() {
time.Sleep(10 * time.Second)
println("hello world")
}
func main() {
go g2()
time.Sleep(1 * time.Minute)
println("main exit")
}
从上述示例中可以看出,main函数创建了两个goroutine,一个是main goroutine,另一个是普通的goroutine。在main goroutine中加入了1分钟的等待时间,以便让非main goroutine有机会运行。
接下来,我们来查看非main goroutine是如何创建的:
(dlv) c
> main.main() ./goexit.go:12 (hits goroutine(1):1 total:1) (PC: 0x46238a)
7: func g2() {
8: time.Sleep(10 * time.Second)
9: println("hello world")
10: }
11:
=> 12: func main() {
13: go g2()
14:
15: time.Sleep(30 * time.Minute)
16: println("main exit")
17: }
通过查看CPU的汇编指令,我们发现go关键字实际上被编译转换为了$runtime.newproc函数的调用。这个函数在前几篇文章中已经详细介绍过,这里就不再赘述。
需要说明的是,main goroutine和普通goroutine的执行顺序。当调用runtime.newproc后,非main goroutine会被添加到P的可运行队列(如果队列满,则添加到全局队列),然后线程会调度运行该goroutine。但对于newproc来说,一旦goroutine被放入队列,newproc就会退出,然后继续执行后续的main goroutine代码。
如果此时非main goroutine未运行或未结束,并且main goroutine未等待或阻塞,main goroutine将直接退出。
前面提到main goroutine和非main goroutine的区别主要体现在goroutine的退出方式。main goroutine的退出比较直接,直接调用exit(0)退出进程。那么,非main goroutine是如何退出的呢?
我们在g2函数的结束点处设置断点,观察g2是如何退出的:
(dlv) b ./goexit.go:10
Breakpoint 1 set at 0x46235b for main.g2() ./goexit.go:10
(dlv) c
hello world
> main.g2() ./goexit.go:10 (hits goroutine(5):1 total:1) (PC: 0x46235b)
7: func g2() {
8: time.Sleep(10 * time.Second)
9: println("hello world")
=> 10: }
11:
12: func main() {
13: go g2()
14:
15: time.Sleep(30 * time.Minute)
(dlv) si
> main.g2() ./goexit.go:10 (PC: 0x46235f)
goexit.go:9 0x462345 488d05b81b0100 lea rax, ptr [rip+0x11bb8]
goexit.go:9 0x46234c bb0c000000 mov ebx, 0xc
goexit.go:9 0x462351 e88a30fdff call $runtime.printstring
goexit.go:9 0x462356 e86528fdff call $runtime.printunlock
goexit.go:10 0x46235b* 4883c410 add rsp, 0x10
=> goexit.go:10 0x46235f 5d pop rbp
goexit.go:10 0x462360 c3 ret
goexit.go:7 0x462361 e89ab1ffff call $runtime.morestack_noctxt
goexit.go:7 0x462366 ebb8 jmp $main.g2
CPU执行指令到pop rbp,然后执行ret:
goexit.go:10 0x46235f 5d pop rbp
=> goexit.go:10 0x462360 c3 ret
goexit.go:7 0x462361 e89ab1ffff call $runtime.morestack_noctxt
goexit.go:7 0x462366 ebb8 jmp $main.g2
(dlv) si
> runtime.goexit() /usr/local/go/src/runtime/asm_amd64.s:1651 (PC: 0x45d7a1)
Warning: debugging optimized function
TEXT runtime.goexit(SB) /usr/local/go/src/runtime/asm_amd64.s
asm_amd64.s:1650 0x45d7a0 90 nop
=> asm_amd64.s:1651 0x45d7a1 e8ba250000 call $runtime.goexit1
asm_amd64.s:1653 0x45d7a6 90 nop
我们发现,执行ret后直接跳转到了call $runtime.goexit1。在前文中我们提到,每个goroutine的栈在“栈顶”放有funcPC(goexit) + 1的地址。这里实际上进行了一种偷梁换柱的操作,非main goroutine的栈在退出时会跳转到call $runtime.goexit1继续执行。
进入runtime.goexit1:
// 结束当前goroutine的执行。
func goexit1() {
...
mcall(goexit0) // mcall会切换当前栈到g0栈,然后在g0栈执行goexit0
}
实际执行的是goexit0:
// 在g0上继续goexit。
func goexit0(gp *g) {
mp := getg().m // 这里是g0栈,mp = m0
pp := mp.p.ptr() // m0绑定的P
casgstatus(gp, _Grunning, _Gdead) // 将gp的状态更新为_Gdead
gp.m = nil // 将gp绑定的线程更新为nil,解绑线程
...
dropg() // 将当前线程和gp解绑
...
gfput(pp, gp) // 退出的gp还可以重用,gfput将gp放到本地或全局空闲队列中
...
schedule() // 线程执行完一个gp还未退出,继续进入schedule找goroutine执行
}
非main goroutine退出了,但线程并没有退出,线程会将非main goroutine安顿好后,继续开始新一轮调度。
本文介绍了使用go关键字创建的goroutine是如何运行的,下一篇文章将继续分析调度器的行为。