內存洩漏(增長)火焰圖

正文

當你的應用程序佔用的內存不斷地提升時,你不得不立即修復它。造成這種情況的原因可能是因為錯誤配置而導致的內存增長,也可能是因為軟件bug引起的內存洩露。無論哪一種,由於垃圾回收機制開始積極響應(消耗CPU),一些應用的性能便會開始下降。一旦某個應用增長得太過龐大,那麼其性能會受調頁機制(swapping)的影響出現斷崖式下降,甚至可能直接被系統kill掉(Linux系統的OOM Killer)。無論是內存洩漏還是內存增長,如果你的應用在擴展,你肯定想先看看其內部發生了什麼,說不定其實是個很容易修復的小問題。但關鍵是你怎樣才能做到呢?

在調試增長問題時,不管你是使用應用程序還是系統工具,通常都要檢查一下應用配置以及內存使用情況。內存洩漏問題往往更難處理,但好在有一些工具可以提供幫助。一些工具採用對應用程序的malloc調用進行程序插樁(instrumentation)的方式,比如Valgrind memcheck,它還能夠模仿一顆CPU,以至於所有的內存訪問都能被檢測到。但使用該工具通常會導致某個應用程序變慢20-30倍,有時甚至會更慢;另外一個工具是libtcmalloc的堆分析器,使用它能快一點,但應用程序還是會慢5倍以上;還有一類工具(比如gdb工具)會引發core dump,並隨後處理它來研究內存使用情況。通常在發生core dump時會要求程序暫停,或者是終止,那麼free()例程就會被調用。所以儘管插樁型工具或者core dump技術都能夠提供寶貴的細節,但別忘了你此時針對的是一個時刻增長的應用,這種情況下無論哪種工具都很難使用。

本文我會總結一下我在分析(運行時應用的)內存增長和內存洩漏問題時所用到的四種追蹤方法。運用這些方法能夠得到有關內存使用情況的代碼路徑,隨後我會使用棧追蹤技術對代碼路徑進行檢查,並且會以火焰圖的形式把它們可視化輸出。我將在Linux上演示一下分析過程,隨後概述一下其它系統的情況。

四種方法已在下圖中用綠色字標記:

內存洩漏(增長)火焰圖


這些方法都有各自的缺陷,我將予以詳細解釋。目錄:

  • | 先決條件 Linux:perf,eBPF |
  • | 1. 追蹤分配器函數:malloc(), free(), ... 2. brk() 3. mmap() 4. 缺頁中斷 |
  • | 其他操作系統 |
  • | 總結 |

先決條件

下面所有方法都必須保證tracers能夠正常地進行棧追蹤,這可能需要你事先進行檢查,因為棧追蹤並不總是能夠正常進行。比如現在有很多應用程序在編譯的時候都使用了 -fomit-frame-pointer 這個GCC選項,這會使得基於幀指針的棧追蹤技術無法使用;像java等一些基於虛擬機的runtime則會進行即時編譯,這樣tracers(如果不提供額外信息)很可能找不到程序的符號信息,就會導致棧追蹤的結果僅僅是一些16進制的數值。還有其他一些陷阱,請參閱我以前基於perf寫的棧追蹤和JIT符號相關文章。

Linux: perf, eBPF

下面展示的方法是通用的,我將使用Linux作為目標示例,然後概括其他操作系統。

在Linux平臺上有很多可以用於分析內存的tracers,這裡我選擇了perf以及bcc/eBPF這兩個標準的Linux tracers,它們都是Linux內核源碼的一部分。perf通常在較老的Linux版本上運行(也可在較新的Linux上運行),而eBPF則至少需要Linux 4.8才能進行棧追蹤。使用eBPF可以更輕鬆地概括內核情況,使得棧追蹤更加高效並且降低了開銷。

1. 追蹤分配器函數: malloc(), free(), ...

我們開始追蹤malloc(),free()等內存分配器函數。設想你用" -p PID"選項在某個進程上運行Valgrind memcheck工具,並收集60秒內其內存洩漏的信息。雖然不完整,但有這些信息已經有希望捕獲嚴重的內存洩漏問題了。即使針對單個進程,運行Valgrind memcheck工具會帶來同樣的性能下降,甚至比預期下降更多,但是這種損失只有在你需要追蹤的時候才會出現,並且只持續很短的一段時間。

