由生到死看透 goroutine
通过一个简单的样例代码来了解 goroutine 的从生到死,样例代码如下所示:
package main
import (
"fmt"
"time"
)
func goCaller() {
fmt.Println("hello goroutine")
}
func main() {
goCaller()
go goCaller()
time.Sleep(3 * time.Second)
}
go 关键字
通过 dlv 工具查看样例代码的反汇编:
root@ubuntu-hirsute:~/go/src/go-tracing/goroutine#
>> dlv debug main.go --check-go-version=false
WARNING: undefined behavior - Go version 1.13.6 is too old for this version of Delve (minimum supported version 1.16)
Type 'help' for list of commands.
(dlv) b main.main
Breakpoint 1 set at 0x4aa6af for main.main() ./main.go:12
(dlv) r
Process restarted with PID 1200953
(dlv) c
> main.main() ./main.go:12 (hits goroutine(1):1 total:1) (PC: 0x4aa6af)
7:
8: func goCaller() {
9: fmt.Println("hello goroutine")
10: }
11:
=> 12: func main() {
13: goCaller()
14: go goCaller()
15: time.Sleep(3 * time.Second)
16: }
(dlv) disass
TEXT main.main(SB) /root/go/src/go-tracing/goroutine/main.go
main.go:12 0x4aa6a0 64488b0c25f8ffffff mov rcx, qword ptr fs:[0xfffffff8]
main.go:12 0x4aa6a9 483b6110 cmp rsp, qword ptr [rcx+0x10]
main.go:12 0x4aa6ad 7643 jbe 0x4aa6f2
=> main.go:12 0x4aa6af* 4883ec18 sub rsp, 0x18
main.go:12 0x4aa6b3 48896c2410 mov qword ptr [rsp+0x10], rbp
main.go:12 0x4aa6b8 488d6c2410 lea rbp, ptr [rsp+0x10]
main.go:13 0x4aa6bd e83effffff call $main.goCaller
main.go:14 0x4aa6c2 c7042400000000 mov dword ptr [rsp], 0x0
main.go:14 0x4aa6c9 488d05c8050400 lea rax, ptr [rip+0x405c8]
main.go:14 0x4aa6d0 4889442408 mov qword ptr [rsp+0x8], rax
main.go:14 0x4aa6d5 e8b6c2f8ff call $runtime.newproc
main.go:15 0x4aa6da b8005ed0b2 mov eax, -0x4d2fa200
main.go:15 0x4aa6df 48890424 mov qword ptr [rsp], rax
main.go:15 0x4aa6e3 e85804faff call $time.Sleep
main.go:16 0x4aa6e8 488b6c2410 mov rbp, qword ptr [rsp+0x10]
main.go:16 0x4aa6ed 4883c418 add rsp, 0x18
main.go:16 0x4aa6f1 c3 ret
main.go:12 0x4aa6f2 e849e9faff call $runtime.morestack_noctxt
.:0 0x4aa6f7 eba7 jmp $main.main
(dlv)
goCaller
正常函数调用的汇编:
main.go:12 0x4aa6b3 48896c2410 mov qword ptr [rsp+0x10], rbp
main.go:12 0x4aa6b8 488d6c2410 lea rbp, ptr [rsp+0x10]
main.go:13 0x4aa6bd e83effffff call $main.goCaller
go goCaller()
goroutine 函数调用的汇编:
main.go:14 0x4aa6c2 c7042400000000 mov dword ptr [rsp], 0x0
main.go:14 0x4aa6c9 488d05c8050400 lea rax, ptr [rip+0x405c8]
main.go:14 0x4aa6d0 4889442408 mov qword ptr [rsp+0x8], rax
main.go:14 0x4aa6d5 e8b6c2f8ff call $runtime.newproc
通过 dlv
调试看看 runtime.newproc
函数到底做了什么:
(dlv) n
hello goroutine
> main.main() ./main.go:14 (PC: 0x4aa6c2)
9: fmt.Println("hello goroutine")
10: }
11:
12: func main() {
13: goCaller()
=> 14: go goCaller()
15: time.Sleep(3 * time.Second)
16: }
(dlv) disass
TEXT main.main(SB) /root/go/src/go-tracing/goroutine/main.go
main.go:12 0x4aa6a0 64488b0c25f8ffffff mov rcx, qword ptr fs:[0xfffffff8]
main.go:12 0x4aa6a9 483b6110 cmp rsp, qword ptr [rcx+0x10]
main.go:12 0x4aa6ad 7643 jbe 0x4aa6f2
main.go:12 0x4aa6af* 4883ec18 sub rsp, 0x18
main.go:12 0x4aa6b3 48896c2410 mov qword ptr [rsp+0x10], rbp
main.go:12 0x4aa6b8 488d6c2410 lea rbp, ptr [rsp+0x10]
main.go:13 0x4aa6bd e83effffff call $main.goCaller
=> main.go:14 0x4aa6c2 c7042400000000 mov dword ptr [rsp], 0x0
main.go:14 0x4aa6c9 488d05c8050400 lea rax, ptr [rip+0x405c8]
main.go:14 0x4aa6d0 4889442408 mov qword ptr [rsp+0x8], rax
main.go:14 0x4aa6d5 e8b6c2f8ff call $runtime.newproc
main.go:15 0x4aa6da b8005ed0b2 mov eax, -0x4d2fa200
main.go:15 0x4aa6df 48890424 mov qword ptr [rsp], rax
main.go:15 0x4aa6e3 e85804faff call $time.Sleep
main.go:16 0x4aa6e8 488b6c2410 mov rbp, qword ptr [rsp+0x10]
main.go:16 0x4aa6ed 4883c418 add rsp, 0x18
main.go:16 0x4aa6f1 c3 ret
main.go:12 0x4aa6f2 e849e9faff call $runtime.morestack_noctxt
.:0 0x4aa6f7 eba7 jmp $main.main
(dlv) b runtime.newproc
Breakpoint 2 set at 0x436990 for runtime.newproc() /usr/local/go/src/runtime/proc.go:3251
(dlv) n
> runtime.newproc() /usr/local/go/src/runtime/proc.go:3251 (hits goroutine(1):1 total:1) (PC: 0x436990)
Warning: debugging optimized function
3246: // The compiler turns a go statement into a call to this.
3247: // Cannot split the stack because it assumes that the arguments
3248: // are available sequentially after &fn; they would not be
3249: // copied if a stack split occurred.
3250: //go:nosplit
=>3251: func newproc(siz int32, fn *funcval) {
3252: argp := add(unsafe.Pointer(&fn), sys.PtrSize)
3253: gp := getg()
3254: pc := getcallerpc()
3255: systemstack(func() {
3256: newproc1(fn, (*uint8)(argp), siz, gp, pc)
(dlv) regs
Rip = 0x0000000000436990
Rsp = 0x000000c000049f38
Rax = 0x00000000004eac98
Rbx = 0x000000c000049bb8
Rcx = 0x0000000000000000
Rdx = 0x00000000004dc040
Rsi = 0x0000000000000000
Rdi = 0x000000c00008c008
Rbp = 0x000000c000049f50
R8 = 0x0000000000000000
R9 = 0x0000000000000000
R10 = 0x0000000000000000
R11 = 0x0000000000000202
R12 = 0x0000000000203000
R13 = 0x0000000000000000
R14 = 0x00000000000000c8
R15 = 0x0000000000000034
Rflags = 0x0000000000000202 [IF IOPL=0]
Es = 0x0000000000000000
Cs = 0x0000000000000033
Ss = 0x000000000000002b
Ds = 0x0000000000000000
Fs = 0x0000000000000000
Gs = 0x0000000000000000
Fs_base = 0x0000000000582110
Gs_base = 0x0000000000000000
(dlv) p *(*runtime.funcval)(0x00000000004eac98)
runtime.funcval {fn: 4892160}
(dlv) p &main.goCaller
(*)(0x4aa600)
(dlv) n
> runtime.newproc() /usr/local/go/src/runtime/proc.go:3252 (PC: 0x43699e)
Warning: debugging optimized function
3247: // Cannot split the stack because it assumes that the arguments
3248: // are available sequentially after &fn; they would not be
3249: // copied if a stack split occurred.
3250: //go:nosplit
3251: func newproc(siz int32, fn *funcval) {
=>3252: argp := add(unsafe.Pointer(&fn), sys.PtrSize)
3253: gp := getg()
3254: pc := getcallerpc()
3255: systemstack(func() {
3256: newproc1(fn, (*uint8)(argp), siz, gp, pc)
3257: })
(dlv) p siz
0
通过上述 debug 可以发现,go goCaller()
其实转换成了 runtime.newproc(0, (*runtime.funcval)(0x00000000004eac98))
,0x4eac98
是 goroutine 调用的 funcval 变量的地址。funcval 结构体如下所示:
type struct funcval {
fn uintptr
}
通过打印 runtime.newproc
函数的 funcval
变量可以确认,调用就是函数 goCaller
即 (0x4aa600)
。
goroutine 创建
新的goroutine都是通过函数 runtime.newproc
创建的,runtime.newproc 函数定义如下所示:
// 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.
//
// The stack layout of this call is unusual: it assumes that the
// arguments to pass to fn are on the stack sequentially immediately
// after &fn. Hence, they are logically part of newproc's argument
// frame, even though they don't appear in its signature (and can't
// because their types differ between call sites).
//
// This must be nosplit because this stack layout means there are
// untyped arguments in newproc's argument frame. Stack copies won't
// be able to adjust them and stack splits won't be able to copy them.
//
//go:nosplit
func newproc(siz int32, fn *funcval) {
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg()
pc := getcallerpc()
systemstack(func() {
newg := newproc1(fn, argp, siz, gp, pc)
_p_ := getg().m.p.ptr()
runqput(_p_, newg, true)
if mainStarted {
wakep()
}
})
}
func newproc(siz int32, fn *funcval)
函数功能就是创建一个新的 g
,根据代码注释,这个函数不能用分段栈,因为它假设参数的放置顺序是紧接着函数 fn
的,分段栈会破坏这个布局,所以在代码中加入了标记go:nosplit
即 #pragma textflag 7
表示不使用分段栈。newproc
它会调用函数 newproc1
,在 newproc1
中可以使用分段栈。真正的工作是调用 newproc1
完成的。newproc1 进行下面这些动作:
- 首先,
newproc1
会检查当前结构体 M 中的 P 中,是否有可用的结构体 G。如果有,则直接从中取一个,否则,需要分配一个新的结构体 G。如果分配了新的 G,需要将它挂到 runtime 的相关队列中。获取了结构体 G 之后,将调用参数保存到 g 的栈,将 sp,pc 等上下文环境保存在 g 的 sched 域,这样整个 goroutine 就准备好了,整个状态和一个运行中的 goroutine 被中断时一样,只要等分配到 CPU,它就可以继续运行。
newg->sched.sp = (uintptr)sp;
newg->sched.pc = (byte*)runtime·goexit;
newg->sched.g = newg;
runtime·gostartcallfn(&newg->sched, fn);
newg->gopc = (uintptr)callerpc;
newg->status = Grunnable;
newg->goid = runtime·xadd64(&runtime·sched.goidgen, 1);
然后将这个“准备好”的结构体 G 挂到当前 M 的 P 的队列中。这里会给予新的 goroutine 一次运行的机会,即:如果当前的 P 的数目没有到上限,也没有正在自旋抢 CPU 的 M,则调用 wakep 将 P 立即投入运行。wakep 函数唤醒 P 时,调度器会试着寻找一个可用的 M 来绑定 P,必要的时候会新建 M。让我们看看新建M的函 runtime.newm
功能跟 newproc
相似,前者分配一个 goroutine
,而后者分配一个 M。其实一个 M 就是一个操作系统线程的抽象,可以看到它会调用 runtime.newosproc
。runtime.newosproc
(平台相关的)会调用系统的 runtime.clone
(平台相关的)来新建一个线程,新的线程将以 runtime.mstart
为入口函数。
接着看一下 runtime.mstart
函数,它是 runtime.newosproc
新建的系统线程的入口地址,新线程执行时会从这里开始运行。新线程的执行和 goroutine 的执行是两个概念,由于有 M 这一层对机器的抽象,是 M 在执行 G 而不是线程在执行 G。所以线程的入口是 runtime.mstart
,G 的执行要到 schedule 才算入口。函数runtime.mstart
最后调用了schedule。
最后从 runtime.mstart
进入到 schedule 的,那么 schedule 中逻辑非常简单,大概就这几步:
- 找到一个等待运行的 G
- 如果 G 是锁定到某个 M 的,则让那个 M 运行
- 否则调用
runtime.execute
函数让 G 在当前的 M 中运行
综上所述,goroutine 创建的大致流程: newproc -> newproc1 -> (如果P数目没到上限)wakep -> startm -> (可能引发)newm -> newosproc -> (线程入口)mstart -> schedule -> execute -> goroutine运行
goroutine 阻塞
假设 goroutine 暂时无法分配到资源被调度,它要进入系统调用了,暂时无法继续执行。进入系统调用时,如果系统调用是阻塞的,goroutine 会被剥夺 CPU,将状态设置成 Gsyscall 后放到就绪队列。Go 的 syscall 库中提供了对系统调用的封装,它会在真正执行系统调用之前先调用函数 .entersyscall
,并在系统调用函数返回后调用 .exitsyscall
函数。这两个函数就是通知 Go 的运行时库这个 goroutine 进入了系统调用或者完成了系统调用,调度器会做相应的调度。
比如 syscall 包中的 Open 函数,它会调用 Syscall(SYS_OPEN, uintptr(unsafe.Pointer(_p0)), uintptr(mode), uintptr(perm))
实现。这个函数是用汇编写的,在syscall/asm_linux_amd64.s中可以看到它的定义:
// func Syscall(trap int64, a1, a2, a3 uintptr) (r1, r2, err uintptr);
// Trap # in AX, args in DI SI DX R10 R8 R9, return in AX DX
// Note that this differs from "standard" ABI convention, which
// would pass 4th arg in CX, not R10.
TEXT ·Syscall(SB),NOSPLIT,$0-56
CALL runtime·entersyscall(SB)
MOVQ a1+8(FP), DI
MOVQ a2+16(FP), SI
MOVQ a3+24(FP), DX
MOVQ trap+0(FP), AX // syscall entry
SYSCALL
CMPQ AX, $0xfffffffffffff001
JLS ok
MOVQ $-1, r1+32(FP)
MOVQ $0, r2+40(FP)
NEGQ AX
MOVQ AX, err+48(FP)
CALL runtime·exitsyscall(SB)
RET
ok:
MOVQ AX, r1+32(FP)
MOVQ DX, r2+40(FP)
MOVQ $0, err+48(FP)
CALL runtime·exitsyscall(SB)
RET
可以看到它进系统调用和出系统调用时分别调用了 runtime.entersyscall
和 runtime.exitsyscall
函数。
entersyscall
:
- 首先,将函数的调用者的 SP,PC 等保存到结构体 G 的 sched 域中。同时也保存到 g->gcsp 和 g->gcpc 等,这个是跟垃圾回收相关的。
- 然后检查结构体 Sched 中的 sysmonwait 域,如果不为0,则将它置为0,并调用
runtime·notewakeup(&runtime·sched.sysmonnote)
。做这这一步的原因是,目前这个 goroutine 要进入 Gsyscall 状态了,它将要让出 CPU。如果有人在等待 CPU 的话,会通知并唤醒等待者,马上就有 CPU 可用了。 - 接下来,将 m 的 MCache 置为空,并将 m->p->m 置为空,表示进入系统调用后结构体 M 是不需要 MCache 的,并且 P 也被剥离了,将 P 的状态设置为 PSyscall。
runtime·exitsyscall
:
- 首先检查当前 m 的 P 和它状态,如果 P 不空且状态为 Psyscall,则说明是从一个非阻塞的系统调用中返回的,这时是仍然有 CPU 可用的。因此将 p->m 设置为当前 m,将 p 的 mcache 放回到 m,恢复 g 的状态为 Grunning。否则,它是从一个阻塞的系统调用中返回的,因此之前 m 的 P 已经完全被剥离了。这时会查看调用中是否还有 idle 的 P,如果有,则将它与当前的 M 绑定。
- 如果从一个阻塞的系统调用中出来,并且出来的这一时刻又没有 idle 的 P 了,这种情况代码当前的 goroutine 无法继续运行了,调度器会将它的状态设置为 Grunnable,将它挂到全局的就绪 G 队列中,然后停止当前 m 并调用schedule 函数。
goroutine 死亡
goroutine 死亡比较简单,注意在函数 runtime.newproc1
,设置了 fnstart
为 goroutine 执行的函数,而将新建的goroutine 的 sched 域的 pc 设置为了函数 runtime.goexit
。当 fnstart
函数执行完返回时,它会返回到 runtime.goexit
中。这时 Go 就知道这个 goroutine 要结束了,runtime.goexit
中会做一些回收工作,会将 g 的状态设置为 Gdead 等,并将 g 挂到 P 的 free 队列中。
另外还有一个 main goroutine 死亡退出,可以参考下面的代码分析:
// The main goroutine.
func main() {
// g = main goroutine,不再是 g0 了
g := getg()
// ……………………
if sys.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}
// Allow newproc to start new Ms.
mainStarted = true
systemstack(func() {
// 创建监控线程,该线程独立于调度器,不需要跟 p 关联即可运行
newm(sysmon, nil)
})
lockOSThread()
if g.m != &m0 {
throw("runtime.main not on m0")
}
// 调用 runtime 包的初始化函数,由编译器实现
runtime_init() // must be before defer
if nanotime() == 0 {
throw("nanotime returning zero")
}
// Defer unlock so that runtime.Goexit during init does the unlock too.
needUnlock := true
defer func() {
if needUnlock {
unlockOSThread()
}
}()
// Record when the world started. Must be after runtime_init
// because nanotime on some platforms depends on startNano.
runtimeInitTime = nanotime()
// 开启垃圾回收器
gcenable()
main_init_done = make(chan bool)
// ……………………
// main 包的初始化,递归的调用我们 import 进来的包的初始化函数
fn := main_init
fn()
close(main_init_done)
needUnlock = false
unlockOSThread()
// ……………………
// 调用 main.main 函数
fn = main_main
fn()
if raceenabled {
racefini()
}
// ……………………
// 进入系统调用,退出进程,可以看出 main goroutine 并未返回,而是直接进入系统调用退出进程了
exit(0)
// 保护性代码,如果 exit 意外返回,下面的代码会让该进程 crash 死掉
for {
var x *int32
*x = 0
}
}
P.S.
从以上的分析中,其实已经基本上经历了 goroutine 的各种状态变化。在 newproc1 中新建的 goroutine 被设置为Grunnable 状态,投入运行时设置成 Grunning。在 entersyscall 的时候 goroutine 的状态被设置为 Gsyscall,到出系统调用时根据它是从阻塞系统调用中出来还是非阻塞系统调用中出来,又会被设置成 Grunning 或者 Grunnable 的状态。在 goroutine 最终退出的 runtime.goexit 函数中,goroutine 被设置为 Gdead 状态。
goroutine 状态迁移图: