記憶體洩漏的定位與排查:Heap Profiling 原理解析

PingCAP發表於2021-11-23

title: 記憶體洩漏的定位與排查:Heap Profiling 原理解析
author: ['張業祥']
date: 2021-11-17
summary: 本文將介紹一些常見的 Heap Profiler 的實現原理及使用方法,幫助讀者更容易地理解 TiKV 中相關實現,或將這類分析手段更好地運用到自己專案中。

tags: ['tikv 效能優化']

系統長時間執行之後,可用記憶體越來越少,甚至導致了某些服務失敗,這就是典型的記憶體洩漏問題。這類問題通常難以預測,也很難通過靜態程式碼梳理的方式定位。Heap Profiling 就是幫助我們解決此類問題的。

TiKV 作為分散式系統的一部分,已經初步擁有了 Heap Profiling 的能力。本文將介紹一些常見的 Heap Profiler 的實現原理及使用方法,幫助讀者更容易地理解 TiKV 中相關實現,或將這類分析手段更好地運用到自己專案中。

什麼是 Heap Profiling

執行時的記憶體洩漏問題在很多場景下都相當難以排查,因為這類問題通常難以預測,也很難通過靜態程式碼梳理的方式定位。

Heap Profiling 就是幫助我們解決此類問題的。

Heap Profiling 通常指對應用程式的堆分配進行收集或取樣,來向我們報告程式的記憶體使用情況,以便分析記憶體佔用原因或定位記憶體洩漏根源。

Heap Profiling 是如何工作的

作為對比,我們先簡單瞭解下 CPU Profiling 是如何工作的。

當我們準備進行 CPU Profiling 時,通常需要選定某一時間視窗,在該視窗內,CPU Profiler 會向目標程式註冊一個定時執行的 hook(有多種手段,譬如 SIGPROF 訊號),在這個 hook 內我們每次會獲取業務執行緒此刻的 stack trace。

我們將 hook 的執行頻率控制在特定的數值,譬如 100hz,這樣就做到每 10ms 採集一個業務程式碼的呼叫棧樣本。當時間視窗結束後,我們將採集到的所有樣本進行聚合,最終得到每個函式被採集到的次數,相較於總樣本數也就得到了每個函式的相對佔比

藉助此模型我們可以發現佔比較高的函式,進而定位 CPU 熱點。

在資料結構上,Heap Profiling 與 CPU Profiling 十分相似,都是 stack trace + statistics 的模型。如果你使用過 Go 提供的 pprof,會發現二者的展示格式是幾乎相同的:

Go CPU Profile

Go Heap Profile

與 CPU Profiling 不同的是,Heap Profiling 的資料採集工作並非簡單通過定時器開展,而是需要侵入到記憶體分配路徑內,這樣才能拿到記憶體分配的數量。所以 Heap Profiler 通常的做法是直接將自己整合在記憶體分配器內,當應用程式進行記憶體分配時拿到當前的 stack trace,最終將所有樣本聚合在一起,這樣我們便能知道每個函式直接或間接地記憶體分配數量了。

Heap Profile 的 stack trace + statistics 資料模型與 CPU Proflie 是一致的

接下來我們將介紹多款 Heap Profiler 的使用和實現原理。

注:諸如 GNU gprofValgrind 等工具的使用場景與我們的目的場景不匹配,因此本文不會展開。原因參考 gprof, Valgrind and gperftools - an evaluation of some tools for application level CPU profiling on Linux - Gernot.Klingler

Heap Profiling in Go

大部分讀者應該對 Go 會更加熟悉一些,因此我們以 Go 為起點和基底來進行調研。

注:如果一個概念我們在靠前的小節講過了,後邊的小節則不再贅述,即使它們不是同一個專案。另外出於完整性目的,每個專案都配有 usage 小節來闡述其用法,對此已經熟悉的同學可以直接跳過。

Usage

Go runtime 內建了方便的 profiler,heap 是其中一種型別。我們可以通過如下方式開啟一個 debug 埠:

import _ "net/http/pprof"

go func() {
   log.Print(http.ListenAndServe("0.0.0.0:9999", nil))
}()

然後在程式執行期間使用命令列拿到當前的 Heap Profiling 快照:

$ go tool pprof http://127.0.0.1:9999/debug/pprof/heap

或者也可以在應用程式程式碼的特定位置直接獲取一次 Heap Profiling 快照:

import "runtime/pprof"

pprof.WriteHeapProfile(writer)

這裡我們用一個完整的 demo 來串一下 heap pprof 的用法:

package main

import (
 "log"
 "net/http"
 _ "net/http/pprof"
 "time"
)

func main() {
 go func() {
  log.Fatal(http.ListenAndServe(":9999", nil))
 }()

 var data [][]byte
 for {
  data = func1(data)
  time.Sleep(1 * time.Second)
 }
}

func func1(data [][]byte) [][]byte {
 data = func2(data)
 return append(data, make([]byte, 1024*1024)) // alloc 1mb
}