分配器函數在虛擬內存上運行,而不是物理(常駐)內存,通常後者才是洩漏檢測的目標。不過幸運的是,通常在虛擬內存這一層進行分析,已經離目標(找出問題代碼)非常接近了。

我有時也會追蹤分配器函數,但是開銷很大。這使得追蹤分配器函數更像是一種調試手段,而不是一種產品分析的方法。開銷很大是因為像malloc()和free()這樣的分配器函數在以很高的頻率運行(每秒數百萬次),哪怕每次只增加一點點開銷,算下來整個開銷也會增大非常多。但為了解決既存問題,這些都是值得的,它的開銷至少比使用Valgrind memcheck或tcmalloc的堆分析工具都要小一些。內核工具eBPF在4.9或更高版本的Linux上更加好用,如果你想自己嘗試一下,我想你可以先看看使用eBPF可以解決多少問題。

1.1. Perl的例子

這裡有一個用eBPF追蹤分配器函數的例子。需要用到stackcount,這是一個我開發的bcc工具,可以簡單記錄一個特定進程(這裡是一個Perl程序)中malloc()庫函數被調用的次數,這是通過用戶態動態追蹤機制uprobe來實現的。

<code># /usr/share/bcc/tools/stackcount -p 19183 -U c:malloc > out.stacks

^
C

# more out.stacks

[...]



__GI___libc_malloc


Perl_sv_grow


Perl_sv_setpvn


Perl_newSVpvn_flags


Perl_pp_split


Perl_runops_standard

S_run_body

perl_run

main

__libc_start_main


[
unknown
]


23380



__GI___libc_malloc


Perl_sv_grow


Perl_sv_setpvn


Perl_newSVpvn_flags


Perl_pp_split


Perl_runops_standard

S_run_body

perl_run

main

__libc_start_main


[
unknown
]


65922/<code>

輸出結果顯示了追蹤的棧以及malloc()調用次數,例如,最後的一次棧追蹤導致malloc() 函數被調用了65922次。這全部是在內核上下文中統計的數據,在程序結束時才輸出摘要,這樣做避免了每次追蹤malloc()向用戶空間傳輸數據的開銷。我還使用了-U選項,代表僅僅追蹤了用戶級堆棧,因為我要檢測的是用戶級函數:libc庫中的malloc()。

然後你可以用我寫的FlameGraph軟件把輸出結果(out.stacks)轉化成火焰圖(out.svg):

<code>$ 
./
stackcollapse
.
pl
<

out
.
stacks
|

./
flamegraph
.
pl
--
color
=
mem \\


--
title
=
"malloc() Flame Graph"

--
countname
=
"calls"

>

out
.
svg
/<code>

現在就可以在一個Web瀏覽器中打開out.svg了。下圖是一個火焰圖的例子,將鼠標停留在一個條形塊上會自動顯示細節,點擊就可以放大(如果你的瀏覽器不支持SVG,請嘗試PNG):


內存洩漏(增長)火焰圖


上圖表明大多數分配都是通過 Perl_pp_split()路徑進行的。(最寬的一個分支)

如果你想自己嘗試這種方法,記得最好把所有的分配器函數(malloc()、realloc()、calloc()等)都追蹤一遍。不僅如此,你還可以測量每次分配內存的大小,把它加進來取代之前的樣本計數,這樣火焰圖就會顯示分配的字節數而不再是函數調用的次數。Sasha Goldshtein已經基於eBPF編寫了一個先進的工具,很好地實現了這些功能,其能夠以字節數大小的形式追蹤那些被分配後長期存留且沒有在取樣間隔內被釋放的內存,用以識別內存洩漏。這個工具是bcc中的memleak:詳見示例文件https://github.com/iovisor/bcc/blob/master/tools/memleak_example.txt 。

1.2. MySQL的例子

我將把上面的例子進行一點擴展,這次的場景是一個正在處理某個benchmark負載的MySQL數據庫服務器。首先,我們還是使用之前stackcount工具來得到一個malloc()計數火焰圖,但這次使用 stackcount-D30,代表持續時間為30秒。得到的火焰圖如下:

