一個全新的 Go pprof 視角 - 對象引用分析
在Go語言開發(fā)中,內(nèi)存泄漏問題往往難以定位,傳統(tǒng)的Pprof工具雖然能提供一定幫助,但在復(fù)雜場景下其能力有限。為了更高效地分析和解決這些問題,CloudWeGo 團(tuán)隊(duì)開發(fā)了一款新的工具——Goref。Goref 基于 Delve,能夠深入分析Go程序的堆對象引用,顯示內(nèi)存引用的分布,幫助開發(fā)者快速定位內(nèi)存泄漏或優(yōu)化GC開銷。該工具不僅支持運(yùn)行時進(jìn)程的分析,還能分析核心轉(zhuǎn)儲文件,為 Go 開發(fā)者提供了一個強(qiáng)大的內(nèi)存分析工具。項(xiàng)目已開源在 GitHub 上,歡迎社區(qū)貢獻(xiàn)和使用。
Pprof的局限性
作為 Go 研發(fā)有時會遇到內(nèi)存泄露的情況,大部分人第一時間會嘗試打一個 heap profile 看問題原因。但很多時候,heap profile 火焰圖對問題排查起不到什么幫助,因?yàn)樗挥涗浟藢ο笫窃谀膭?chuàng)建的。在一些復(fù)雜業(yè)務(wù)場景下,對象經(jīng)過多層依賴傳遞或者內(nèi)存池復(fù)用,幾乎已經(jīng)無法根據(jù)創(chuàng)建的堆棧信息定位根因。
以如下 heap profile 為例,F(xiàn)astRead 函數(shù)棧是 Kitex 框架反序列化函數(shù),如果業(yè)務(wù)協(xié)程泄露了請求對象,實(shí)際上并不能反映到對應(yīng)泄露的代碼位置,而只能體現(xiàn)在 FastRead 函數(shù)棧占據(jù)了內(nèi)存。
眾所周知, Go 是帶 GC 的語言,一個對象無法釋放,幾乎 100% 是由于 GC 通過引用分析將其標(biāo)記為存活。而同樣作為 GC 語言,Java 的分析工具就更加完善了,比如 JProfiler 可以有效地展示對象引用關(guān)系。因此,我們也想在 Go 上實(shí)現(xiàn)一個高效的引用分析工具,能夠準(zhǔn)確直接地告訴我們內(nèi)存引用分布和引用關(guān)系,幫我們從艱難的靜態(tài)分析中解放出來。好消息是,我們已基本完成了這個工具的開發(fā)工作,已開源在 https://github.com/cloudwego/goref 倉庫下,使用方式見 README 文檔。
以下將分享這個工具的設(shè)計(jì)思路和詳細(xì)實(shí)現(xiàn)。
思路
GC 標(biāo)記過程
在講具體實(shí)現(xiàn)之前,我們先回顧一下 GC 是怎么標(biāo)記對象的存活的。
Go 采用類似于 tcmalloc 的分級分配方案,每個堆對象在分配時會指定到一個mspan
上,它的 size 是固定的。在 GC 時,一個堆地址會調(diào)用runtime.spanOf
從多級索引中查找到這個mspan
,從而得到原始對象的 base address 和 size。
// simplified code
func spanOf(p uintptr) *mspan {
ri := arenaIndex(p)
ha := mheap_.arenas[ri.l1()][ri.l2()]
return ha.spans[(p/pageSize)%pagesPerArena]
}
通過 runtime.heapBitsForAddr
函數(shù)可以獲得一個對象地址范圍內(nèi)的 GC bitmap。而 GC bitmap 中標(biāo)記了一個對象所在內(nèi)存的每 8 字節(jié)對齊的地址是否是一個指針類型,從而判斷是否進(jìn)一步標(biāo)記下游對象。
例如以下 Go 代碼片段:
type Object struct {
A string
B int64
C *[]byte
}
// global variables
var a = echo()
var b *int64 = &echo().B
func echo() *Object {
bytes := make([]byte, 1024)
return &Object{A: string(bytes), C: &bytes}
}
GC 在掃描變量b
時,不是只簡單地掃描B int64
這個字段的內(nèi)存,而是通過mspan
索引查找出base
和elem size
后再進(jìn)行掃描,因此,字段 A 和 C 以及它們的下游對象的內(nèi)存都會被標(biāo)記為存活。
GC 掃描變量a
變量時,發(fā)現(xiàn)對應(yīng)的 GC bit 是1001
,怎么理解呢?可以認(rèn)為是base+0
和base+24
的地址是指針,要繼續(xù)掃描下游對象,這里A string
和C *[]byte
都包含了一個指向下游對象的指針。
基于以上的簡要分析,我們可以發(fā)現(xiàn),要找到所有存活的對象,簡單的原理就是從 GC Root 出發(fā),挨個掃描對象的 GC bit,如果某個地址被標(biāo)記為1
,就繼續(xù)向下掃描,每個下游地址都要確定它的 mspan,從而獲取完整的對象基地址、大小和 GC bit。
DWARF 類型信息
然而,光知道對象的引用關(guān)系對于問題排查幾乎沒有任何幫助。因?yàn)樗荒茌敵鋈魏斡行У目晒┭邪l(fā)定位問題的變量名稱。所以,還有一個很關(guān)鍵的步驟是,獲取到這些對象的變量名和類型信息。
Go 本身是靜態(tài)語言,對象一般不直接包含其類型信息,比如我們通過obj=new(Object)
調(diào)用創(chuàng)建一個對象,實(shí)際內(nèi)存只存儲了A/B/C
三個字段的值,在內(nèi)存中只有 32 字節(jié)大小。既然如此,有什么辦法能拿到類型信息呢?
Goref 的實(shí)現(xiàn)
Delve 工具介紹
有過 Go 開發(fā)經(jīng)歷的同學(xué)應(yīng)該都用過 Delve,如果你覺得自己沒用過,不要懷疑,你在 Goland IDE 上玩的代碼調(diào)試功能,底層就是基于 Delve 的。說到這里,相信大家已經(jīng)回憶起 Debug 時調(diào)試窗口的畫面了,沒錯,調(diào)試窗口所展示的變量名,變量值,變量類型這些信息,不正是我們需要的類型信息嗎!
$ ./dlv attach 270
(dlv) ...
(dlv) locals
tccCli = ("*code.byted.org/gopkg/tccclient.ClientV2")(0xc000782240)
ticker = (*time.Ticker)(0xc001086be0)
那么,Delve 是怎么獲取這些變量信息的呢?在我們 attach 進(jìn)程時,Delve 從/proc/<pid>/exe
讀取軟鏈接到實(shí)際 elf 文件路徑的可執(zhí)行文件。Go 編譯時會生成一些調(diào)試信息,以 DWARF 標(biāo)準(zhǔn)格式存儲在可執(zhí)行文件的 .debug_*
前綴的 section 節(jié)里。引用分析所需要的全局變量和局部變量的類型信息就可以通過這些 DWARF 信息解析得到。
對于全局變量:Delve 迭代讀取所有 DWARF Entry ,解析出帶Variable
標(biāo)簽的全局變量的 DWARF Entry。這些 Entry 包含了 Location、Type、Name 等屬性。
- 1. 其中,Type 屬性記錄了它的類型信息,按 DWARF 格式遞歸遍歷,可以進(jìn)一步確定變量的每一個子對象類型;
- 2. Location 則是一個相對復(fù)雜的屬性,它記錄了一個可執(zhí)行的表達(dá)式或者一個簡單的變量地址,作用是確定一個變量的內(nèi)存地址,或者返回寄存器的值。在全局變量解析時,Delve 通過它獲得了變量的內(nèi)存地址。
Goroutine 中的局部變量解析的原理與全局變量大同小異,不過還是要更復(fù)雜一些。比如需要根據(jù) PC 確定 DWARF offset,同時 location 表達(dá)式也會更復(fù)雜,還涉及到寄存器訪問。這里不再展開。
GC 分析的元信息構(gòu)建
通過 Delve 提供的進(jìn)程 attach 和 core 文件分析功能,我們還可以獲取到內(nèi)存訪問權(quán)限。我們仿照 GC 標(biāo)記對象的做法,在工具的運(yùn)行時內(nèi)存中構(gòu)建待分析進(jìn)程的必要元信息。這包括:
- 1. 待分析進(jìn)程的各個 Goroutine stack 的地址空間范圍,并包括每個 Goroutine stack 存儲 gcmask 的
stackmap
,用來標(biāo)記是否可能指向一個存活的堆對象; - 2. 待分析進(jìn)程的各個 data/bss segment 的地址空間范圍,包括每個 segment 的 gcmask,也是用來標(biāo)記是否可能指向一個存活的堆對象;
- 3. 以上兩步都是獲取 GC Roots 的必要信息;
- 4. 最后一步是讀取待分析進(jìn)程的
mspan
索引,以及每個mspan
的 base、elem size、gcmask等信息,在工具的內(nèi)存中復(fù)原這個索引;
以上步驟是大概的流程,其中還有一些細(xì)節(jié)問題的處理,例如對 GC finalizer 對象的處理,以及對 Go 1.22 版本 allocation header 特性的特殊處理,這里不再展開。
DWARF 類型掃描
萬事俱備,只欠東風(fēng)。不管是堆掃描的 GC 元信息,還是 GC Root 變量的類型信息都已經(jīng)完成解析。那么所謂的“東風(fēng)”就是最關(guān)鍵的對象引用關(guān)系分析環(huán)節(jié)了。
對于每個 GC Root 變量,我們調(diào)用findRef
函數(shù),按不同的 DWARF 類型訪問對象的內(nèi)存,假設(shè)是一個可能指向下游對象的指針,則讀取指針的值,在 GC 元信息里找到這個下游對象。這時,按前所述,我們得到了對象的 base address、elem size、gcmask 等信息。
如果對象被訪問到,記錄一個 mark bit 位,以避免對象被重復(fù)訪問。通過 DWARF 子對象類型構(gòu)造一個新的變量,再次遞歸調(diào)用findRef
直至所有已知類型的對象被全部確認(rèn)。
然而,這種引用掃描方式和 GC 的做法是完全相悖的。主要原因在于,Go 里面有大量不安全的類型轉(zhuǎn)換,可能某個對象在創(chuàng)建后是帶了指針字段的對象,比如:
func echo() *byte {
bytes := make([]byte, 1024)
obj := &Object{A: string(bytes), C: &bytes}
return (*byte)(unsafe.Pointer(obj))
}
從 GC 的角度出發(fā),雖然 unsafe 轉(zhuǎn)換了類型為*byte
,但并沒有影響其 gcmask 的標(biāo)記,所以在掃描下游對象時,仍然能掃描到完整的Object
對象,識別到bytes
這個下游對象,從而將其標(biāo)記為存活。
但 DWARF 類型掃描可做不到,在掃描到 byte
類型時,會被認(rèn)為是無指針的對象,直接跳過進(jìn)一步的掃描了。所以,唯一的辦法是,優(yōu)先以 DWARF 類型掃描,對于無法掃到的對象,再用 GC 的方式來標(biāo)記。
要實(shí)現(xiàn)這一點(diǎn),做法是每當(dāng)我們用 DWARF 類型訪問一個對象的指針時,都將其對應(yīng)的 gcmask 從 1 標(biāo)記為 0,這樣在掃描完一個對象后,如果對象的地址空間范圍內(nèi)仍然有非 0 標(biāo)記的指針,就把它記錄到最終標(biāo)記的任務(wù)里。等到所有對象通過 DWARF 類型掃描完成后,再把這些最終標(biāo)記任務(wù)取出來,以 GC 的做法二次掃描。
例如,上述 Object
對象訪問時,其 gcmask 是1001
,讀取字段 A 后,gcmask 變成 1000
,如果字段 C 因?yàn)轭愋蛷?qiáng)轉(zhuǎn)沒有訪問到,則在最終掃描的 GC 標(biāo)記時就會被統(tǒng)計(jì)到。
除了類型強(qiáng)轉(zhuǎn)外,引用內(nèi)存越界問題也很常見,如上文示例代碼var b *int64 = &echo().B
所示,字段 A 和 C 都屬于無法被 DWARF 類型掃描的內(nèi)存,也會在最終掃描時被統(tǒng)計(jì)。
最終掃描
上述的被類型強(qiáng)轉(zhuǎn)的字段,或者因?yàn)槌^了 DWARF 定義的地址范圍而無法訪問到的字段,又或者像 unsafe.Pointer
這種無法確定類型的變量,都會在最終掃描時被標(biāo)記。因?yàn)檫@些對象沒法確定具體的類型,所以不需要專門輸出,只需要把 size 和 count 記錄到已知的引用鏈路中即可。
在 Go 原生實(shí)現(xiàn)中,有不少常用庫都采用了unsafe.Pointer
,導(dǎo)致子對象識別出現(xiàn)問題,這類類型要做特殊處理。
輸出文件格式
所有對象掃描完畢后,將引用鏈路及其對象數(shù)、對象內(nèi)存空間輸出到文件,文件對齊 pprof 二進(jìn)制文件格式,采用 protobuf 編碼。
- 1. 輸出的根對象格式:
- ? 棧變量格式:包名 + 函數(shù)名 + 棧變量名
github.com/cloudwego/kitex/client.invokeHandleEndpoint.func1.sendMsg
- ? 全局變量格式:包名 + 全局變量名
github.com/cloudwego/kitex/pkg/loadbalance/lbcache.balancerFactories
- 2. 輸出的子對象格式:
- ? 輸出子對象的字段名和類型名,形如:
Conn.(net.Conn)
; - ? 如果是 map key 或 value 字段,則以
$mapkey. (type_name)
或$mapval. (type_name)
的形式輸出; - ? 如果是數(shù)組的元素,以
[0]. (type_name)
格式輸出,大于等于 10 的以[10+]. (type_name)
格式輸出;
效果展示
以下是一個真實(shí)業(yè)務(wù)用工具采樣后的對象引用火焰圖:
圖中展示了每個 root 變量的名稱,以及其引用的字段名和類型名。注:由于 Go1.23 之前 DWARF Info 沒有支持閉包類型的字段 offset,所以閉包變量wpool.(*Pool).GoCtx.func1.task
暫時無法展示下游對象。
選擇 inuse_objects
標(biāo)簽,還可以查看對象數(shù)分布火焰圖:
項(xiàng)目地址:https://github.com/cloudwego/goref