背事故?分享 6 種常見的 Go 致命錯誤場景

煎魚發表於2021-12-30

大家好,我是煎魚。

有一次事故現場,在緊急恢復後,他正在排查程式碼,查了好一會。我回頭一看,這錯誤提醒很明顯就是致命錯誤,較好定位。

但此時,他竟然在查 panic-recover 是不是哪裡漏了,我表示大受震驚...

今天就由煎魚給大家分享一下錯誤型別有哪幾種,又在什麼場景下會觸發。

錯誤型別

error

第一種是 Go 中最標準的 error 錯誤,其真身是一個 interface{}。

如下:

type error interface {
    Error() string
}

在日常工程中,我們只需要建立任意結構體,實現了 Error 方法,就可以認為是 error 錯誤型別。

如下:

type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

在外部呼叫標準庫 API,一般如下:

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}
// do something with the open *File f

我們會約定最後一個引數為 error 型別,一般常見於第二個引數,可以有個約定俗成的習慣。

panic

第二種是 Go 中的異常處理 panic,能夠產生異常錯誤,結合 panic+recover 可以扭轉程式的執行狀態。

如下:

package main

import "os"

func main() {
    panic("a problem")

    _, err := os.Create("/tmp/file")
    if err != nil {
        panic(err)
    }
}

輸出結果:

$ go run panic.go
panic: a problem
goroutine 1 [running]:
main.main()
    /.../panic.go:12 +0x47
...
exit status 2

如果沒有使用 recover 作為捕獲,就會導致程式中斷。也因此經常被人誤以為程式中斷,就 100% 是 panic 導致的。

這是一個誤區。

throw

第三種是 Go 初學者經常踩坑,也不知道的錯誤型別,那就是致命錯誤 throw。

這個錯誤型別,在使用者側是沒法主動呼叫的,均為 Go 底層自行呼叫的,像是大家常見的 map 併發讀寫,就是由此觸發。

其原始碼如下:

func throw(s string) {
    systemstack(func() {
        print("fatal error: ", s, "\n")
    })
    gp := getg()
    if gp.m.throwing == 0 {
        gp.m.throwing = 1
    }
    fatalthrow()
    *(*int)(nil) = 0 // not reached
}

根據上述程式,會獲取當前 G 的例項,並設定其 M 的 throwing 狀態為 1。

狀態設定好後,會呼叫 fatalthrow 方法進行真正的 crash 相關操作:

func fatalthrow() {
    pc := getcallerpc()
    sp := getcallersp()
    gp := getg()
    
    systemstack(func() {
        startpanic_m()
        if dopanic_m(gp, pc, sp) {
            crash()
        }

        exit(2)
    })

    *(*int)(nil) = 0 // not reached
}

主體邏輯是傳送 _SIGABRT 訊號量,最後呼叫 exit 方法退出,所以你會發現這是攔也攔不住的 “致命” 錯誤。

致命場景

為此,作為一名 “成熟” 的 Go 工程師,除了保障自己程式的健壯性外,我也在網上收集了一些致命的錯誤場景,分享給大家。

一起學習和規避這些致命場景,年底爭取拿個 A,不要背上 P0 事故。

併發讀寫 map

func foo() {
    m := map[string]int{}
    go func() {
        for {
            m["煎魚1"] = 1
        }
    }()
    for {
        _ = m["煎魚2"]
    }
}

輸出結果:

fatal error: concurrent map read and map write

goroutine 1 [running]:
runtime.throw(0x1078103, 0x21)
...

堆疊記憶體耗盡

func foo() {
    var f func(a [1000]int64)
    f = func(a [1000]int64) {
        f(a)
    }
    f([1000]int64{})
}

輸出結果:

runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0xc0200e1bf0 stack=[0xc0200e0000, 0xc0400e0000]
fatal error: stack overflow

runtime stack:
runtime.throw(0x1074ba3, 0xe)
        /usr/local/Cellar/go/1.16.6/libexec/src/runtime/panic.go:1117 +0x72
runtime.newstack()
...

將 nil 函式作為 goroutine 啟動

func foo() {
    var f func()
    go f()
}

輸出結果:

fatal error: go of nil func value

goroutine 1 [running]:
main.foo()
...

goroutines 死鎖

func foo() {
    select {}
}

輸出結果:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [select (no cases)]:
main.foo()
...

執行緒限制耗盡

如果你的 goroutines 被 IO 操作阻塞了,新的執行緒可能會被啟動來執行你的其他 goroutines。

Go 的最大的執行緒數是有預設限制的,如果達到了這個限制,你的應用程式就會崩潰。

會出現如下輸出結果:

fatal error: thread exhaustion
...

可以通過呼叫 runtime.SetMaxThreads 方法增大執行緒數,不過也需要考量是否程式有問題。

超出可用記憶體

如果你執行的操作,例如:下載大檔案等。導致應用程式佔用記憶體過大,程式上漲,導致 OOM。

會出現如下輸出結果:

fatal error: runtime: out of memory
...

建議處理掉一些程式,或者換新電腦了。

總結

在今天這篇文章中,我們介紹了 Go 語言的三種錯誤型別。其中針對大家最少見,但一碰到就很容易翻車的致命錯誤 fatal error 進行了介紹,給出了一些經典案例。

希望大家後續能夠規避,你有沒有遇到過其中的場景

若有任何疑問歡迎評論區反饋和交流,最好的關係是互相成就,各位的點贊就是煎魚創作的最大動力,感謝支援。

文章持續更新,可以微信搜【腦子進煎魚了】閱讀,本文 GitHub github.com/eddycjy/blog 已收錄,學習 Go 語言可以看 Go 學習地圖和路線,歡迎 Star 催更。

參考

  • Are all runtime errors recoverable in Go?

相關文章