Go 最細節篇|記憶體回收又踩坑了

碼農談IT發表於2023-03-06


分享一個 GC 相關的踩坑實踐。公司線上某元件記憶體資源洩漏,偶發 oom 。透過 Go 的 pprof 排查,很快速定位到洩漏的資料結構 A ,結構 A 的相關資源是透過 Go 的 Finalizer 機制來釋放的。但詭異的來了,對照著程式碼審視了多次之後,大家一致斷定,這段程式碼絕對沒有洩漏的問題。但是,事實勝於雄辯,現實就是洩漏就在此處。想不通。。。

幾天之後,問題的轉機來自於另一個毫不相關的地方,我們發現了一個卡住的協程。最開始並不在意,因為雖然卡住是異常的,但是洩漏的地點差了十萬八千里,兩者毫不相關。所以剛開始是忽略的。

後來實在是想不開,閒來無事,把這個異常點拿來看,才發現一點點線索。這個卡住的協程是一個結構體 B 的釋放過程,和 A 一樣也是 Go 的 Finalizer 機制。我們踩的坑就於此有關,很典型,出人意料,所以分享給大家。先複習一下 Finalizer 機制。


什麼是 Go 的 Finalizer 機制?


那麼什麼是 Finalizer 機制呢?這個就必須要再提一嘴 Go 的 GC 機制了。這個是 Go 比較有特色的機制。在 Go 里程式設計師負責申請記憶體,Go 的 runtime 的 GC 機制負責回收。

在這個過程,Go 語言還提供了一個 Finalizer 機制,允許程式設計師在申請的時候指定一個回撥函式,在 GC 回收到這個結構體記憶體的時候,Go 會自動呼叫一次這個回撥函式。

func SetFinalizer(obj interface{}, finalizer interface{})

這個非常實用的一個技巧,在文章《程式設計思考:物件生命週期的問題》裡有分享。主要是比較安全的解決掉物件宣告週期的問題。因為程式設計師自己來管理資源的釋放,那很可能出 bug ,比如在有人用的時候呼叫釋放。透過 Finalizer 機制,則能保證一定是無人引用的結構體記憶體,才會執行回撥。

舉個例子:

type TestStruct struct {
    name string
}

//go:noinline
func newTestStruct() *TestStruct {
    v := &TestStruct{"n1"}
    runtime.SetFinalizer(v, func(p *TestStruct) {
        fmt.Println("gc Finalizer")
    })
    return v
}

func main() {
    t := newTestStruct()
    fmt.Println("== start ===")
    _ = t
    fmt.Println("== ... ===")
    runtime.GC()
    fmt.Println("== end ===")
}

上面的例子,給結構體 TestStruct 的釋放設定了一個 Finalizer 回撥函式。然後在主動呼叫 runtime.GC 來快速回收,童鞋可以體驗一下。

Finalizer 這裡竟然有個坑?


Finalizer 很好用這是事實,但 Finalizer 機制也有限制條件,在官網上有如下宣告:

A single goroutine runs all finalizers for a program, sequentially. If a finalizer must run for a long time, it should do so by starting a new goroutine.

來自 ,什麼意思?

說得是,Go 的 runtime 是用一個單 goroutine 來執行所有的 Finalizer 回撥,還是序列化的。

劃重點:一旦執行某個 Finalizer 出了問題,可能會影響到全域性的 Finalizer 回撥函式的執行。

原來如此!!

我們這次就是精準踩坑。在釋放 B 結構體的時候,呼叫了一個 Finalizer 回撥,然後把協程卡死了。導致後續所有的 Finalizer 回撥都執行不了,比如 A 的 Finalizer 就無法執行,從而導致資源的洩漏和各種的異常。

舉個例子:

var (
    done chan struct{}
)

type A struct {
    name string
}

type B struct {
    name string
}

type C struct {
    name string
}

func newA() *A {
    v := &A{"n1"}
    runtime.SetFinalizer(v, func(p *A) {
        fmt.Println("gc Finalizer A")
    })
    return v
}

func newB() *B {
    v := &B{"n1"}
    runtime.SetFinalizer(v, func(p *B) {
        <-done
        fmt.Println("gc Finalizer B")
    })
    return v
}

func newC() *C {
    v := &C{"n1"}
    runtime.SetFinalizer(v, func(p *C) {
        fmt.Println("gc Finalizer C")
    })
    return v
}

func main() {
    a := newA()
    b := newB()
    c := newC()
    fmt.Println("== start ===")
    _, _, _ = a, b, c
    fmt.Println("== ... ===")
    for i := 0; i < 10; i++ {
        runtime.GC()
    }
    fmt.Println("== end ===")
}

這裡建立了一個極簡的例子,A,B, C 例項都設定了 Finalizer 回撥,故意讓其中一個阻塞住,會影響到剩下的 Finalizer 的執行。

總結


  1. Go 提供的 Finalizer 機制,讓程式設計師建立的時候註冊回撥函式,能很好的幫助程式設計師解決資源安全釋放的問題;
  2. Finalizer 的執行是全域性單協程,且序列化執行的。所以可能會因為某一次的卡住導致全域性的失效,切記;
  3. 排查記憶體問題的時候,pprof 看現場很明確,但是根因可能是看似毫不相關的旮旯角落,有時候要把思維跳出來排查;





來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024924/viewspace-2938249/,如需轉載,請註明出處,否則將追究法律責任。

相關文章