Go 最細節篇|內存回收又踩坑了

 背景提要

分享一個 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.

來自 https://golang.google.cn/pkg/runtime/#SetFinalizer ,什麼意思?

說得是,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 看現場很明確,但是根因可能是看似毫不相關的旮旯角落,有時候要把思維跳出來排查;

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ea7LfF2jOoHOSozX-qUZLA