自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

一個全新的 Go pprof 視角 - 對象引用分析

開發(fā)
為了更高效地分析和解決這些問題,CloudWeGo 團(tuán)隊(duì)開發(fā)了一款新的工具——Goref。Goref 基于 Delve,能夠深入分析Go程序的堆對象引用,顯示內(nèi)存引用的分布,幫助開發(fā)者快速定位內(nèi)存泄漏或優(yōu)化GC開銷。

在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索引查找出baseelem size后再進(jìn)行掃描,因此,字段 A 和 C 以及它們的下游對象的內(nèi)存都會被標(biāo)記為存活。

GC 掃描變量a變量時,發(fā)現(xiàn)對應(yīng)的 GC bit 是1001,怎么理解呢?可以認(rèn)為是base+0base+24的地址是指針,要繼續(xù)掃描下游對象,這里A stringC *[]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. 1. 其中,Type 屬性記錄了它的類型信息,按 DWARF 格式遞歸遍歷,可以進(jìn)一步確定變量的每一個子對象類型;
  2. 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. 1. 待分析進(jìn)程的各個 Goroutine stack 的地址空間范圍,并包括每個 Goroutine stack 存儲 gcmask 的 stackmap,用來標(biāo)記是否可能指向一個存活的堆對象;
  2. 2. 待分析進(jìn)程的各個 data/bss segment 的地址空間范圍,包括每個 segment 的 gcmask,也是用來標(biāo)記是否可能指向一個存活的堆對象;
  3. 3. 以上兩步都是獲取 GC Roots 的必要信息;
  4. 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. 1. 輸出的根對象格式:
  • ? 棧變量格式:包名 + 函數(shù)名 + 棧變量名 github.com/cloudwego/kitex/client.invokeHandleEndpoint.func1.sendMsg
  • ? 全局變量格式:包名 + 全局變量名 github.com/cloudwego/kitex/pkg/loadbalance/lbcache.balancerFactories

  1. 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

責(zé)任編輯:龐桂玉 來源: 字節(jié)跳動技術(shù)團(tuán)隊(duì)
相關(guān)推薦

2021-03-16 08:56:35

Go interface面試

2024-01-25 11:41:00

Python開發(fā)前端

2013-01-25 09:53:40

GitHub

2015-08-19 09:29:35

Git協(xié)議編寫

2022-03-21 08:49:01

存儲引擎LotusDB

2011-07-22 17:00:14

java

2015-10-12 15:50:07

PaaS云平臺開發(fā)go

2021-05-30 07:59:00

String引用類型

2020-06-02 10:04:58

IT部門首席信息官CIO

2023-02-17 15:03:30

人工智能DevOps團(tuán)隊(duì)

2021-04-19 14:18:17

數(shù)據(jù)分析互聯(lián)網(wǎng)運(yùn)營大數(shù)據(jù)

2010-03-31 17:21:04

云計(jì)算

2024-03-01 18:55:54

內(nèi)存調(diào)試Go 語言

2023-02-26 01:37:57

goORM代碼

2015-12-02 11:23:38

DockerUber容器服務(wù)

2023-05-10 08:05:41

GoWeb應(yīng)用

2022-06-15 08:14:40

Go線程遞歸

2014-10-15 11:01:02

Web應(yīng)用測試應(yīng)用

2024-05-27 00:00:20

2021-04-15 08:55:51

Go struc代碼
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號