Go defer 遇上 os.Exit 時失效

間單介紹 defer 使用與特性,並說明遇上 os.Exit 失效的原因

ta-ching chen

3 minute read

 文章目錄

前言 

在撰寫 Go 程式過程中,我們可能需要在函式執行完畢後執行特定動作以確保不會發生異常,比方說讀取檔案完畢後需要關閉 file descriptor。 為求程式簡潔,最常見的做法是呼叫 defer 進行處理。以下會對 defer 幾個特性做簡介,並且說明在什麼狀況下 deffered function 不會如預期般被執行。

若沒有耐心者,可直接跳到 當 defer 遇上 os.Exit 看本文重點 (笑)

defer 簡介 

寫程式時常會遇到需要讀寫檔案、建立連線等行為,在 Go 內類似的操作往往伴隨著關閉 file descriptor 或 connection 的相關操作。

file.Close()

除非偏好波動拳寫法以外

波動拳

否則 early return 寫法往往會包含多個 return,若此時需要在每個 return 前寫同樣的程式碼往往導致簡潔性被破壞。

func fileCopyv1(srcFile, dstFile string) (int64, error) {
    src, err := os.Open(srcFile)
    if err != nil {
        return 0, err
    }

    dst, err := os.Create(dstFile)
    if err != nil {
        src.Close()
        return 0, err
    }

    src.Close()
    dst.Close()
    return io.Copy(dst, src)
}

在這種狀況下 defer 就會派上很大的用途,在必要的地方寫好 cleanup function 後剩下的就可以交給 Go 來處理。

func fileCopyv2(srcFile, dstFile string) (int64, error) {
    src, err := os.Open(srcFile)
    if err != nil {
        return 0, err
    }
    defer src.Close()

    dst, err := os.Create(dstFile)
    if err != nil {
        return 0, err
    }
    defer dst.Close()

    return io.Copy(dst, src)
}

另外,除了上述 cleanup 用途以外,若程式發生未知錯誤導致 panic 發生時,亦可以使用 defer + recover 的手法來確保服務能夠繼續運作。

func do() {
    panic("PANIC")
}

func panicTest() {
    defer func() {
    	// recover from unknown error
        if err := recover(); err != nil {
            fmt.Printf("Error occured: %v\n", err)
        }
    }()
    do()
}

特性 

如此便利的關鍵字,在使用上當然也有幾個特性值得注意:

  1. deferred function 執行順序為 LIFO (Last-in, first-out)
  2. 在 surrounding function return 前執行

deferred function 執行順序為 LIFO 

deferred function 的執行順序並非按照程式寫法由上往下執行(FIFO),而是類似 stack 逐一 pop 的 LIFO 順序執行。

defer execution stack

Fig. 1 Go defer execution stack

這邊特別加粗 類似 stack 的原因在於,實際上 go 內部並未採用 stack 來儲存 deferred functions。

詳情請見補充說明 How Go stores deferred function calls

在 surrounding function return 前執行 

從下面執行結果,可以看到除了 LIFO 特性以外,defer 2 執行是在整個 function return 後,而非離開 if 時就觸發。

func deferTest(input bool) {
    defer log.Println("defer 1")
    if input {
        defer log.Println("defer 2")
    }
    log.Println("End of function")
    return
}
$ go run main.go 
2019/04/08 07:53:25 End of function
2019/04/08 07:53:25 defer 2
2019/04/08 07:53:25 defer 1

但在少數情況下 defer 會無法正常發揮效用,也是本文的重點。

當 defer 遇上 os.Exit 

近期研究如何在 Go 上實現 pluggable system,剛好發現 hashicorp 有推出其產品用的 go-plugin 套件。 有人提到每次執行範例後都會遺留 subprocess,程式面上並無發現特別異常的地方。 該清理遺留子程序的地方也都有利用 defer 做處理。

  • 範例連結

    client := plugin.NewClient(&plugin.ClientConfig{
    HandshakeConfig: shared.Handshake,
    Plugins:         shared.PluginMap,
    Cmd:             exec.Command("sh", "-c", os.Getenv("KV_PLUGIN")),
    AllowedProtocols: []plugin.Protocol{
        plugin.ProtocolNetRPC, plugin.ProtocolGRPC},
    })
    defer client.Kill() // cleanup subprocess after program leaves
    