內存洩漏(增長)火焰圖


圖示結果告訴我們,malloc()調用次數最多的是在 st_select_lex::optimization() -> JOIN::optimization()這條路徑,然而這並不是分配內存最多的地方。

下面這個是malloc()字節火焰圖,寬度顯示了分配的總字節數:

內存洩漏(增長)火焰圖


可以看到大部分的字節都是在 JOIN::exec()中分配的,只有少數字節在 JOIN::optimization()中分配。兩張火焰圖基本上是在同一時間捕獲的,保證了兩次追蹤之間負載不會出現很大的變化,表明這裡造成差異的原因是因為一些調用雖然不頻繁,但是會比其他調用分配更多內存。

我另行開發了一個mallocstacks工具可以做到這樣,它跟stackcount很像,但是不再做堆棧計數,而是以參數 size_t為單位進行統計。下面是在全局追蹤malloc()來生成火焰圖的步驟:

<code># ./mallocstacks.py -f 30 > out.stacks[...copy out.stacks to your local system if desired...]
# git clone https://github.com/brendangregg/FlameGraph
# cd FlameGraph
# ./flamegraph.pl --color=mem --title="malloc() bytes Flame Graph" --countname=bytes < out.stacks > out.svg/<code>

而這裡在生成malloc()字節火焰圖以及之前的malloc()計數火焰圖時,我多加了一步,目的是讓mallocstacks的追蹤範圍只限定在mysqld和sysbench堆棧(sysbench是MySQL負載生成工具),實際中用命令表示如下:

<code>[...]# egrep 'mysqld|sysbench' out.stacks | ./flamegraph.pl ... > out.svg/<code>

由於mallocstack.py(以前使用stackcollapse.pl)的輸出格式是一棧一行,這樣在生成火焰圖之前,可以更方便地添加grep/sed/awk來對數據進行操作。

我的mallocstacks只是一個概念驗證(proof-of-concept)工具,只用於追蹤malloc()。我還會持續開發這些工具,但比起這個我更關心的是開銷問題。

1.3. 警告

警告:從Linux 4.15開始,通過Linux uprobes進行分配器函數追蹤的開銷增高(在以後的內核中可能會有所改進),即便棧追蹤工具使用內核級(即在內核上下文中)計數,但這個Perl程序的運行速度還是慢了4倍(耗時從0.53秒增長到2.14秒),但這至少比libtcmalloc的堆分析要快,後者在運行相同程序時速度慢了6倍。這算是一種最糟糕的情況,原因是Perl程序在初始化期間,malloc()函數頻繁運行。在跟蹤malloc()時,MySQL服務器的吞吐量下降了33% (CPU已經飽和,沒有空閒的餘量給tracers)。無論追蹤什麼,如果樣本數量超過一個極小的量(持續幾秒),那麼在工業級環境中這些開銷仍然是無法接受的。

由於開銷問題,我會嘗試使用其他內存分析方法,後續章節(brk(), mmap(), 缺頁中斷)會詳細描述。

1.4. 其他例子

章亦春(agentzh)基於SystemTap開發了leaks.stp,為了追求效率,leaks.stp仍然在內核上下文中進行摘要。他還生成了一份看起來很不錯火焰圖,請看這裡http://agentzh.org/misc/flamegraph/nginx-memleak-2013-08-05.svg。至此以後,我決定為火焰圖新添加一種配色(--color=mem),這樣我們就可以區分CPU火焰圖(暖色)和內存火焰圖(冷色)。

對於內存洩漏檢測來說,直接追蹤分配器函數或許更有幫助,但是追蹤malloc()的開銷實在太大,我傾向於一些間接的方法,後續章節我會講到brk(), mmap(), 缺頁中斷這些方法,它們是折中的選擇,但是開銷要小很多。

2. brk()系統調用

很多應用使用brk()來獲取內存,brk()系統調用在堆段的尾部(也即進程的數據段)設置斷點。brk()不是由應用程序直接調用的,而是提供接口給malloc()/free()這些用戶級分配器函數,這些分配器函數通常不會把內存直接返還給系統,而是把釋放的內存作為cache以供將來繼續分配。因此,brk()通常只等價於增長(而不是收縮),我們即將設想的情景就是這樣,這簡化了追蹤難度。

