由生到死看透 goroutine
13 min read

由生到死看透 goroutine

goroutine 生命周期深入分析和理解
由生到死看透 goroutine
Photo by https://github.com/dalmarcogd/gwp

通过一个简单的样例代码来了解 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 进行下面这些动作:

  1. 首先,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.newosprocruntime.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.entersyscallruntime.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 状态迁移图:

References

  1. Delve is a debugger for the Go programming language.
  2. goroutine 调度器 - goroutine 如何退出 - 《Go 语言问题集(Go Questions)》
  3. 非main goroutine的退出及调度循环(15)
  4. Golang源码学习:调度逻辑(二)main goroutine的创建
  5. golang调度学习-调度流程 (二) newproc
  6. go关键字 · 深入解析Go

Public discussion