實際測試卻發現 subprocess 並沒有如預期般地被關閉,這當然就引起我的好奇心。 從 diff 中可以看到該問題是在加入 os.Exit(0) 後發生。 將其註解掉後,小鎮又恢復往常的和平。(感謝飛天小女警的努力)

從 godoc 針對 os.Exit 的用途描述:

Exit causes the current program to exit with the given status code. Conventionally, code zero indicates success, non-zero an error. The program terminates immediately; deferred functions are not run.

os.Exit立即性放棄執行,也就不符合上述的 surrounding function return 條件,因此不會觸發 deferred function。

等等,panic 不也是直接跳離程式嗎?

其實不管是隱式 (index out of bounds) 或顯式呼叫,panic 都會在離開前呼叫 deferred function 避免異常發生。

StackOverflow 的這則回覆寫得相當不錯,涵蓋兩者間的區別也提供對應的文件參考。 但下面另個回覆則提供相當好記的小技巧,來幫助開發者瞭解何時該用 panicos.Exit

So basically panic is for you, a bad exit code is for your user.

意思是指 panic 是當問題發生在自己端時呼叫,一來可以除錯、二來可以利用 recover 確保服務運作, 而 os.Exit 則可以用來告知使用者說你那邊發生問題了,比如何執行指令後我們會利用其 exit code 檢測是否正常執行。

$ echo "test"
test
$ echo $?
0

(這種記法讓我想到這則推特寫的 4xx、5xx 代表含義 XDD)

總結 

  1. deferred function 執行順序為 LIFO
  2. 只在最外層 function return 前執行
  3. os.Exit 會立即離開不會 return,致使無法觸發 deferred function

補充 

How go stores deferred function calls 

實際上每次呼叫 defer 實際會改呼叫 runtime.deferproc,裡面會呼叫到 newdefer

從 newdefer 可以看到,實際上是利用 slice 方式儲存,所以在程式執行時是按照呼叫順序 append 進去

  • source code

    systemstack(func() {
    lock(&sched.deferlock)
    for len(pp.deferpool[sc]) < cap(pp.deferpool[sc])/2 && sched.deferpool[sc] != nil {
        d := sched.deferpool[sc]
        sched.deferpool[sc] = d.link
        d.link = nil
        pp.deferpool[sc] = append(pp.deferpool[sc], d) // append deferred function to defer pool
    }
    unlock(&sched.deferlock)
    })
    

有趣的事情發生在離開時,freedefer 會將整個 slice 串成反向 linked list,因此 deferred function 才會以 LIFO 呼叫。

  • source code

    systemstack(func() {
    var first, last *_defer
    for len(pp.deferpool[sc]) > cap(pp.deferpool[sc])/2 {
        n := len(pp.deferpool[sc])
        d := pp.deferpool[sc][n-1]
        pp.deferpool[sc][n-1] = nil
        pp.deferpool[sc] = pp.deferpool[sc][:n-1]
        if first == nil {
            first = d
        } else {
            last.link = d
        }
        last = d
    }
    lock(&sched.deferlock)
    last.link = sched.deferpool[sc]
    sched.deferpool[sc] = first
    unlock(&sched.deferlock)
    })
    

參考頁面 

  1. https://golang.org/doc/effective_go.html#panic
  2. https://golang.org/pkg/os/#Exit
  3. https://logmatic.io/blog/5-common-golang-panics-that-ninjas-should-know/
  4. https://stackoverflow.com/questions/28472922/when-to-use-os-exit-and-panic/
  5. https://ieevee.com/tech/2017/11/23/go-panic.html

相關文章

文章內容的轉載、重製、發佈,請註明出處: https://tachingchen.com/tw/
comments powered by Disqus