通常brk()調用頻率不高(每秒不多於1000次),這意味著即使我們用perf對每個brk()都進行追蹤,也不會明顯地降低效率。使用perf測量brk()調用頻率的命令如下(這裡還是在內核上下文中計數):

<code># perf stat -e syscalls:sys_enter_brk -I 1000 -a
# time counts unit events


1.000283341

0
syscalls
:
sys_enter_brk


2.000616435

0
syscalls
:
sys_enter_brk


3.000923926

0
syscalls
:
sys_enter_brk


4.001251251

0
syscalls
:
sys_enter_brk


5.001593364

3
syscalls

:
sys_enter_brk


6.001923318

0
syscalls
:
sys_enter_brk


7.002222241

0
syscalls
:
sys_enter_brk


8.002540272

0
syscalls
:
sys_enter_brk

[...]
/<code>

由於這是一個服務器,我們看到大部分時間brk()都沒有運行,這表明如果你想要得到火焰圖,你需要長時間(幾分鐘)的測量來捕獲足夠多的樣本。

如果你仍然覺得brk()運行頻率過低,你可以使用perf的 sampling模式,該模式下將對每個事件都進行dump。下面是用perf採樣brk()之後再用FlameGraph生成火焰圖的步驟:

<code># perf record -e syscalls:sys_enter_brk -a -g -- sleep 120# perf/>#           time             counts unit events     1.000283341                  0     syscalls:sys_enter_brk    
1.000283341 0 syscalls:sys_enter_brk
2.000616435 0 syscalls:sys_enter_brk
3.000923926 0 syscalls:sys_enter_brk
5.001593364 3 syscalls:sys_enter_brk

6.001923318 0 syscalls:sys_enter_brk
7.002222241 0 syscalls:sys_enter_brk
8.002540272 0 syscalls:sys_enter_brk
[...]/<code>

我使用了一個“ sleep120”啞命令。由於brk的頻率較低,可以將採樣時間維持120秒(你也可以增加)來捕獲足夠多的樣本用於分析。

除了perf,在較新的Linux系統上(4.8以上)你還可以使用Linux eBPF。可以通過內核函數Sysbrk()或sysbrk()來追蹤brk();4.14以上的內核還可以通過 syscall:sys_enter_brk這個tracepoint實現追蹤。這裡我將通過內核函數Sys_brk,使用BCC工具stackcount來演示eBPF的追蹤步驟:

<code># /usr/share/bcc/tools/stackcount SyS_brk > out.stacks
[...copy out.stacks to a local system if desired...]
# ./stackcollapse.pl < out.stacks | ./flamegraph.pl --color=mem \\ --title="Heap Expansion Flame Graph" --countname="calls" > out.svg/<code>

下面是部分stackcount輸出的結果:

<code>$ cat out.stacks
[...]
sys_brk entry_SYSCALL_64_fastpath
brk
Perl_do_readline
Perl_pp_readline
Perl_runops_standard
S_run_body
perl_run
main
__libc_start_main
[unknown]
3
sys_brk entry_SYSCALL_64_fastpath
brk
19/<code>

輸出包括許多追蹤到的棧以及相應的brk()調用次數,儘管完整的輸出不是特別長,但我還是截取了最後兩次的結果。因為brk()不會被頻繁調用,並且也沒有很多有明顯差異的棧(僅當分配時會使當前堆溢出時才會調用brk)。這意味著它的開銷非常低,與追蹤malloc()/free()導致速度變慢四倍以上相比,追蹤brk()的開銷可以忽略。

現在給出一個brk火焰圖的例子:


內存洩漏(增長)火焰圖


通過追蹤brk(),我們能夠得到導致堆空間擴展的代碼路徑,可能屬於以下其中一種:

  • 一條導致內存增長的代碼路徑
  • 一條導致內存洩漏的代碼路徑
  • 一條無辜的代碼路徑,恰好引發了當前堆空間不足的問題
  • 異步分配器函數的代碼路徑,在可用空間減少時調用

通常需要一番偵察才能明確分辨它們,但有時會很幸運。比如你在特意檢查洩漏問題時,很容易發現有一條異常代碼路徑已經現身在BUG數據庫中,它會指向一個已知的洩漏問題。

