序言
最近一位非常热心的网友建议结合 demo 来分析一下 goroutine 的调度器, 而且还提供了一个 demo 代码, 于是便有了本文, 在此对这位网友表示衷心的感谢!
这位网友提供的 demo 程序可能有的 gopher 以前见过, 已经知道了具体原因, 但本文假定我们是第一次遇到这种问题, 然后从零开始, 通过一步一步的分析和定位, 最终找到问题的根源及解决方案.
虽然本文不需要太多的背景知识, 但最好使用过 gdb 或 delve 调试工具, 了解汇编语言及函数调用栈当然就更好了.
本文我们需要重点了解下面这 3 个内容.
调试工具无法准确显示函数调用栈时如何找到函数调用链;
发生 GC 时, 如何 STOP THE WORLD;
什么时候抢占调度不会起作用以及如何规避.
本文的实验环境为 AMD64 Linux + go1.12
Demo 程序及运行现象
- package main
- import(
- "fmt"
- "runtime"
- "time"
- )
- func deadloop() {
- for {
- }
- }
- func worker() {
- for {
- fmt.Println("worker is running")
- time.Sleep(time.Second * 1)
- }
- }
- func main() {
- fmt.Printf("There are %d cores.\n", runtime.NumCPU())
- goworker()
- godeadloop()
- i := 3
- for {
- fmt.Printf("main is running, i=%d\n", i)
- i--
- if i == 0 {
- runtime.GC()
- }
- time.Sleep(time.Second * 1)
- }
- }
编译并运行, 结果:
- bobo@Ubuntu:~/study/go$ ./deadlock
- There are 4cores.
- main is running, i=3
- worker is running
- main is running, i=2
- worker is running
- worker is running
- main is running, i=1
- worker is running
程序运行起来打印了这几条信息之后就再也没有输出任何信息, 看起来程序好像卡死了!
我们第一次遇到这种问题, 该如何着手开始分析呢?
分析代码
首先来分析一下代码, 这个程序启动之后将会在 main 函数中创建一个 worker goroutine 和一个 deadloop goroutine, 加上 main goroutine, 一共应该有 3 个用户 goroutine, 其中
dealloop goroutine 一直在执行一个死循环, 并未做任何实际的工作;
worker goroutine 每隔一秒循环打印 worker is running;
main goroutine 也一直在执行着一个循环, 每隔一秒打印一下 main is running, 同时输出变量 i 的值并对 i 执行减减操作, 当 i 等于 0 的时候会去调用 runtime.GC 函数触发垃圾回收.
因为我们目前掌握的知识有限, 所以暂时看不出有啥问题, 看起来一切都应该很正常才对, 为什么会卡死呢?
分析日志
看不出程序有什么问题, 我们就只能再来仔细看一下输出的日志信息. 从日志信息可以看出, 一开始 main goroutine 和 worker 还很正常, 但当打印了 i = 1 之后, main goroutine 就再也没有输出信息了, 而这之后 worker 也只打印了一次就没有再打印信息了.
从代码可以知道, 打印了 i = 1 之后 i 就自减了 1 变成了 0,i 等于 0 之后就会去执行 runtime.GC(), 所以我们有理由怀疑卡死跟 GC 垃圾回收有关, 怀疑归怀疑, 我们需要拿出证据来证明它们确实有关才行. 怎么找证据呢?
跟踪函数调用链
因为程序并没有退出, 而是卡起了, 我们会很自然的想到通过调试工具来看一下到底发生了什么事情. 这里我们使用 delve 这个专门为 Go 程序定制的调试器.
使用 pidof 命令找到 deadlock 的进程 ID, 然后使用 dlv attach 上去, 并通过 goroutines 命令查看程序中的 goroutine
- bobo@Ubuntu:~/study/go$ pidofdeadlock
- 2369
- bobo@Ubuntu:~/study/go$ sudodlv attach 2369
- Type 'help'forlist of commands.
- (dlv) goroutines
- Goroutine 1-User: /usr/local/go/src/runtime/mgc.go:1055 runtime.GC (0x416ab8)
- Goroutine 2-User: /usr/local/go/src/runtime/proc.go:302 runtime.gopark (0x429b2f)
- Goroutine 3-User: /usr/local/go/src/runtime/proc.go:302 runtime.gopark (0x429b2f)
- Goroutine 4-User: /usr/local/go/src/runtime/proc.go:302 runtime.gopark (0x429b2f)
- Goroutine 5-User: /usr/local/go/src/runtime/proc.go:307 time.Sleep (0x442a09)
- Goroutine 6-User: ./deadlock.go:10 main.deadloop (0x488f90) (thread 2372)
- Goroutine 7-User: /usr/local/go/src/runtime/proc.go:302 runtime.gopark (0x429b2f)
- Goroutine 17-User: /usr/local/go/src/runtime/proc.go:3005 runtime.exitsyscall (0x4307e6)
- Goroutine 33-User: /usr/local/go/src/runtime/proc.go:302 runtime.gopark (0x429b2f)
- Goroutine 34-User: /usr/local/go/src/runtime/proc.go:302 runtime.gopark (0x429b2f)
- Goroutine 35-User: /usr/local/go/src/runtime/proc.go:302 runtime.gopark (0x429b2f)
- Goroutine 36-User: /usr/local/go/src/runtime/proc.go:302 runtime.gopark (0x429b2f)
- Goroutine 37-User: /usr/local/go/src/runtime/proc.go:302 runtime.gopark (0x429b2f)
- Goroutine 49-User: /usr/local/go/src/runtime/proc.go:302 runtime.gopark (0x429b2f)
- [14 goroutines]
- (dlv)
从输出信息可以看到程序中一共有 14 个 goroutine, 其它的 goroutine 不用管, 我们只关心那 3 个用户 goroutine, 容易看出它们分别是
- Goroutine 1-User: /usr/local/go/src/runtime/mgc.go:1055 runtime.GC (0x416ab8) #main goroutine
- Goroutine 5-User: /usr/local/go/src/runtime/proc.go:307 time.Sleep (0x442a09) #worker goroutine
- Goroutine 6-User: ./deadlock.go:10 main.deadloop (0x488f90) (thread 2372) #deadloop goroutine
因为我们怀疑卡死跟 runtime.GC()函数调用有关, 所以我们切换到 Goroutine 1 并使用 backtrace 命令 (简写 bt) 查看一下 main goroutine 的函数调用栈:
- (dlv) goroutine 1
- Switched from 0to 1(thread 2371)
- (dlv) bt
- 0 0x0000000000453383 inruntime.futex at /usr/local/go/src/runtime/sys_linux_amd64.s:536
- 1 0x000000000044f5d0 inruntime.systemstack_switch at /usr/local/go/src/runtime/asm_amd64.s:311
- 2 0x0000000000416eb9 inruntime.gcStart at /usr/local/go/src/runtime/mgc.go:1284
- 3 0x0000000000416ab8 inruntime.GC at /usr/local/go/src/runtime/mgc.go:1055
- 4 0x00000000004891a6 inmain.main at ./deadlock.go:39
- 5 0x000000000042974c inruntime.main at /usr/local/go/src/runtime/proc.go:200
- 6 0x0000000000451521 inruntime.goexit at /usr/local/go/src/runtime/asm_amd64.s:1337
- (dlv)
从输出可以看到 main goroutine 的函数调用链为:
main()->runtime.GC()->runtime.gcStart()->runtime.systemstack_switch()->runtime.futex
我们从 main 函数开始顺着这个链去看一下源代码, 会发现 mgc.go 的 1284 行代码并非 systemstack_switch 函数, 而是 systemstack(stopTheWorldWithSema)这一句代码, 在这里, 这句代码的意思是从 main goroutine 的栈切换到 g0 栈并执行 stopTheWorldWithSema 函数, 但从上面的函数调用栈并未看到 stopTheWorldWithSema 函数的身影, 这可能是因为从 main goroutine 的栈切换到了 g0 栈导致调试工具没有处理好? 不管怎么样, 我们需要找到从 stopTheWorldWithSema 函数到 runtime.futex 函数的调用路径才能搞清楚到底发生了什么事情.
手动追踪函数调用链
既然调试工具显示的函数调用路径有问题, 我们就需要手动来找到它, 首先反汇编看一下当前正要运行的指令:
- (dlv) disass
- TEXT runtime.futex(SB) /usr/local/go/src/runtime/sys_linux_amd64.s
- mov rdi, qword ptr [rsp+0x8]
- mov esi, dword ptr [rsp+0x10]
- mov edx, dword ptr [rsp+0x14]
- mov r10, qword ptr [rsp+0x18]
- mov r8, qword ptr [rsp+0x20]
- mov r9d, dword ptr [rsp+0x28]
- mov eax, 0xca
- syscall
- => mov dword ptr [rsp+0x30], eax
- ret
反汇编结果告诉我们, 下一条即将执行的指令是 sys_linux_amd64.s 文件中的 futex 函数的倒数第二条指令:
==> mov dword ptr [rsp+0x30], eax
为了搞清楚谁调用了 futex 函数, 我们需要让 futex 执行完并返回到调用它的函数中去, 多次使用 si 单步执行命令, 程序返回到了 runtime.futexsleep 函数, 如下:
- (dlv) si
- > runtime.futex() /usr/local/go/src/runtime/sys_linux_amd64.s:536
- MOVQ ts+16(FP), R10
- MOVQ addr2+24(FP), R8
- MOVL val3+32(FP), R9
- MOVL $SYS_futex, AX
- SYSCALL
- => MOVL AX, ret+40(FP)
- RET
- (dlv) si
- > runtime.futex() /usr/local/go/src/runtime/sys_linux_amd64.s:537
- MOVQ addr2+24(FP), R8
- MOVL val3+32(FP), R9
- MOVL $SYS_futex, AX
- SYSCALL
- MOVL AX, ret+40(FP)
- => RET
- (dlv) si
- > runtime.futexsleep() /usr/local/go/src/runtime/os_linux.go:64
- }else {
- ts.tv_nsec =0
- ts.set_sec(int64(timediv(ns, 1000000000, (*int32)(unsafe.Pointer(&ts.tv_nsec)))))
- }
- futex(unsafe.Pointer(addr), _FUTEX_WAIT_PRIVATE, val, unsafe.Pointer(&ts), nil, 0)
- => }
- // If any procs are sleeping on addr, wake up at most cnt.
- //go:nosplit
- funcfutexwakeup(addr *uint32, cnt uint32) {
- ret:=futex(unsafe.Pointer(addr), _FUTEX_WAKE_PRIVATE, cnt, nil, nil, 0)
- (dlv)
现在程序停在了 os_linux.go 的 64 行 (=> 这个符号表示程序当前停在这里), 这是 futexsleep 函数的最后一行, 使用 n 命令单步执行一行 go 代码, 从 runteme.futexsleep 函数返回到了 runtime.notetsleep_internal 函数:
- (dlv) n
- >runtime.notetsleep_internal() /usr/local/go/src/runtime/lock_futex.go:194
- if *cgo_yield != nil && ns> 10e6 {
- ns = 10e6
- }
- gp.m.blocked = true
- futexsleep(key32(&n.key), 0, ns)
- => if *cgo_yield != nil {
- asmcgocall(*cgo_yield, nil)
- }
- gp.m.blocked = false
- if atomic.Load(key32(&n.key)) != 0 {
- break
在 runtime.notetsleep_internal 函数中再连续使用几次 n 命令, 函数从 runtime.notetsleep_internal 返回到了 runtime.notetsleep 函数:
- (dlv) n
- >runtime.notetsleep() /usr/local/go/src/runtime/lock_futex.go:210
- =>func notetsleep(n *note, ns int64) bool{
- gp := getg()
- if gp != gp.m.g0&&gp.m.preemptoff != "" {
- throw("notetsleep not on g0")
- }
- return notetsleep_internal(n, ns)
- }
为了搞清楚谁调用了 notetsleep 函数, 继续执行几次 n, 奇怪的事情发生了, 居然无法从 notetsleep 函数返回到调用它的函数中去, 一直在 notetsleep 这个函数打转, 好像发生了递归调用一样, 见下:
- (dlv) n
- >runtime.notetsleep() /usr/local/go/src/runtime/lock_futex.go:211
- func notetsleep(n *note, ns int64) bool {
- => gp := getg()
- if gp!= gp.m.g0 && gp.m.preemptoff != "" {
- throw("notetsleep not on g0")
- }
- return notetsleep_internal(n, ns)
- }
- (dlv) n
- >runtime.notetsleep() /usr/local/go/src/runtime/lock_futex.go:216
- func notetsleep(n *note, ns int64) bool {
- gp := getg()
- if gp != gp.m.g0 && gp.m.preemptoff != "" {
- throw("notetsleep not on g0")
- }
- => return notetsleep_internal(n, ns)
- }
- (dlv) n
- >runtime.notetsleep() /usr/local/go/src/runtime/lock_futex.go:210
- => func notetsleep(n *note, ns int64) bool {
- gp := getg()
- if gp != gp.m.g0 && gp.m.preemptoff != "" {
- throw("notetsleep not on g0")
- }
- return notetsleep_internal(n, ns)
- }
notetsleep 函数只有简单的几行代码, 并没有递归调用, 这真有点诡异, 看来这个调试器还真有点问题. 我们反汇编来看一下:
- (dlv) disass
- TEXT runtime.notetsleep(SB) /usr/local/go/src/runtime/lock_futex.go
- => mov rcx, qword ptr fs:[0xfffffff8]
- cmp rsp, qword ptr [rcx+0x10]
- jbe 0x4095df
- sub rsp, 0x20
- mov qwordptr[rsp+0x18], rbp
- lea rbp, ptr [rsp+0x18]
- mov rax, qword ptr fs:[0xfffffff8]
- ......
现在程序停在 notetsleep 函数的第一条指令. 我们知道, 只要发生了函数调用, 这个时候 CPU 的 rsp 寄存器一定指向这个函数执行完成之后的返回地址, 所以我们看一下 rsp 寄存器的值
- (dlv) regs
- Rip=0x0000000000409560
- Rsp=0x000000c000045f60
- ......
得到 rsp 寄存器的值之后我们来看一下它所指的内存单元中存放的是什么:
- (dlv) p *(*int)(0x000000c000045f60)
- 4374697
如果这个 4374697 是返回地址, 那一定可以在这个地方下一个执行断点, 试一试看:
- (dlv) b *4374697
- Breakpoint 1 set at 0x42c0a9 for runtime.stopTheWorldWithSema() /usr/local/go/src/runtime/proc.go:1050
真是苍天不负有心人, 终于找到了 stopTheWorldWithSema()函数, 断点告诉我们 runtime/proc.go 文件的 1050 行调用了 notetsleep 函数, 我们打开源代码可以看到这个地方确实是在一个循环中调用 notetsleep 函数.
到此, 我们得到了 main goroutine 完整的函数调用路径:
main()->runtime.GC()->runtime.gcStart()->runtime.stopTheWorldWithSema()->runtime.notetsleep_internal()->runtime.futexsleep()->runtime.futex()
分析 stopTheWorldWithSema 函数
接着, 我们来仔细的看一下 stopTheWorldWithSema 函数为什么会调用 notetsleep 函数进入睡眠:
- // stopTheWorldWithSema is the core implementation of stopTheWorld.
- // The caller is responsible for acquiring worldsema and disabling
- // preemption first and then should stopTheWorldWithSema on the system
- // stack:
- //
- //semacquire(&worldsema, 0)
- //m.preemptoff = "reason"
- //systemstack(stopTheWorldWithSema)
- //
- // When finished, the caller must either call startTheWorld or undo
- // these three operations separately:
- //
- //m.preemptoff = ""
- //systemstack(startTheWorldWithSema)
- //semrelease(&worldsema)
- //
- // It is allowed to acquire worldsema once and then execute multiple
- // startTheWorldWithSema/stopTheWorldWithSema pairs.
- // Other P's are able to execute between successive calls to
- // startTheWorldWithSema and stopTheWorldWithSema.
- // Holding worldsema causes any other goroutines invoking
- // stopTheWorld to block.
- func stopTheWorldWithSema() {
- _g_ := getg() // 因为在 g0 栈运行, 所以_g_ = g0
- ......
- lock(&sched.lock)
- sched.stopwait = gomaxprocs // gomaxprocs 即 p 的数量, 需要等待所有的 p 停下来
- atomic.Store(&sched.gcwaiting, 1) // 设置 gcwaiting 标志, 表示我们正在等待着垃圾回收
- preemptall() // 设置抢占标记, 希望处于运行之中的 goroutine 停下来
- // stop current P, 暂停当前 P
- _g_.m.p.ptr().status = _Pgcstop // Pgcstop is only diagnostic.
- sched.stopwait--
- // try to retake all P's in Psyscall status
- for _, p := range allp {
- s := p.status
- // 通过修改 p 的状态为_Pgcstop 抢占那些处于系统调用之中的 goroutine
- if s == _Psyscall && atomic.Cas(&p.status, s, _Pgcstop) {
- if trace.enabled {
- traceGoSysBlock(p)
- traceProcStop(p)
- }
- p.syscalltick++
- sched.stopwait--
- }
- }
- // stop idle P's
- for { // 修改 idle 队列中 p 的状态为_Pgcstop, 这样就不会被工作线程拿去使用了
- p := pidleget()
- if p == nil {
- break
- }
- p.status = _Pgcstop
- sched.stopwait--
- }
- wait := sched.stopwait> 0
- unlock(&sched.lock)
- // wait for remaining P's to stop voluntarily
- if wait {
- for {
- // wait for 100us, then try to re-preempt in case of any races
- if notetsleep(&sched.stopnote, 100*1000) { // 我们这个场景程序卡在了这里
- noteclear(&sched.stopnote)
- break
- }
- preemptall() // 循环中反复设置抢占标记
- }
- }
- ......
- }
stopTheWorldWithSema 函数流程比较清晰:
通过 preemptall() 函数对那些正在运行 go 代码的 goroutine 设置抢占标记;
停掉当前工作线程所绑定的 p;
通过 cas 操作修改那些处于系统调用之中的 p 的状态为_Pgcstop 从而停掉对应的 p;
修改 idle 队列中 p 的状态为_Pgcstop;
等待处于运行之中的 p 停下来.
从这个流程可以看出, stopTheWorldWithSema 函数主要通过两种方式来 Stop The World:
对于那些此时此刻并未运行 go 代码的 p, 包括位于空闲队列之中的 p 以及处于系统调用之中的 p, 通过直接设置其状态为_Pgcstop 来阻止工作线程绑定它们, 从而保持内存引用的一致性. 因为工作线程要执行 go 代码就必须要绑定 p, 没有 p 工作线程就无法运行 go 代码, 不运行 go 代码也就无法修改内存之间的引用关系;
对于那些此时此刻绑定到某个工作线程正在运行 go 代码的 p, 不能简单的修改其状态, 只能通过设置抢占标记来请求它们停下来;
从前面的分析我们已经知道, deadlock 程序卡在了下面这个 for 循环之中:
- for {
- // wait for 100us, then try to re-preempt in case of any races
- if notetsleep(&sched.stopnote, 100 * 1000) { // 我们这个场景程序卡在了这里
- noteclear(&sched.stopnote)
- break
- }
- preemptall() // 循环中反复设置抢占标记
- }
程序一直在执行上面这个 for 循环, 在这个循环之中, 代码通过反复调用 preemptall()来对那些正在运行的 goroutine 设置抢占标记然后通过 notetsleep 函数来等待这些 goroutine 的暂停. 从程序的运行现象及我们的分析来看, 应该是有 goroutine 没有暂停下来导致了这里的 for 循环无法 break 出去.
寻找没有暂停下来的 goroutine
再次看一下我们的 3 个用户 goroutine:
- Goroutine 1-User: /usr/local/go/src/runtime/mgc.go:1055 runtime.GC (0x416ab8) #main goroutine
- Goroutine 5-User: /usr/local/go/src/runtime/proc.go:307 time.Sleep (0x442a09) #worker goroutine
- Goroutine 6-User: ./deadlock.go:10 main.deadloop (0x488f90) (thread 2372) #deadloop goroutine
Goroutine 1 所在的工作线程正在执行上面的 for 循环, 所以不可能是它没有停下来, 再来看 Goroutine 5:
- (dlv) goroutine 5
- Switched from 0to 5(thread 2765)
- (dlv) bt
- 0 0x0000000000429b2f inruntime.gopark at /usr/local/go/src/runtime/proc.go:302
- 1 0x0000000000442a09 inruntime.goparkunlock at /usr/local/go/src/runtime/proc.go:307
- 2 0x0000000000442a09 intime.Sleep at /usr/local/go/src/runtime/time.go:105
- 3 0x0000000000489023 inmain.worker at ./deadlock.go:19
- 4 0x0000000000451521 inruntime.goexit at /usr/local/go/src/runtime/asm_amd64.s:1337
从函数调用栈可以看出来 goroutine 5 已经停在了 gopark 处, 所以应该是 goroutine 6 没有停下来, 我们切换到 goroutine 6 看看它的函数调用栈以及正在执行的指令:
- (dlv) goroutine6
- Switchedfrom5to6(thread2768)
- (dlv) bt
- 0 0x0000000000488f90inmain.deadloop at./deadlock.go:10
- 1 0x0000000000451521inruntime.goexit at/usr/local/go/src/runtime/asm_amd64.s:1337
- (dlv) disass
- TEXT main.deadloop(SB) /home/bobo/study/go/deadlock.go
- =>deadlock.go:10 0x488f90ebfe jmp $main.deadloop
- (dlv)
可以看出来 goroutine 一直在这里执行 jmp 指令跳转到自己所在的位置. 为了搞清楚它为什么停不下来, 我们需要看一下 preemptall() 函数到底是怎么请求 goroutine 暂停的.
- // Tell all goroutines that they have been preempted and they should stop.
- // This function is purely best-effort. It can fail to inform a goroutine if a
- // processor just started running it.
- // No locks need to be held.
- // Returns true if preemption request was issued to at least one goroutine.
- func preemptall() bool {
- res := false
- for _, _p_ := rangeallp { // 遍历所有的 p
- if _p_.status != _Prunning {
- continue
- }
- // 只请求处于运行状态的 goroutine 暂停
- if preemptone(_p_) {
- res = true
- }
- }
- return res
- }
继续看 preemptone 函数:
- // Tell the goroutine running on processor P to stop.
- // This function is purely best-effort. It can incorrectly fail to inform the
- // goroutine. It can send inform the wrong goroutine. Even if it informs the
- // correct goroutine, that goroutine might ignore the request if it is
- // simultaneously executing newstack.
- // No lock needs to be held.
- // Returns true if preemption request was issued.
- // The actual preemption will happen at some point in the future
- // and will be indicated by the gp->status no longer being
- // Grunning
- func preemptone(_p_ *p) bool{
- mp := _p_.m.ptr()
- if mp==nil || mp == getg().m {
- return false
- }
- gp := mp.curg // 通过 p 找到正在执行的 goroutine
- if gp == nil || gp == mp.g0 {
- return false
- }
- gp.preempt = true // 设置抢占调度标记
- // Every call in a go routine checks for stack overflow by
- // comparing the current stack pointer to gp->stackguard0.
- // Setting gp->stackguard0 to StackPreempt folds
- // preemption into the normal stack overflow check.
- gp.stackguard0 = stackPreempt // 设置扩栈标记, 这里用来触发被请求 goroutine 执行扩栈函数
- return true
- }
从 preemptone 函数可以看出, 所谓的抢占仅仅是给正在运行的 goroutine 设置一个标志而已, 并没有使用什么有效的手段强制其停下来, 所以被请求的 goroutine 应该需要去检查 preempt 和 stackguard0 这两个标记. 但从上面 deallock 函数的汇编代码看起来它并没有去检查这两个标记, 它只有一条跳转到自身执行死循环的指令, 所以它应该是无法处理暂停请求的, 也就没法停下来, 因而这才导致了上面那个等待它停下来的 for 循环一直无法退出, 最终导致整个程序像是卡死了一样的现象.
到此, 我们已经过找到程序假死的表面原因是, 因为执行 deadlock 函数的 goroutine 没有暂停导致垃圾回收无法进行, 从而导致其它已经暂停了的 goroutine 无法恢复运行. 但为什么其它 goroutine 可以暂停下来呢, 唯独这个 goroutine 不行, 我们需要继续分析.
探索真相
从上面的分析我们得知, preemptone 函数通过设置
- gp.preempt = true
- gp.stackguard0 = stackPreempt //stackPreempt = 0xfffffffffffffade
来请求正在运行的 goroutine 暂停. 为了找到哪里的代码会去检查这些标志, 我们使用文本搜索工具在源代码中查找 "preempt","stackPreempt" 以及 "stackguard0" 这 3 个字符串, 可以找到处理抢占请求的函数为 newstack(), 在该函数中如果发现自己被抢占, 则会暂停当前 goroutine 的执行. 然后再查找哪些函数会调用 newstack 函数, 顺藤摸瓜便可以找到相关的函数调用链为
morestack_noctxt()->morestack()->newstack()
从源代码中 morestack 函数的注释可以知道, 该函数会被编译器插入到函数的序言 (prologue) 或尾声 (epilogue) 之中.
- // Called during function prolog when more stack is needed.
- //
- // The traceback routines see morestack on a g0 as being
- // the top of a stack (for example, morestack calling newstack
- // calling the scheduler calling newm calling gc), so we must
- // record an argument size. For that purpose, it has no arguments.
- TEXT runtime.morestack(SB),NOSPLIT,$0-0
为了验证这个注释, 我们反汇编一下 main 函数看看:
- TEXT main.main(SB) /home/bobo/study/go/deadlock.go
- 0x0000000000489030<+0>: mov %fs:0xfffffffffffffff8,%rcx
- 0x0000000000489039<+9>: cmp 0x10(%rcx),%rsp
- 0x000000000048903d<+13>: jbe 0x4891b0 <main.main+384>
- 0x0000000000489043<+19>: sub $0x80,%rsp
- 0x000000000048904a<+26>: mov %rbp,0x78(%rsp)
- 0x000000000048904f<+31>: lea 0x78(%rsp),%rbp
- ......
- 0x00000000004891a1<+369>: callq 0x416a60 <runtime.GC>
- 0x00000000004891a6<+374>: mov 0x50(%rsp),%rax
- 0x00000000004891ab<+379>: jmpq 0x489108 <main.main+216>
- 0x00000000004891b0<+384>: callq 0x44f730 <runtime.morestack_noctxt>
- 0x00000000004891b5<+389>: jmpq 0x489030 <main.main>
在 main 函数的尾部我们看到了对 runtime.morestack_noctxt 函数的调用, 往前我们可以看到, 对 runtime.morestack_noctxt 的调用是通过 main 函数的第三条 jbe 指令跳转过来的.
- 0x000000000048903d<+13>: jbe 0x4891b0 <main.main+384>
- ......
- 0x00000000004891b0<+384>: callq 0x44f730 <runtime.morestack_noctxt>
jbe 是条件跳转指令, 它依靠上一条指令的执行结果来判断是否需要跳转. 这里的上一条指令是 main 函数的第二条指令, 为了看清楚这里到底在干什么, 我们把 main 函数的前三条指令都列出来:
- 0x0000000000489030<+0>: mov %fs:0xfffffffffffffff8,%rcx #main 函数第一条指令
- 0x0000000000489039<+9>: cmp 0x10(%rcx),%rsp #main 函数第二条指令
- 0x000000000048903d<+13>: jbe 0x4891b0 <main.main+384> #main 函数第三条指令
在我写的 Go 语言调度器源代码情景分析系列文章中曾经介绍过, go 语言使用 fs 寄存器实现系统线程的本地存储(TLS),main 函数的第一条指令就是从 TLS 中读取当前正在运行的 g 的指针并放入 rcx 寄存器, 第二条指令的源操作数是间接寻址, 从内存中读取相对于 g 偏移 16 这个地址中的内容到 rsp 寄存器, 我们来看看 g 偏移 16 的地址是放的什么东西, 首先再来回顾一下 g 结构体的定义:
- type g struct {
- stack stack
- stackguard0 uintptr
- stackguard1 uintptr
- ......
- }
- type stack struct {
- lo uintptr //8 bytes
- hi uintptr //8 bytes
- }
可以看到结构体 g 的第一个成员 stack 占 16 个字节(lo 和 hi 各占 8 字节), 所以 g 结构体变量的起始位置加偏移 16 就应该对应到 stackguard0 字段. 因此 main 函数的第二条指令相当于在比较栈顶寄存器 rsp 的值是否比 stackguard0 的值小, 如果 rsp 的值更小, 说明当前 g 的栈要用完了, 有溢出风险, 需要调用 morestack_noctxt 函数来扩栈, 从前面的分析我们知道, preemptone 函数在设置抢占标志时把需要被抢占的 goroutine 的 stackguard0 成员设置成了 stackPreempt, 而 stackPreempt 是一个很大的整数 0xfffffffffffffade, 对于 goroutine 来说其 rsp 栈顶不可能这么大. 因此任何一个 goroutine 对应的 g 结构体对象的 stackguard0 成员一旦被设置为抢占标记, 在进行函数调用时就会通过由编译器插入的指令去调用 morestack_noctxt 函数.
对于我们这个场景中的 deadlock 函数, 它一直在执行 jmp 指令, 并没有调用其它函数, 所以它没有机会去检查 g 结构体对象的 stackguard0 成员, 也就不会通过调用 morestack_noctxt 函数去执行处理抢占请求的 newstack()函数(在该函数中如果发现自己被抢占, 则会暂停当前 goroutine 的执行), 当然也就停不下来了.
知道了问题的根源, 要解决它就比较简单了, 只需要在 deadlock 函数的 for 循环中调用一下其它函数应该就行了, 读者可以自己去验证一下. 不过需要提示一点的是, 编译器并不会为每个函数都插入检查是否需要扩栈的代码, 只有编译器觉得某个函数有栈溢出风险才会在函数开始和结尾处插入刚刚我们分析过的 prologue 和 epilogue 代码.
结论
从本文的分析我们可以看到, Go 语言中的抢占调度其实是一种协作式抢占调度, 它需要被抢占 goroutine 的配合才能顺利完成, 而这种配合是通过编译器在函数的序言和尾声中插入的检测代码而实现的. 这也提示我们, 在编写 go 代码时需要避免纯计算式的长时间循环, 这可能导致程序假死或 STW 时间过长.
来源: https://www.cnblogs.com/abozhang/p/10892088.html