func func2(data [][]byte) [][]byte {
 return append(data, make([]byte, 1024*1024)) // alloc 1mb

程式碼持續地在 func1 和 func2 分別進行記憶體分配,每秒共分配 2mb 堆記憶體。

將程式執行一段時間後,執行如下命令拿到 profile 快照並開啟一個 web 服務來進行瀏覽:

$ go tool pprof -http=":9998" localhost:9999/debug/pprof/heap

Go Heap Graph

從圖中我們能夠很直觀的看出哪些函式的記憶體分配佔大頭(方框更大),同時也能很直觀的看到函式的呼叫關係(通過連線)。譬如上圖中很明顯看出是 func1 和 func2 的分配佔大頭,且 func2 被 func1 呼叫。

注意,由於 Heap Profiling 也是取樣的(預設每分配 512k 取樣一次),所以這裡展示的記憶體大小要小於實際分配的記憶體大小。同 CPU Profiling 一樣,這個數值僅僅是用於計算相對佔比,進而定位記憶體分配熱點。

注:事實上,Go runtime 對取樣到的結果有估算原始大小的邏輯,但這個結論並不一定準確。

此外,func1 方框中的 48.88% of 90.24% 表示 Flat% of Cum%。

什麼是 Flat% 和 Cum%?我們先換一種瀏覽方式,在左上角的 View 欄下拉點選 Top:

Go Heap Top

  • Name 列表示相應的函式名
  • Flat 列表示該函式自身分配了多少記憶體
  • Flat% 列表示 Flat 相對總分配大小的佔比
  • Cum 列表示該函式,及其呼叫的所有子函式一共分配了多少記憶體
  • Cum% 列表示 Cum 相對總分配大小的佔比

Sum% 列表示自上而下 Flat% 的累加(可以直觀的判斷出從哪一行往上一共分配的多少記憶體)
上述兩種方式可以幫助我們定位到具體的函式,Go 提供了更細粒度的程式碼行數級別的分配源統計,在左上角 View 欄下拉點選 Source:

Go Heap Source

在 CPU Profiling 中我們常用火焰圖找寬頂來快速直觀地定位熱點函式。當然,由於資料模型的同質性,Heap Profiling 資料也可以通過火焰圖來展現,在左上角 View 欄下拉點選 Flame Graph:

Go Heap Flamegraph

通過上述各種方式我們可以很簡單地看出記憶體分配大頭在 func1 和 func2。然而現實場景中絕不會這麼簡單就讓我們定位到問題根源,由於我們拿到的是某一刻的快照,對於記憶體洩漏問題來說這並不夠用,我們需要的是一個增量資料,來判斷哪些記憶體在持續地增長。所以可以在間隔一定時間後再獲取一次 Heap Profile,對兩次結果做 diff。

Implementation details

本節我們重點關注 Go Heap Profiling 的實現原理。

回顧 “Heap Profiling 是如何工作的” 一節,Heap Profiler 通常的做法是直接將自己整合在記憶體分配器內,當應用程式進行記憶體分配時拿到當前的 stack trace,而 Go 正是這麼做的。

Go 的記憶體分配入口是 src/runtime/malloc.go 中的 mallocgc() 函式,其中一段關鍵程式碼如下:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
 // ...
 if rate := MemProfileRate; rate > 0 {
  // Note cache c only valid while m acquired; see #47302
  if rate != 1 && size < c.nextSample {
   c.nextSample -= size
  } else {
   profilealloc(mp, x, size)
  }
 }
 // ...
}

func profilealloc(mp *m, x unsafe.Pointer, size uintptr) {
 c := getMCache()
 if c == nil {
  throw("profilealloc called without a P or outside bootstrapping")
 }
 c.nextSample = nextSample()
 mProf_Malloc(x, size)
}

這也就意味著,每通過 mallocgc() 分配 512k 的堆記憶體,就呼叫 profilealloc() 記錄一次 stack trace。

為什麼需要定義一個取樣粒度?每次 mallocgc() 都記錄下當前的 stack trace 不是更準確嗎?

完全精確地拿到所有函式的記憶體分配看似更加吸引人,但這樣帶來的效能開銷是巨大的。malloc() 作為使用者態庫函式會被應用程式非常頻繁地呼叫,優化記憶體分配效能是 allocator 的責任。如果每次 malloc() 呼叫都伴隨一次棧回溯,帶來的開銷幾乎是不可接受的,尤其是在服務端長期持續進行 profiling 的場景。選擇 “取樣” 並非結果上更優,而僅僅是一種妥協。

當然,我們也可以自行修改 MemProfileRate 變數,將其設定為 1 會導致每次 mallocgc() 必定進行 stack trace 記錄,設定為 0 則會完全關閉 Heap Profiling,使用者可以根據實際場景來權衡效能與精確度。

注意,當我們將 MemProfileRate 設定為一個通常的取樣粒度時,這個值並不是完全精確的,而是每次都在以 MemProfileRate 為平均值的指數分佈中隨機取一個值

// nextSample returns the next sampling point for heap profiling. The goal is
// to sample allocations on average every MemProfileRate bytes, but with a
// completely random distribution over the allocation timeline; this
// corresponds to a Poisson process with parameter MemProfileRate. In Poisson
// processes, the distance between two samples follows the exponential
// distribution (exp(MemProfileRate)), so the best return value is a random
// number taken from an exponential distribution whose mean is MemProfileRate.
func nextSample() uintptr

由於很多情況下記憶體分配是有規律的,如果按照固定的粒度進行取樣,最終得到的結果可能會存在很大的誤差,可能剛好每次取樣都趕上了某個特定型別的記憶體分配。這就是這裡選擇隨機化的原因。

