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