實戰分析一個執行起來會卡死的Go程式

愛寫程式的阿波張發表於2019-05-20

序言

最近一位非常熱心的網友建議結合demo來分析一下goroutine的排程器,而且還提供了一個demo程式碼,於是便有了本文,在此對這位網友表示衷心的感謝!

這位網友提供的demo程式可能有的gopher以前見過,已經知道了具體原因,但本文假定我們是第一次遇到這種問題,然後從零開始,通過一步一步的分析和定位,最終找到問題的根源及解決方案。

雖然本文不需要太多的背景知識,但最好使用過gdb或delve除錯工具,瞭解組合語言及函式呼叫棧當然就更好了。

本文我們需要重點了解下面這3個內容。

  1. 除錯工具無法準確顯示函式呼叫棧時如何找到函式呼叫鏈;

  2. 發生GC時,如何STOP THE WORLD;

  3. 什麼時候搶佔排程不會起作用以及如何規避。

本文的實驗環境為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,其中

  1. dealloop goroutine一直在執行一個死迴圈,並未做任何實際的工作;

  2. worker goroutine每隔一秒迴圈列印worker is running;

  3. 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函式流程比較清晰:

  1. 通過preemptall() 函式對那些正在執行go程式碼的goroutine設定搶佔標記;

  2. 停掉當前工作執行緒所繫結的p;

  3. 通過cas操作修改那些處於系統呼叫之中的p的狀態為_Pgcstop從而停掉對應的p;

  4. 修改idle佇列中p的狀態為_Pgcstop;

  5. 等待處於執行之中的p停下來。

從這個流程可以看出,stopTheWorldWithSema函式主要通過兩種方式來Stop The World:

  1. 對於那些此時此刻並未執行go程式碼的p,包括位於空閒佇列之中的p以及處於系統呼叫之中的p,通過直接設定其狀態為_Pgcstop來阻止工作執行緒繫結它們,從而保持記憶體引用的一致性。因為工作執行緒要執行go程式碼就必須要繫結p,沒有p工作執行緒就無法執行go程式碼,不執行go程式碼也就無法修改記憶體之間的引用關係;

  2. 對於那些此時此刻繫結到某個工作執行緒正在執行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時間過長。

相關文章