不只是 Heap Profiling,基於 sampling 的各類 profiler 總會有一些誤差存在(例:SafePoint Bias),在審閱基於 sampling 的 profiling 結果時,需要時刻提醒自己不要忽視誤差存在的可能性。

位於 src/runtime/mprof.go 的 mProf_Malloc() 函式負責具體的取樣工作:

// Called by malloc to record a profiled block.
func mProf_Malloc(p unsafe.Pointer, size uintptr) {
 var stk [maxStack]uintptr
 nstk := callers(4, stk[:])
 lock(&proflock)
 b := stkbucket(memProfile, size, stk[:nstk], true)
 c := mProf.cycle
 mp := b.mp()
 mpc := &mp.future[(c+2)%uint32(len(mp.future))]
 mpc.allocs++
 mpc.alloc_bytes += size
 unlock(&proflock)

 // Setprofilebucket locks a bunch of other mutexes, so we call it outside of proflock.
 // This reduces potential contention and chances of deadlocks.
 // Since the object must be alive during call to mProf_Malloc,
 // it's fine to do this non-atomically.
 systemstack(func() {
  setprofilebucket(p, b)
 })
}

func callers(skip int, pcbuf []uintptr) int {
 sp := getcallersp()
 pc := getcallerpc()
 gp := getg()
 var n int
 systemstack(func() {
  n = gentraceback(pc, sp, 0, gp, skip, &pcbuf[0], len(pcbuf), nil, nil, 0)
 })
 return n
}

通過呼叫 callers() 以及進一步的 gentraceback() 來獲取當前呼叫棧儲存在 stk 陣列中(即 PC 地址的陣列),這一技術被稱為呼叫棧回溯,在很多場景均有應用(譬如程式 panic 時的棧展開)。

注:術語 PC 指 Program Counter,特定於 x86-64 平臺時為 RIP 暫存器;FP 指 Frame Pointer,特定於 x86-64 時為 RBP 暫存器;SP 指 Stack Pointer,特定於 x86-64 時為 RSP 暫存器。

一種原始的呼叫棧回溯實現方式是在函式呼叫約定(Calling Convention)上保證發生函式呼叫時 RBP 暫存器(on x86-64)儲存的一定是棧基址,而不再作為通用暫存器使用,由於 call 指令會首先將 RIP (返回地址)入棧,我們只要保證接下來入棧的第一條資料是當前的 RBP,那麼所有函式的棧基址就以 RBP 為頭,串成了一條地址連結串列。我們只需為每個 RBP 地址向下偏移一個單元,便能拿到 RIP 的陣列了。