brk()追蹤可以告訴我們是什麼導致內存擴展,而後面講到的缺頁中斷追蹤,則會告訴我們是什麼消耗了內存。

3. mmap() syscall

一個應用程序,特別是在其啟動和初始化期間,可以顯式地使用mmap() 系統調用來加載數據文件或創建各種段,在這個上下文中,我們聚焦於那些比較緩慢的應用增長,這種情況可能是由於分配器函數調用了mmap()而不是brk()造成的。而libc通常用mmap()分配較大的內存,可以使用munmap()將分配的內存返還給系統。

mmap()的調用頻率不高,所以用perf追蹤每個事件應該是高效的。如果你不確定,可以用之前檢查brk()的方法檢查一下mmap(),只需把事件替換為 syscall:sys_enter_mmap。使用perf和FlameGraph的步驟:

<code># perf record -e syscalls:sys_enter_mmap -a -g -- sleep 60
# perf/>[...copy out.stacks to a local system if desired...]
# ./stackcollapse-perf.pl < out.stacks | ./flamegraph.pl --color=mem \\ --title="mmap() Flame Graph" --countname="calls" > out.svg/<code>

當然,在較新的Linux系統上(4.8以上)你還可以使用Linux eBPF。可以通過內核函數Sysmmap()或sysmmap()來追蹤mmap();4.14以上的內核還可以通過 syscall:sys_enter_mmap這個tracepoint實現追蹤。這裡我還是通過內核函數(SyS_mmap),再次使用BCC工具stackcount來演示eBPF的追蹤步驟:

<code># /usr/share/bcc/tools/stackcount SyS_mmap > out.stacks
[...copy out.stacks to a local system if desired...]
# ./stackcollapse.pl < out.stacks | ./flamegraph.pl --color=mem \\ --title="mmap() Flame Graph" --countname="calls" > out.svg/<code>

與brk()不同,調用mmap()並不意味著一定增長,因為它可能馬上就被munmap()釋放掉了。所以在追蹤mmap()時會顯示很多新的映射,它們中的大部分(也可能是全部)既不是增長也不是洩漏。如果你的系統經常存在一些短期進程(比如編譯一個軟件),那麼大量的mmap()s就會在這些程序初始化時如洪水般湧來,毀掉你的追蹤過程。

與追蹤malloc()/ free()一樣,你可以觀察和聯想地址映射的情況,於是就能找到那些沒有被free的內存。我將其留給讀者作為練習。:-)

與brk()的追蹤一樣,一旦你找出那些增長的映射(還沒有被munmap()釋放),它們就可能是如下幾種情況之一:

  • 一條導致內存增長的代碼路徑
  • 一條映射內存洩漏的代碼路徑
  • 異步分配器函數的代碼路徑,在可用空間減少時調用

由於mmap()和munmap()調用頻率不高,所以這同樣是一種低開銷的、用於分析增長問題的方法。如果它們調用頻率很高(超過每秒1000次),那麼開銷就會變得非常大,這也表明分配器和應用的設計出了問題。

4. 缺頁中斷

brk()和mmap()追蹤顯示的是虛擬內存擴展,隨後的寫入操作會逐漸消耗物理內存,引起缺頁中斷並初始化虛擬到物理的映射。這些過程可能在不同的代碼路徑上發生,一條路徑有時可能足以說明問題,有時卻可能不太典型,可以通過追蹤缺頁中斷來進一步分析。

對比分配器函數malloc(),缺頁中斷是一個低頻率行為。這意味著開銷可以忽略不計,並且你可以用perf對每一個事件進行dump,可以先用perf在一個工業環境系統上檢查一下缺頁中斷的頻率:

<code># perf stat -e page-faults -I 1000 -a
# time counts unit events
1.000257907 534 page-faults
2.000581953 440 page-faults
3.000886622 457 page-faults
4.001184123 701 page-faults

5.001474912 690 page-faults 6
.001793133 630 page-faults 7.002094796 636 page-faults 8.002401844 998 page-faults[...]/<code>

