记一次 Golang runtime 的调试
调试环境
-
go 版本和平台
go version go1.5.4 linux/amd64 -
delve 版本
Delve Debugger
Version: 1.0.0
Build: $Id: c98a142125d0b17bb11ec0513bde346229b5f533 $
问题描述
一个编译好的 golang 程序的真正的入口并不是我们的 main 包中的 main 函数,在我们的 main 函数启动前,golang 的 runtime 会做一系列的工作来为我们准备好运行时环境,这包括运行时 golang 程序层面的内存管理和垃圾回收,最重要的是还帮我们建立了一套 goroutine(协程)的调度机制。 为了了解这个 golang 的底层 runtime 是怎么工作的,在看完一些关于 runtime 的资料后,我打算用调试工具来 debug,追踪一下 runtime 的运行过程,以此来验证一下自己的理解。 可是在刚开始调试的时候,就遇到了麻烦。
golang 的入口代码如下:
runtime/asm_amd64.s
TEXT runtime·rt0_go(SB),NOSPLIT,$0
// copy arguments forward on an even stack
MOVQ DI, AX // argc
MOVQ SI, BX // argv
SUBQ $(4*8+7), SP // 2args 2auto
ANDQ $~15, SP
MOVQ AX, 16(SP)
MOVQ BX, 24(SP)
// 这里去掉一些不影响阅读的代码
LEAQ runtime·tls0(SB), DI
CALL runtime·settls(SB)
// store through it, to make sure it works
get_tls(BX)
MOVQ $0x123, g(BX)
MOVQ runtime·tls0(SB), AX
CMPQ AX, $0x123
JEQ 2(PC)
MOVL AX, 0 // abort
ok:
// set the per-goroutine and per-mach "registers"
get_tls(BX)
LEAQ runtime·g0(SB), CX
MOVQ CX, g(BX)
LEAQ runtime·m0(SB), AX
// save m->g0 = g0
MOVQ CX, m_g0(AX)
// save m0 to g0->m
MOVQ AX, g_m(CX)
CLD // convention is D is always left cleared
CALL runtime·check(SB)
MOVL 16(SP), AX // copy argc
MOVL AX, 0(SP)
MOVQ 24(SP), AX // copy argv
MOVQ AX, 8(SP)
CALL runtime·args(SB)
CALL runtime·osinit(SB)
CALL runtime·schedinit(SB)
// create a new goroutine to start program
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
PUSHQ $0 // arg size
CALL runtime·newproc(SB)
POPQ AX
POPQ AX
// start this M
CALL runtime·mstart(SB)
MOVL $0xf1, 0xf1 // crash
RET
在 runtime 中,定义了两个全局变量
runtime/proc1.go
var (
m0 m
g0 g
)
在 golang runtime GPM 模型初始化运行之前就存在了, 这个 m0 是程序启动后,在 GPM 模型中,代表 runtime.rt0_go 这个主线程的对象。 这个 m0 负责执行初始化操作以及启动第一个 goroutine。 而 g0 的作用主要是给 m0 提供线程栈空间,以及在 cpu 切换线程时,为线程提供标识。
rt0_go 这个汇编函数中有段注释
// save m->g0 = g0
// save m0 to g0->m
就是把这两个对象在开始发挥作用前绑定起来。
在 rt0_go 这个汇编函数的尾部,有这样一段代码
// create a new goroutine to start program
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
PUSHQ $0 // arg size
CALL runtime·newproc(SB)
POPQ AX
POPQ AX
// start this M
CALL runtime·mstart(SB)
源码注释很明白,就是将我们 main 包里的 main 函数放到一个新建的 goroutine 当中以启动程序,后面就是启动 m0 这个线程。 于是我就在这两个 CALL 的函数定义处分别打了断点,准备跟进去走个流程。
[test]$ go build -gcflags “-N -l” test
[test]$ dlv debug test
Type ‘help’ for list of commands.
(dlv) b runtime.newproc
Breakpoint 1 set at 0x4327e0 for runtime.newproc() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:2216 (dlv) b runtime.mstart
Breakpoint 2 set at 0x42e650 for runtime.mstart() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:668
从 runtime.rt0_go 那里调用这个 runtime.newproc 主要是将我们的 main 包中的 main 函数装进一个新的 goroutine 当中,然后放到与当前的 m0 绑定的 P(在之前 runtime.rt0_go 中初始化时的 runtime.schedinit 中绑定的)的 runnable goroutine 队列中,待调度机制调度后开始工作。
runtime/proc1.go
func newproc1(fn *funcval, argp *uint8, narg int32, nret int32, callerpc uintptr) *g {
_g_ := getg()
......
_p_ := _g_.m.p.ptr()
newg := gfget(_p_)
if newg == nil {
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
......
runqput(_p_, newg, true)
......
return newg
}
这里用调试器查看一下g值,和前面提到的 g0 和 m0 这两个全局的变量的地址
(dlv) n
> runtime.newproc.func1() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:2220 (PC: 0x44f5c3)
2215: //go:nosplit
2216: func newproc(siz int32, fn *funcval) {
2217: argp := add(unsafe.Pointer(&fn), ptrSize)
2218: pc := getcallerpc(unsafe.Pointer(&siz))
2219: systemstack(func() {
=>2220: newproc1(fn, (*uint8)(argp), siz, 0, pc)
2221: })
2222: }
(dlv) n
> runtime.newproc1() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:2231 (PC: 0x432875)
2226: // address of the go statement that created this. The new g is put
2227: // on the queue of g's waiting to run.
2228: func newproc1(fn *funcval, argp *uint8, narg int32, nret int32, callerpc uintptr) *g {
2229: _g_ := getg()
2230:
=>2231: if fn == nil {
2232: _g_.m.throwing = -1 // do not dump full stacks
2233: throw("go of nil func value")
2234: }
2235: _g_.m.locks++ // disable preemption because it can be holding p in a local var
2236: siz := narg + nret
(dlv) local
Command failed: command not available
(dlv) locals
siz = 200
sp = 139891379798019
newg = (*runtime.g)(0x4341ec)
_p_ = (*runtime.p)(0x896a40)
_g_ = (*runtime.g)(0x872aa0)
(dlv) p unsafe.Pointer(uintptr(&g0))
unsafe.Pointer(0x872aa0)
(dlv) p unsafe.Pointer(uintptr(&m0))
unsafe.Pointer(0x873040)
这个_g_ 指向 g0, 和预想的一样
这个返回g 的函数是编译器实现的,源代码注释有解释
// getg returns the pointer to the current g.
// The compiler rewrites calls to this function into instructions
// that fetch the g directly (from TLS or from the dedicated register).
func getg() *g
这段 runtime.newproc 的代码到此结束,接下来转到 runtime.mstart,看看线程时怎么启动起来的。
直接 continue 一下到 runtime.mstart 这个断点。
(dlv) c
> runtime.mstart() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:671 (PC: 0x42e65d)
666: // Called to start an M.
667: //go:nosplit
668: func mstart() {
669: _g_ := getg()
670:
=> 671: if _g_.stack.lo == 0 {
672: // Initialize stack bounds from system stack.
673: // Cgo may have left stack size in stack.hi.
674: size := _g_.stack.hi
675: if size == 0 {
676: size = 8192 * stackGuardMultiplier
678: _g_.stack.hi = uintptr(noescape(unsafe.Pointer(&size)))
679: _g_.stack.lo = _g_.stack.hi - size + 1024
680: }
681: // Initialize stack guards so that we can start calling
682: // both Go and C functions with stack growth prologues.
683: _g_.stackguard0 = _g_.stack.lo + _StackGuard
684: _g_.stackguard1 = _g_.stackguard0
685: mstart1()
686: }
(dlv) locals
size = 0
(dlv) p _g_
Command failed: could not find symbol value for _g_
这回g这个变量查不出来,前面说了这个返回g 的函数是编译器实现的。
查了相关资料,这个 TLS,
全称是 Thread-local storage, 代表每个线程的中的本地数据.
例如标准 c 中的 errno 就是一个典型的 TLS 变量, 每个线程都有一个独自的 errno, 写入它不会干扰到其他线程中的值.
go 在实现协程时非常依赖 TLS 机制, 会用于获取系统线程中当前的 G 和 G 所属的 M 的实例.
因为 go 并不使用 glibc, 操作 TLS 会使用系统原生的接口, 以 linux x64 为例, go 在新建 M 时会调用 arch_prctl 这个 syscall 设置 FS 寄存器的值为 M.tls 的地址, 运行中每个 M 的 FS 寄存器都会指向它们对应的 M 实例的 tls, linux 内核调度线程时 FS 寄存器会跟着线程一起切换, 这样 go 代码只需要访问 FS 寄存器就可以存取线程本地的数据.
那就看看汇编代码。
(dlv) disassemble
TEXT runtime.mstart(SB) /root/.gvm/gos/go1.5.4/src/runtime/proc1.go
proc1.go:668 0x42e650* 4883ec08 sub rsp, 0x8
proc1.go:669 0x42e654 64488b0425f8ffffff mov rax, qword ptr fs:[0xfffffff8]
proc1.go:671 0x42e65d 488b18 mov rbx, qword ptr [rax]
proc1.go:671 0x42e660 4883fb00 cmp rbx, 0x0
proc1.go:671 0x42e664 753b jnz 0x42e6a1
=> proc1.go:674 0x42e666 488b6808 mov rbp, qword ptr [rax+0x8]
FS 寄存器看样子是放到 rax 寄存器里面了,我们验证一下,看看 rax 的值是不是和 g0 的地址一样。
(dlv) regs
Rip = 0x000000000042e65d
Rsp = 0x00007ffd936859d0
Rax = 0x0000000000872aa0
Rbx = 0x000000c820000180
Rcx = 0x000000000077e320
Rdx = 0x0000000000872aa0
Rdi = 0x000000000042b6e0
Rsi = 0x00007ffd936859e0
Rbp = 0x0000000000873040
R8 = 0x000000000077e318
R9 = 0x0000000000000008
R10 = 0x0000000000629e00
R11 = 0x0000000000000000
R12 = 0x0000000000000008
R13 = 0x000000000077b770
R14 = 0x0000000000000000
R15 = 0x0000000000000008
Orig_rax = 0xffffffffffffffff
Cs = 0x0000000000000033
Eflags = 0x0000000000000202 [IF IOPL=0]
Ss = 0x000000000000002b
Fs_base = 0x00007fbf429ae740
Gs_base = 0x0000000000000000
Ds = 0x0000000000000000
Es = 0x0000000000000000
Fs = 0x0000000000000000
Gs = 0x0000000000000000
(dlv) p unsafe.Pointer(uintptr(&g0))
unsafe.Pointer(0x872aa0)
都是 0x872aa0,没问题。继续走流程。
(dlv) n
> runtime.mstart() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:685 (PC: 0x42e6bd)
680: }
681: // Initialize stack guards so that we can start calling
682: // both Go and C functions with stack growth prologues.
683: _g_.stackguard0 = _g_.stack.lo + _StackGuard
684: _g_.stackguard1 = _g_.stackguard0
=> 685: mstart1()
686: }
调试器又回到了 mstart 函数开始处,并没有跳进 mstart1 函数,并且多显示了两行调试信息,和hits total:X
的额外信息。
(dlv) n
> runtime.mstart1() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:688 (hits total:1) (PC: 0x42e6e3)
breakpoint hit during next, continuing...
> runtime.mstart() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:668 (hits total:2) (PC: 0x42e650)
663: }
664: }
665:
666: // Called to start an M.
667: //go:nosplit
=> 668: func mstart() {
669: _g_ := getg()
670:
671: if _g_.stack.lo == 0 {
672: // Initialize stack bounds from system stack.
673: // Cgo may have left stack size in stack.hi.
发生了什么事情,为什么又跳回去了?
看一下寄存器
(dlv) regs
Rip = 0x000000000042e65d
Rsp = 0x00007f23e3133eb8
Rax = 0x000000c820000900
Rbx = 0x000000000042e650
Rcx = 0x00000000017f6000
Rdx = 0x00000000ffffffff
Rdi = 0x000000000042e650
Rsi = 0x00007f23e34fb768
Rbp = 0x000000c820000900
R8 = 0x0000000000000000
R9 = 0x00000000017f6010
R10 = 0x00007f23e3133960
R11 = 0x00007f23e31ba580
R12 = 0x0000000000000000
R13 = 0x0000000000801000
R14 = 0x0000000000000000
R15 = 0x00007f23e3134700
Orig_rax = 0xffffffffffffffff
Cs = 0x0000000000000033
Eflags = 0x0000000000000216 [PF AF IF IOPL=0]
Ss = 0x000000000000002b
Fs_base = 0x00007f23e3134700
Gs_base = 0x0000000000000000
Ds = 0x0000000000000000
Es = 0x0000000000000000
Fs = 0x0000000000000000
Gs = 0x0000000000000000
(dlv) p unsafe.Pointer(uintptr(&g0))
unsafe.Pointer(0x872aa0)
Rax = 0x000000c820000900, 也就是说_g_的值和 g0 的地址不相等。
调试信息说 runtime.mstart()被 hit 了两次,发生了什么?
我们再复现一下这个场景。
(dlv) r \\重启调试过程
(dlv) c \\到达第一个断点runtime.newproc()
(dlv) c \\到达第二个断点runtime.mstart()
(dlv) n \\几次next命令之后,在即将进入mstart1函数之前停下
> runtime.mstart() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:685 (PC: 0x42e6bd)
680: }
681: // Initialize stack guards so that we can start calling
682: // both Go and C functions with stack growth prologues.
683: _g_.stackguard0 = _g_.stack.lo + _StackGuard
684: _g_.stackguard1 = _g_.stackguard0
=> 685: mstart1()
686: }
687:
688: func mstart1() {
689: _g_ := getg()
690:
(dlv) goroutines
[1 goroutines]
Goroutine 1 - User: /root/.gvm/gos/go1.5.4/src/runtime/proc.go:28 runtime.main (0x42b6e0)
(dlv) threads
* Thread 22392 at 0x42e6bd /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:685 runtime.mstart
(dlv)
我们用 golang 专属调试器 delve 提供针对 goroutine 的调试命令来查看一下。 显示有一个编号为 1 的 goroutine 和一个编号为 22392 的 Thread。 继续,
(dlv) n
> runtime.mstart1() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:688 (hits total:1) (PC: 0x42e6e3)
breakpoint hit during next, continuing...
> runtime.newproc() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:2216 (hits goroutine(1):1 total:2) (PC: 0x4327e0)
> runtime.mstart() /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:668 (hits total:2) (PC: 0x42e650)
663: }
664: }
665:
666: // Called to start an M.
667: //go:nosplit
=> 668: func mstart() {
669: _g_ := getg()
670:
671: if _g_.stack.lo == 0 {
672: // Initialize stack bounds from system stack.
673: // Cgo may have left stack size in stack.hi.
(dlv) goroutines
[2 goroutines]
Goroutine 1 - User: /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:2216 runtime.newproc (0x4327e0) (thread 22392)
Goroutine 17 - User: /root/.gvm/gos/go1.5.4/src/runtime/asm_amd64.s:1722 runtime.goexit (0x45b1a1)
(dlv) threads
Thread 22392 at 0x4327e0 /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:2216 runtime.newproc
* Thread 23645 at 0x42e650 /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:668 runtime.mstart
(dlv)
原来出现了线程切换,调试信息显示第二次跳回 mstart 函数的时候,因为线程切换,调试器直接 next 到了新线程里面最先出现的断点,也就是 runtime.mstart 函数处。这也是为什么_g的值和 g0 的地址不相等, 因为_g是从 TLS 取的值,而底层操作系统做线程调度切换的时候,会将 TLS 更新。
但是为什么会跳到 runtime.mstart 函数这里呢?为什么总是在 runtime.mstart1()这个地方做线程切换,而不是其他地方呢?
我们查一下线程栈
(dlv) goroutine 17 stack -full
0 0x000000000045b1a1 in runtime.goexit
at /root/.gvm/gos/go1.5.4/src/runtime/asm_amd64.s:1722
(dlv) threads
Thread 26556 at 0x4327e0 /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:2216 runtime.newproc
* Thread 26616 at 0x42e650 /root/.gvm/gos/go1.5.4/src/runtime/proc1.go:668 runtime.mstart
(dlv)
(未完待续)