Go FramePointer Backtrace(圖片來自 go-profiler-notes

注:圖中提到 Go 的所有引數均通過棧傳遞,這一結論現在已經過時了,Go 從 1.17 版本開始支援暫存器傳參。

由於 x86-64 將 RBP 歸為了通用暫存器,諸如 GCC 等編譯器預設不再使用 RBP 儲存棧基址,除非使用特定的選項將其開啟。然而 Go 編譯器卻保留了這個特性,因此在 Go 中通過 RBP 進行棧回溯是可行的。

但 Go 並沒有採用這個簡單的方案,原因是在某些特殊場景下該方案會帶來一些問題,譬如如果某個函式被 inline 掉了,那麼通過 RBP 回溯得到的呼叫棧就是缺失的。另外這個方案需要在常規函式呼叫間插入額外的指令,且需要額外佔用一個通用暫存器,存在一定的效能開銷,即使我們不需要棧回溯。

每個 Go 的二進位制檔案都包含一個名為 gopclntab 的 section,這是 Go Program Counter Line Table 的縮寫,它維護了 PC 到 SP 及其返回地址的對映。這樣我們就無需依賴 FP,便能直接通過查表的方式完成 PC 連結串列的串聯。同時 gopclntab 中維護了 PC 及其所處函式是否已被內聯優化的資訊,所以我們在棧回溯過程中便不會丟失行內函數幀。此外 gopclntab 還維護了符號表,儲存 PC 對應的程式碼資訊(函式名,行數等),所以我們最終才能看到人類可讀的 panic 結果或者 profiling 結果,而不是一大坨地址資訊。

gopclntab

與特定於 Go 的 gopclntab 不同,DWARF 是一種標準化的除錯格式,Go 編譯器同樣為其生成的 binary 新增了 DWARF (v4) 資訊,所以一些非 Go 生態的外部工具可以依賴它對 Go 程式進行除錯。值得一提的是,DWARF 所包含的資訊是 gopclntab 的超集。

回到 Heap Profiling 來,當我們通過棧回溯技術(前邊程式碼中的 gentraceback() 函式)拿到 PC 陣列後,並不需要著急直接將其符號化,符號化的開銷是相當可觀的,我們完全可以先通過指標地址棧進行聚合。所謂的聚合就是在 hashmap 中對相同的樣本進行累加,相同的樣本指的是兩個陣列內容完全一致的樣本。

通過 stkbucket() 函式以 stk 為 key 獲取相應的 bucket,然後將其中統計相關的欄位進行累加。

另外,我們注意到 memRecord 有多組 memRecordCycle 統計資料:

type memRecord struct {
 active memRecordCycle
 future [3]memRecordCycle
}

在累加時是通過 mProf.cycle 全域性變數作為下標取模來訪問某組特定的 memRecordCycle。mProf.cycle 每經過一輪 GC 就會遞增,這樣就記錄了三輪 GC 間的分配情況。只有當一輪 GC 結束後,才會將上一輪 GC 到這一輪 GC 之間的記憶體分配、釋放情況併入最終展示的統計資料中。這個設計是為了避免在 GC 執行前拿到 Heap Profile,給我們看到大量無用的臨時記憶體。

並且,在一輪 GC 週期的不同時刻我們也可能會看到不穩定的堆記憶體狀態。

最終呼叫 setprofilebucket() 將 bucket 記錄到此次分配地址相關的 mspan 上,用於後續 GC 時呼叫 mProf_Free() 來記錄相應的釋放情況。

就這樣,Go runtime 中始終維護著這份 bucket 集合,當我們需要進行 Heap Profiling 時(譬如呼叫 pprof.WriteHeapProfile() 時),就會訪問這份 bucket 集合,轉換為 pprof 輸出所需要的格式。

這也是 Heap Profiling 與 CPU Profiling 的一個區別:CPU Profiling 只在進行 profiling 的時間視窗內對應用程式存在一定取樣開銷,而 Heap Profiling 的取樣是無時無刻不在發生的,執行一次 profiling 僅僅是 dump 一下迄今為止的資料快照

接下來我們將進入 C/C++/Rust 的世界,幸運的是,由於大部分 Heap Profiler 的實現原理是類似的,前文所述的很多知識在後文對應的上。最典型的,Go Heap Profiling 其實就是從 Google tcmalloc 移植而來的,它們具備相似的實現方式。

Heap Profiling with gperftools

gperftools(Google Performance Tools)是一套工具包,包括 Heap Profiler、Heap Checker、CPU Profiler 等工具。之所以在 Go 之後緊接著介紹它,是因為它與 Go 有很深的淵源。

前文提到的 Go runtime 所移植的 Google tcmalloc 從內部分化出了兩個社群版本:一個是 tcmalloc,即純粹的 malloc 實現,不包含其它附加功能;另一個就是 gperftools,是帶 Heap Profiling 能力的 malloc 實現,以及配套的其它工具集。

其中 pprof 也是大家最為熟知的工具之一。pprof 早期是一個 perl 指令碼,後來演化成了 Go 編寫的強大工具 pprof,現在已經被整合到了 Go 主幹,平時我們使用的 go tool pprof 命令內部就是直接使用的 pprof 包。

注:gperftools 的主要作者是 Sanjay Ghemawat,與 Jeff Dean 結對程式設計的牛人。

Usage

Google 內部一直在使用 gperftools 的 Heap Profiler 分析 C++ 程式的堆記憶體分配,它可以做到:

  • Figuring out what is in the program heap at any given time
  • Locating memory leaks
  • Finding places that do a lot of allocation

作為 Go pprof 的祖先,看起來和 Go 提供的 Heap Profiling 能力是相同的。

Go 是直接在 runtime 中的記憶體分配函式硬編入了採集程式碼,與此類似,gperftools 則是在它提供的 libtcmalloc 的 malloc 實現中植入了採集程式碼。使用者需要在專案編譯連結階段執行 -ltcmalloc 連結該庫,以替換 libc 預設的 malloc 實現。

當然,我們也可以依賴 Linux 的動態連結機制來在執行階段進行替換:

$ env LD_PRELOAD="/usr/lib/libtcmalloc.so" <binary>

當使用 LD_PRELOAD 指定了 libtcmalloc.so 後,我們程式中所預設連結的 malloc() 就被覆蓋了,Linux 的動態連結器保證了優先執行 LD_PRELOAD 所指定的版本。

在執行連結了 libtcmalloc 的可執行檔案之前,如果我們將環境變數 HEAPPROFILE 設定為一個檔名,那麼當程式執行時,Heap Profile 資料就會被寫入該檔案。

在預設情況下,每當我們的程式分配了 1g 記憶體時,或每當程式的記憶體使用高水位線增加了 100mb 時,都會進行一次 Heap Profile 的 dump。這些引數可以通過環境變數來修改。

使用 gperftools 自帶的 pprof 指令碼可以分析 dump 出來的 profile 檔案,用法與 Go 基本相同。

$ pprof --gv gfs_master /tmp/profile.0100.heap

gperftools gv

$ pprof --text gfs_master /tmp/profile.0100.heap
   255.6  24.7%  24.7%    255.6  24.7% GFS_MasterChunk::AddServer
   184.6  17.8%  42.5%    298.8  28.8% GFS_MasterChunkTable::Create
   176.2  17.0%  59.5%    729.9  70.5% GFS_MasterChunkTable::UpdateState
   169.8  16.4%  75.9%    169.8  16.4% PendingClone::PendingClone
    76.3   7.4%  83.3%     76.3   7.4% __default_alloc_template::_S_chunk_alloc
    49.5   4.8%  88.0%     49.5   4.8% hashtable::resize
   ...

同樣的,從左到右依次是 Flat(mb),Flat%,Sum%,Cum(mb),Cum%,Name。

Implementation details

類似的,tcmalloc 在 malloc() 和 operator new 中增加了一些取樣邏輯,當根據條件觸發取樣 hook 時,會執行以下函式:

// Record an allocation in the profile.
static void RecordAlloc(const void* ptr, size_t bytes, int skip_count) {
  // Take the stack trace outside the critical section.
void* stack[HeapProfileTable::kMaxStackDepth];
  int depth = HeapProfileTable::GetCallerStackTrace(skip_count + 1, stack);
  SpinLockHolder l(&heap_lock);
  if (is_on) {
    heap_profile->RecordAlloc(ptr, bytes, depth, stack);
    MaybeDumpProfileLocked();
  }
}

void HeapProfileTable::RecordAlloc(
    const void* ptr, size_t bytes, int stack_depth,
    const void* const call_stack[]) {
  Bucket* b = GetBucket(stack_depth, call_stack);
  b->allocs++;
  b->alloc_size += bytes;
  total_.allocs++;
  total_.alloc_size += bytes;

  AllocValue v;
  v.set_bucket(b);  // also did set_live(false); set_ignore(false)
  v.bytes = bytes;
  address_map_->Insert(ptr, v);
}

執行流程如下:

  1. 呼叫 GetCallerStackTrace() 獲取呼叫棧。
  2. 以呼叫棧作為 hashmap 的 key 呼叫 GetBucket() 獲取相應的 Bucket。
  3. 累加 Bucket 中的統計資料。

由於沒有了 GC 的存在,取樣流程相比 Go 簡單了許多。從變數命名上來看,Go runtime 中的 profiling 程式碼的確是從這裡移植過去的。

sampler.h 中詳細描述了 gperftools 的取樣規則,總的來說也和 Go 一致,即:512k average sample step。

在 free() 或 operator delete 中同樣需要增加一些邏輯來記錄記憶體釋放情況,這比擁有 GC 的 Go 同樣要簡單不少:

// Record a deallocation in the profile.
static void RecordFree(const void* ptr) {
  SpinLockHolder l(&heap_lock);
  if (is_on) {
    heap_profile->RecordFree(ptr);
    MaybeDumpProfileLocked();
  }
}

void HeapProfileTable::RecordFree(const void* ptr) {
  AllocValue v;
  if (address_map_->FindAndRemove(ptr, &v)) {
    Bucket* b = v.bucket();
    b->frees++;
    b->free_size += v.bytes;
    total_.frees++;
    total_.free_size += v.bytes;
  }
}

找到相應的 Bucket,累加 free 相關的欄位即可。

現代 C/C++/Rust 程式獲取呼叫棧的過程通常是依賴 libunwind 庫進行的,libunwind 進行棧回溯的原理上與 Go 類似,都沒有選擇 Frame Pointer 回溯模式,都是依賴程式中的某個特定 section 所記錄的 unwind table。不同的是,Go 所依賴的是自己生態內建立的名為 gopclntab 的特定 section,而 C/C++/Rust 程式依賴的是 .debug_frame section 或 .eh_frame section。

其中 .debug_frame 為 DWARF 標準定義,Go 編譯器也會寫入這個資訊,但自己不用,只留給第三方工具使用。GCC 只有開啟 -g 引數時才會向 .debug_frame 寫入除錯資訊。

而 .eh_frame 則更為現代一些,在 Linux Standard Base 中定義。原理是讓編譯器在彙編程式碼的相應位置插入一些偽指令(CFI Directives,Call Frame Information),來協助彙編器生成最終包含 unwind table 的 .eh_frame section。

以如下程式碼為例:

// demo.c

int add(int a, int b) {
    return a + b;
}

我們使用 cc -S demo.c 來生成彙編程式碼(gcc/clang 均可),注意這裡並沒有使用 -g 引數。

  .section __TEXT,__text,regular,pure_instructions
 .build_version macos, 11, 0 sdk_version 11, 3
 .globl _add                            ## -- Begin function add
 .p2align 4, 0x90
_add:                                   ## @add
 .cfi_startproc
## %bb.0:
 pushq %rbp
 .cfi_def_cfa_offset 16
 .cfi_offset %rbp, -16
 movq %rsp, %rbp
 .cfi_def_cfa_register %rbp
 movl %edi, -4(%rbp)
 movl %esi, -8(%rbp)
 movl -4(%rbp), %eax
 addl -8(%rbp), %eax
 popq %rbp
 retq
 .cfi_endproc
                                        ## -- End function
.subsections_via_symbols

從生成的彙編程式碼中可以看到許多以 .cfi_ 為字首的偽指令,它們便是 CFI Directives。

Heap Profiling with jemalloc

接下來我們關注 jemalloc,這是因為 TiKV 預設使用 jemalloc 作為記憶體分配器,能否在 jemalloc 上順利地進行 Heap Profiling 是值得我們關注的要點。

Usage

jemalloc 自帶了 Heap Profiling 能力,但預設是不開啟的,需要在編譯時指定 --enable-prof 引數。

./autogen.sh
./configure --prefix=/usr/local/jemalloc-5.1.0 --enable-prof
make
make install

與 tcmalloc 相同,我們可以選擇通過 -ljemalloc 將 jemalloc 連結到程式,或通過 LD_PRELOAD 用 jemalloc 覆蓋 libc 的 malloc() 實現。

我們以 Rust 程式為例展示如何通過 jemalloc 進行 Heap Profiling。

fn main() {
    let mut data = vec![];
    loop {
        func1(&mut data);
        std::thread::sleep(std::time::Duration::from_secs(1));
    }
}

fn func1(data: &mut Vec<Box<[u8; 1024*1024]>>) {
    data.push(Box::new([0u8; 1024*1024])); // alloc 1mb
    func2(data);
}

fn func2(data: &mut Vec<Box<[u8; 1024*1024]>>) {
    data.push(Box::new([0u8; 1024*1024])); // alloc 1mb
}

與 Go 一節中提供的 demo 類似,我們同樣在 Rust 中每秒分配 2mb 堆記憶體,func1 和 func2 各分配 1mb,由 func1 呼叫 func2。

直接使用 rustc 不帶任何引數編譯該檔案,然後執行如下命令啟動程式:

$ export MALLOC_CONF="prof:true,lg_prof_interval:25"
$ export LD_PRELOAD=/usr/lib/libjemalloc.so
$ ./demo

MALLOC_CONF 用於指定 jemalloc 的相關引數,其中 prof:true 表示開啟 profiler,log_prof_interval:25 表示每分配 2^25 位元組(32mb)堆記憶體便 dump 一份 profile 檔案。

注:更多 MALLOC_CONF 選項可以參考文件

等待一段時間後,即可看到有一些 profile 檔案產生。

jemalloc 提供了一個和 tcmalloc 的 pprof 類似的工具,叫 jeprof,事實上它就是由 pprof perl 指令碼 fork 而來的,我們可以使用 jeprof 來審閱 profile 檔案。

$ jeprof ./demo jeprof.7262.0.i0.heap

同樣可以生成與 Go/gperftools 相同的 graph:

$ jeprof --gv ./demo jeprof.7262.0.i0.heap

jeprof svg

Implementation details

與 tcmalloc 類似,jemalloc 在 malloc() 中增加了取樣邏輯:

JEMALLOC_ALWAYS_INLINE int
imalloc_body(static_opts_t *sopts, dynamic_opts_t *dopts, tsd_t *tsd) {
 // ...
 // If profiling is on, get our profiling context.
 if (config_prof && opt_prof) {
  bool prof_active = prof_active_get_unlocked();
  bool sample_event = te_prof_sample_event_lookahead(tsd, usize);
  prof_tctx_t *tctx = prof_alloc_prep(tsd, prof_active,
      sample_event);

  emap_alloc_ctx_t alloc_ctx;
  if (likely((uintptr_t)tctx == (uintptr_t)1U)) {
   alloc_ctx.slab = (usize <= SC_SMALL_MAXCLASS);
   allocation = imalloc_no_sample(
       sopts, dopts, tsd, usize, usize, ind);
  } else if ((uintptr_t)tctx > (uintptr_t)1U) {
   allocation = imalloc_sample(
       sopts, dopts, tsd, usize, ind);
   alloc_ctx.slab = false;
  } else {
   allocation = NULL;
  }

  if (unlikely(allocation == NULL)) {
   prof_alloc_rollback(tsd, tctx);
   goto label_oom;
  }
  prof_malloc(tsd, allocation, size, usize, &alloc_ctx, tctx);
 } else {
  assert(!opt_prof);
  allocation = imalloc_no_sample(sopts, dopts, tsd, size, usize,
      ind);
  if (unlikely(allocation == NULL)) {
   goto label_oom;
  }
 }
 // ...
}

在 prof_malloc() 中呼叫 prof_malloc_sample_object() 對 hashmap 中相應的呼叫棧記錄進行累加:

void
prof_malloc_sample_object(tsd_t *tsd, const void *ptr, size_t size,
    size_t usize, prof_tctx_t *tctx) {
 // ...
 malloc_mutex_lock(tsd_tsdn(tsd), tctx->tdata->lock);
 size_t shifted_unbiased_cnt = prof_shifted_unbiased_cnt[szind];
 size_t unbiased_bytes = prof_unbiased_sz[szind];
 tctx->cnts.curobjs++;
 tctx->cnts.curobjs_shifted_unbiased += shifted_unbiased_cnt;
 tctx->cnts.curbytes += usize;
 tctx->cnts.curbytes_unbiased += unbiased_bytes;
 // ...
}

jemalloc 在 free() 中注入的邏輯也與 tcmalloc 類似,同時 jemalloc 也依賴 libunwind 進行棧回溯,這裡均不再贅述。

Heap Profiling with bytehound

Bytehound 是一款 Linux 平臺的 Memory Profiler,用 Rust 編寫。特點是提供的前端功能比較豐富,我們關注的重點在於它是如何實現的,以及能否在 TiKV 中使用,所以只簡單介紹下基本用法。

Usage

我們可以在 Releases 頁面下載 bytehound 的二進位制動態庫,只有 Linux 平臺的支援。

然後,像 tcmalloc 或 jemalloc 一樣,通過 LD_PRELOAD 掛載它自己的實現。這裡我們假設執行的是 Heap Profiling with jemalloc 一節相同的帶有記憶體洩漏的 Rust 程式:

$ LD_PRELOAD=./libbytehound.so ./demo

接下來在程式的工作目錄會產生一個 memory-profiling_*.dat 檔案,這便是 bytehound 的 Heap Profiling 產物。注意,與其它 Heap Profiler 不同的是,這個檔案是持續更新的,而非每隔特定的時間就生成一個新的檔案。

接下來執行如下命令開啟一個 web 埠用於實時分析上述檔案:

$ ./bytehound server memory-profiling_*.dat

Bytehound GUI

最直觀的方法是點選右上角的 Flamegraph 檢視火焰圖:

Bytehound Flamegraph

從圖中可以輕易看出 demo::func1 與 demo::func2 的記憶體熱點。

Bytehound 提供了豐富的 GUI 功能,這是它的一大亮點,大家可以參考文件自行探索。

Implementation details

Bytehound 同樣是替換掉了使用者預設的 malloc 實現,但 bytehound 本身並沒有實現記憶體分配器,而是基於 jemalloc 做了包裝。

// 入口
#[cfg_attr(not(test), no_mangle)]
pub unsafe extern "C" fn malloc( size: size_t ) -> *mut c_void {
    allocate( size, AllocationKind::Malloc )
}

#[inline(always)]
unsafe fn allocate( requested_size: usize, kind: AllocationKind ) -> *mut c_void {
    // ...
    // 呼叫 jemalloc 進行記憶體分配
    let pointer = match kind {
        AllocationKind::Malloc => {
            if opt::get().zero_memory {
                calloc_real( effective_size as size_t, 1 )
            } else {
                malloc_real( effective_size as size_t )
            }
        },
        // ...
    };
    // ...
    // 棧回溯
    let backtrace = unwind::grab( &mut thread );
    // ...
    // 記錄樣本
    on_allocation( id, allocation, backtrace, thread );
    pointer
}

// xxx_real 連結到 jemalloc 實現
#[cfg(feature = "jemalloc")]
extern "C" {
    #[link_name = "_rjem_mp_malloc"]
    fn malloc_real( size: size_t ) -> *mut c_void;
    // ...
}

看起來在每次 malloc 時都會固定進行棧回溯和記錄,沒有采樣邏輯。而在 on_allocation hook 中,分配記錄被髮送到了 channel,由統一的 processor 執行緒進行非同步處理。

pub fn on_allocation(
    id: InternalAllocationId,
    allocation: InternalAllocation,
    backtrace: Backtrace,
    thread: StrongThreadHandle
) {
    // ...
    crate::event::send_event_throttled( move || {
        InternalEvent::Alloc {
            id,
            timestamp,
            allocation,
            backtrace,
        }
    });
}

#[inline(always)]
pub(crate) fn send_event_throttled< F: FnOnce() -> InternalEvent >( callback: F ) {
    EVENT_CHANNEL.chunked_send_with( 64, callback );
}

而 EVENT_CHANNEL 的實現是簡單的 Mutex<Vec<T>>:

pub struct Channel< T > {
    queue: Mutex< Vec< T > >,
    condvar: Condvar
}

Performance overhead

本節我們來探尋一下前文所述的各個 Heap Profiler 的效能開銷,具體測量方法因場景而異。

所有測試均單獨執行在下述物理機環境:

主機Intel NUC11PAHi7
CPUIntel Core i7-1165G7 2.8GHz~4.7GHz 4核8執行緒
記憶體Kingston 64G DDR4 3200MHz
硬碟Samsung 980PRO 1T SSD PCIe4.
作業系統Arch Linux Kernel-5.14.1

Go

在 Go 中我們的測量方式是使用 TiDB + unistore 部署單節點,針對 runtime.MemProfileRate 引數進行調整然後分別用 sysbench 進行測量。

相關軟體版本及壓測引數資料:

Go Version1.17.1
TiDB Versionv5.3.0-alpha-1156-g7f36a07de
Commit Hash7f36a07de9682b37d46240b16a2107f5c84941ba
| Sysbench                                           

| Version | 1.0.20 |
| Tables | 8 |
| TableSize | 100000 |
| Threads | 128 |
| Operation | oltp_read_only |

得到的結果資料:

MemProfileRate結論
0: 不記錄Transactions: 1505224 (2508.52 per sec.)<br/>Queries: 24083584 (40136.30 per sec.)<br/>Latency (AVG): 51.02<br/>Latency (P95): 73.13
512k: 取樣記錄Transactions: 1498855 (2497.89 per sec.)<br/>Queries: 23981680 (39966.27 per sec.)<br/>Latency (AVG): 51.24<br/>Latency (P95): 74.46
1: 全量記錄Transactions: 75178 (125.18 per sec.)<br/>Queries: 1202848 (2002.82 per sec.)<br/>Latency (AVG): 1022.04<br/>Latency (P95): 2405.65

相較 “不記錄” 來說,無論是 TPS/QPS,還是 P95 延遲線,512k 取樣記錄的效能損耗基本都在 1% 以內。而 “全量記錄” 帶來的效能開銷符合“會很高”的預期,但卻高的出乎意料:TPS/QPS 縮水了 20 倍,P95 延遲增加了 30 倍

由於 Heap Profiling 是一項通用功能,我們無法準確的給出所有場景下的通用效能損耗,只有在特定專案下的測量結論才有價值。TiDB 是一個相對偏計算密集型的應用,記憶體分配頻率可能不及一些記憶體密集型應用,因此該結論(及後續所有結論)僅可用做參考,讀者可自行測量自身應用場景下的開銷。

tcmalloc/jemalloc

我們基於 TiKV 來測量 tcmalloc/jemalloc,方法是在機器上部署一個 PD 程式和一個 TiKV 程式,採用 go-ycsb 進行壓測,關鍵引數如下:

threadcount=200
recordcount=100000
operationcount=1000000
fieldcount=20

在啟動 TiKV 前分別使用 LD_PRELOAD 注入不同的 malloc hook。其中 tcmalloc 使用預設配置,即類似 Go 的 512k 取樣;jemalloc 使用預設取樣策略,且每分配 1G 堆記憶體就 dump 一份 profile 檔案。

最終得到如下資料:

defaultOPS: 119037.2 Avg(us): 4186 99th(us): 14000
tcmallocOPS: 113708.8 Avg(us): 4382 99th(us): 16000
jemallocOPS: 114639.9 Avg(us): 4346 99th(us): 15000

tcmalloc 與 jemalloc 的表現相差無幾,OPS 相較預設記憶體分配器下降了 4% 左右,P99 延遲線上升了 10% 左右。

在前邊我們已經瞭解到 tcmalloc 的實現和 Go heap pprof 的實現基本相同,但這裡測量出來的資料卻不太一致,推測原因是 TiKV 與 TiDB 的記憶體分配特徵存在差異,這也印證了前文所講的:“我們無法準確的給出所有場景下的通用效能損耗,只有在特定專案下的測量結論才有價值”。

bytehound

我們沒有將 bytehound 與 tcmalloc/jemalloc 放在一起的原因是在 TiKV 上實際使用 bytehound 時會在啟動階段遇到死鎖問題。

由於我們推測 bytehound 的效能開銷會非常高,理論上是無法應用在 TiKV 生產環境的,所以我們只需印證這個結論即可。

注:推測效能開銷高的原因是在 bytehound 程式碼中沒有找到取樣邏輯,每次採集到的資料通過 channel 傳送給後臺執行緒處理,而 channel 也只是簡單使用 Mutex + Vec 封裝了下。

我們選擇一個簡單的 mini-redis 專案來測量 bytehound 效能開銷,由於目標僅僅是確認是否能夠滿足 TiKV 生產環境使用的要求,而不是精確測量資料,所以我們只簡單統計並對比其 TPS 即可,具體 driver 程式碼片段如下:

var count int32

for n := 0; n < 128; n++ {
 go func() {
  for {
   key := uuid.New()
   err := client.Set(key, key, 0).Err()
   if err != nil {
    panic(err)
   }
   err = client.Get(key).Err()
   if err != nil {
    panic(err)
   }
   atomic.AddInt32(&count, 1)
  }
 }()
}

開啟 128 goroutine 對 server 進行讀寫操作,一次讀/寫被認為是一次完整的 operation,其中僅僅對次數進行統計,沒有測量延遲等指標,最終使用總次數除以執行時間,得到開啟 bytehound 前後的不同 TPS,資料如下:

預設Count: 11784571 Time: 60s TPS: 196409
開啟 bytehoundCount: 5660952 Time: 60s TPS: 94349

從結果來看 TPS 損失了 50% 以上

What can BPF bring

雖然 BPF 效能開銷很低,但基於 BPF 很大程度上只能拿到系統層面的指標,通常意義上的 Heap Profiling 需要在記憶體分配鏈路上進行統計,但記憶體分配是趨於分層的。

舉個例子,如果我們在自己的程式裡提前 malloc 了一大塊堆記憶體作為記憶體池,自己設計了分配演算法,接下來所有業務邏輯所需的堆記憶體全都從記憶體池裡自行分配,那麼現有的 Heap Profiler 就沒法用了。因為它只告訴你你在啟動階段申請了一大段記憶體,其它時候的記憶體申請數量為0。在這種場景下我們就需要侵入到自己設計的記憶體分配程式碼中,在入口處做 Heap Profiler 該做的事情。

BPF 的問題與此類似,我們可以掛個鉤子在 brk/sbrk 上,當使用者態真正需要向核心申請擴容堆記憶體時,對當前的 stack trace 進行記錄。然而記憶體分配器是複雜的黑盒,最常觸發 brk/sbrk 的使用者棧不一定就是導致記憶體洩漏的使用者棧。這需要做一些實驗來驗證,如果結果真的有一定價值,那麼將 BPF 作為低成本的兜底方案長期執行也未嘗不可(需要額外考慮 BPF 的許可權問題)。

至於 uprobe,只是無侵入的程式碼植入,對於 Heap Profiling 本身還是要在 allocator 裡走相同的邏輯,進而帶來相同的開銷,而我們對程式碼的侵入性並不敏感。

https://github.com/parca-dev/parca 實現了基於 BPF 的 Continuous Profiling,但真正利用了 BPF 的模組其實只有 CPU Profiler。在 bcc-tools 中已經提供了一個 Python 工具用來做 CPU Profiling(https://github.com/iovisor/bcc/blob/master/tools/profile.py),核心原理是相同的。對於 Heap Profiling 暫時沒有太大的借鑑意義。

相關文章