可以看到每秒鐘會有幾百次的缺頁中斷。當前系統有16顆CPU,如果維持這種頻率,用perf追蹤每個事件造成的開銷可以忽略不計。但如果系統只有一顆CPU,或者是缺頁中斷頻率高達每秒幾千次,那麼我就會考慮使用eBPF內核概括的方法來降低開銷。

使用perf以及Flamegraph的步驟:

<code># perf record -e page-fault -a -g -- sleep 30
# perf/># ./stackcollapse-perf.pl < out.stacks | ./flamegraph.pl --color=mem \\ --title="Page Fault Flame Graph" --countname="pages" > out.svg/<code>

同樣,在較新的Linux系統上你可以用eBPF。可以通過內核函數,比如 handle_mm_fault()來動態追蹤缺頁中斷,也可以在4.14以上的內核中通過tracepoint來追蹤,比如 t:exceptions:page_fault_user 和 t:exceptions:page_fault_kerne。這裡我通過tracepoint,使用BCC工具stackcount來演示eBPF的追蹤步驟:

<code># /usr/share/bcc/tools/stackcount 't:exceptions:page_fault_*' > out.stacks
[...copy out.stacks to a local system if desired...]
# ./stackcollapse.pl < out.stacks | ./flamegraph.pl --color=mem \\ --title="Page Fault Flame Graph" --countname="pages" > out.svg/<code>

下面是一個火焰圖的例子,這次是從一個java應用中生成的:

內存洩漏(增長)火焰圖

一些路徑很正常,比如中間 Universe::initialize_heap到 os::pretouch_memory這條,但其實我對右邊表示編譯過程的那條分支更感興趣,因為它能顯示出有多少內存增長是由於java的編譯造成的,而不是數據本身造成的。

之前幾種方法顯示的都是初始分配的代碼路徑,而缺頁中斷追蹤有所不同:它顯示那些佔據物理內存的代碼路徑。他們可能是:

  • 一條導致內存增長的代碼路徑
  • 一條導致內存洩漏的代碼路徑

同樣,需要進行偵察才能區分二者。如果你在排查某個應用的漏洞,並且有一個相似的正常(沒有增長的)應用,那麼為每個應用都生成一個火焰圖,然後對比尋找多出來的代碼路徑,可能能夠很快地找出不同;如果你正在開發應用,那麼每天都收集一些基線數據,這不僅可以使你找到一個增長或洩漏的代碼路徑,還能確定它們發生的日期,有助你跟蹤變化。

追蹤缺頁中斷的開銷可能比brk()或mmap()大一點,但也不會太大。缺頁中斷仍然屬於低頻率事件,這使得追蹤它的開銷接近於“可忽略”。在實踐中,我發現用追蹤缺頁中斷來診斷內存增長和洩漏是簡單、快速而且往往有效的。儘管它不是萬能的,但值得一試。

其它操作系統

  • Solaris: 可以使用DTrace進行內存追蹤,這是我的原創文章: Solaris 內存洩漏(增長)火焰圖
  • FreeBSD: 可以像Solaris一樣使用DTrace,我有機會可以分享一些例子。

總結

我已經詳細論述了4種動態追蹤的方法來分析內存增長:

  1. 分配器函數追蹤
  2. brk()系統調用追蹤
  3. mmap()系統調用追蹤
  4. 缺頁中斷追蹤

運用這些方法可以識別虛擬或物理內存的增長,定位造成增長包括洩漏的原因。brk(),mmap()以及缺頁中斷不能直接分析洩漏問題,仍需要進一步分析。然而它們也具有優勢,一個是超低的開銷使得它們可以廣泛適用於工業級應用分析,另一個是在於它們可以使追蹤工具靈活部署,不需要重啟應用程序。

鏈接

  • 我在USENIX LISA 2013 的關於Blazing Performance with Flame Graphs 的報告演講中介紹了這四種分析內存的方法,在 幻燈片102 和 視頻 56:22處。
  • 我的原創網頁內容Solaris Memory Flame Graphs 提供了更多的例子(基於其他系統)
  • 在我的 2016 ACMQ文章 The Flame Graph中我總結了這四種內存分析的方法, 文章以Communications of the ACM, Vol. 59 No. 6出版。

在火焰圖主頁 Flame Graphs中有其他形式的火焰圖、鏈接,還有火焰圖軟件。


分享到:


相關文章: