18 | 案例篇:內存泄漏了,我該如何定位和處理?


通過前幾節對內存基礎的學習,我相信你對 Linux 內存的工作原理,已經有了初步了解。
對普通進程來說,能看到的其實是內核提供的虛擬內存,這些虛擬內存還需要通過頁表,由系統映射為物理
當進程通過 malloc() 申請虛擬內存后,系統並不會立即為其分配物理內存,而是在首次訪問時,才通過缺頁異常陷入內核中分配
為了協調 CPU 與磁盤間的性能差異,Linux 還會使用 Cache 和 Buffer ,分別把文件和磁盤讀寫的數據緩存到內存中。
對應用程序來說,動態內存的分配和回收,是既核心又復雜的一個邏輯功能模塊。管理內存的過程中,也很容易發生各種各樣的“事故”,比如,
  • 沒正確回收分配后的內存,導致了泄漏。
  • 訪問的是已分配內存邊界外的地址,導致程序異常退出,等等。

今天我就帶你來看看,內存泄漏到底是怎么發生的,以及發生內存泄漏之后該如何排查和定位。
說起內存泄漏,這就要先從內存的分配和回收說起了。

內存的分配和回收

先回顧一下,你還記得應用程序中,都有哪些方法來分配內存嗎?用完后,又該怎么釋放還給系統呢?

前面講進程的內存空間時,我曾經提到過,用戶空間內存包括多個不同的內存段,比如只讀段、數據段、堆、棧以及文件映射段等。這些內存段正是應用程序使用內存的基本方式。

舉個例子,你在程序中定義了一個局部變量,比如一個整數數組 int data[64] ,就定義了一個可以存儲 64 個整數的內存段。由於這是一個局部變量,它會從內存空間的棧中分配內存。

棧內存由系統自動分配和管理。一旦程序運行超出了這個局部變量的作用域,棧內存就會被系統自動回收,所以不會產生內存泄漏的問題。

再比如,很多時候,我們事先並不知道數據大小,所以你就要用到標准庫函數 malloc() _,_ 在程序中動態分配內存。這時候,系統就會從內存空間的堆中分配內存。

堆內存由應用程序自己來分配和管理。除非程序退出,這些堆內存並不會被系統自動釋放,而是需要應用程序明確調用庫函數 free() 來釋放它們。如果應用程序沒有正確釋放堆內存,就會造成內存泄漏。

這是兩個棧和堆的例子,那么,其他內存段是否也會導致內存泄漏呢?經過我們前面的學習,這個問題並不難回答。
  • 只讀段,包括程序的代碼和常量,由於是只讀的,不會再去分配新的內存,所以也不會產生內存泄漏。
  • 數據段,包括全局變量和靜態變量,這些變量在定義時就已經確定了大小,所以也不會產生內存泄漏。
  • 最后一個內存映射段,包括動態鏈接庫和共享內存,其中共享內存由程序動態分配和管理。所以,如果程序在分配后忘了回收,就會導致跟堆內存類似的泄漏問題。
內存泄漏的危害非常大,這些忘記釋放的內存,不僅應用程序自己不能訪問,系統也不能把它們再次分配給其他應用。內存泄漏不斷累積,甚至會耗盡系統內存。

雖然,系統最終可以通過 OOM (Out of Memory)機制殺死進程,但進程在 OOM 前,可能已經引發了一連串的反應,導致嚴重的性能問題。

比如,其他需要內存的進程,可能無法分配新的內存;內存不足,又會觸發系統的緩存回收以及 SWAP 機制,從而進一步導致 I/O 的性能問題等等。

內存泄漏的危害這么大,那我們應該怎么檢測這種問題呢?特別是,如果你已經發現了內存泄漏,該如何定位和處理呢。

接下來,我們就用一個計算斐波那契數列的案例,來看看內存泄漏問題的定位和處理方法。

斐波那契數列是一個這樣的數列:0、1、1、2、3、5、8…,也就是除了前兩個數是 0 和 1,其他數都由前面兩數相加得到,用數學公式來表示就是 F(n)=F(n-1)+F(n-2),(n>=2),F(0)=0, F(1)=1。

案例

今天的案例基於 Ubuntu 18.04,當然,同樣適用其他的 Linux 系統。
機器配置:2 CPU,8GB 內存
預先安裝 sysstat、Docker 以及 bcc 軟件包,比如:
# install sysstat docker
sudo apt-get install -y sysstat docker.io

# Install bcc
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 4052245BD4284CDD
echo "deb https://repo.iovisor.org/apt/bionic bionic main" | sudo tee /etc/apt/sources.list.d/iovisor.list
sudo apt-get update
sudo apt-get install -y bcc-tools libbcc-examples linux-headers-$(uname -r)
其中,sysstat 和 Docker 我們已經很熟悉了。sysstat 軟件包中的 vmstat ,可以觀察內存的變化情況;而 Docker 可以運行案例程序。
bcc 軟件包前面也介紹過,它提供了一系列的 Linux 性能分析工具,常用來動態追蹤進程和內核的行為。更多工作原理你先不用深究,后面學習我們會逐步接觸。這里你只需要記住,按照上面步驟安裝完后,它提供的所有工具都位於 /usr/share/bcc/tools 這個目錄中。
注意:bcc-tools 需要內核版本為 4.1 或者更高,如果你使用的是 CentOS7,或者其他內核版本比較舊的系統,那么你需要手動升級內核版本后再安裝。
打開一個終端,SSH 登錄到機器上,安裝上述工具。
同以前的案例一樣,下面的所有命令都默認以 root 用戶運行,如果你是用普通用戶身份登陸系統,請運行 sudo su root 命令切換到 root 用戶。
如果安裝過程中有什么問題,同樣鼓勵你先自己搜索解決,解決不了的,可以在留言區向我提問。如果你以前已經安裝過了,就可以忽略這一點了。
安裝完成后,再執行下面的命令來運行案例:
$ docker run --name=app -itd feisky/app:mem-leak
案例成功運行后,你需要輸入下面的命令,確認案例應用已經正常啟動。如果一切正常,你應該可以看到下面這個界面:
$ docker logs app
2th => 1
3th => 2
4th => 3
5th => 5
6th => 8
7th => 13
從輸出中,我們可以發現,這個案例會輸出斐波那契數列的一系列數值。實際上,這些數值每隔 1 秒輸出一次。

知道了這些,我們應該怎么檢查內存情況,判斷有沒有泄漏發生呢?你首先想到的可能是 top 工具,不過,top 雖然能觀察系統和進程的內存占用情況,但今天的案例並不適合。內存泄漏問題,我們更應該關注內存使用的變化趨勢。

vmstat 

所以,開頭我也提到了,今天推薦的是另一個老熟人, vmstat 工具。
運行下面的 vmstat ,等待一段時間,觀察內存的變化情況。如果忘了 vmstat 里各指標的含義,記得復習前面內容,或者執行 man vmstat 查詢。
# 每隔 3 秒輸出一組數據
$ vmstat 3
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
0  0      0 6601824  97620 1098784    0    0     0     0   62  322  0  0 100  0  0
0  0      0 6601700  97620 1098788    0    0     0     0   57  251  0  0 100  0  0
0  0      0 6601320  97620 1098788    0    0     0     3   52  306  0  0 100  0  0
0  0      0 6601452  97628 1098788    0    0     0    27   63  326  0  0 100  0  0
2  0      0 6601328  97628 1098788    0    0     0    44   52  299  0  0 100  0  0
0  0      0 6601080  97628 1098792    0    0     0     0   56  285  0  0 100  0  0 
從輸出中你可以看到,內存的 free 列在不停的變化,並且是下降趨勢;而 buffer 和 cache 基本保持不變。

未使用內存在逐漸減小,而 buffer 和 cache 基本不變,這說明,系統中使用的內存一直在升高。但這並不能說明有內存泄漏,因為應用程序運行中需要的內存也可能會增大。比如說,程序中如果用了一個動態增長的數組來緩存計算結果,占用內存自然會增長。

那怎么確定是不是內存泄漏呢?或者換句話說,有沒有簡單方法找出讓內存增長的進程,並定位增長內存用在哪兒呢?

根據前面內容,你應該想到了用 top 或 ps 來觀察進程的內存使用情況,然后找出內存使用一直增長的進程,最后再通過 pmap 查看進程的內存分布。
但這種方法並不太好用,因為要判斷內存的變化情況,還需要你寫一個腳本,來處理 top 或者 ps 的輸出。

memleak

這里,我介紹一個專門用來檢測內存泄漏的工具,memleak。memleak 可以跟蹤系統或指定進程的內存分配、釋放請求,然后定期輸出一個未釋放內存和相應調用棧的匯總情況(默認 5 秒)。

當然,memleak 是 bcc 軟件包中的一個工具,我們一開始就裝好了,執行 /usr/share/bcc/tools/memleak 就可以運行它。比如,我們運行下面的命令:

# -a 表示顯示每個內存分配請求的大小以及地址
# -p 指定案例應用的 PID 號
$ /usr/share/bcc/tools/memleak -a -p $(pidof app)
WARNING: Couldn't find .text section in /app
WARNING: BCC can't handle sym look ups for /app
    addr = 7f8f704732b0 size = 8192
    addr = 7f8f704772d0 size = 8192
    addr = 7f8f704712a0 size = 8192
    addr = 7f8f704752c0 size = 8192
    32768 bytes in 4 allocations from stack
        [unknown] [app]
        [unknown] [app]
        start_thread+0xdb [libpthread-2.27.so] 
從 memleak 的輸出可以看到,案例應用在不停地分配內存,並且這些分配的地址沒有被回收。

這里有一個問題,Couldn’t find .text section in /app,所以調用棧不能正常輸出,最后的調用棧部分只能看到 [unknown] 的標志。

為什么會有這個錯誤呢?實際上,這是由於案例應用運行在容器中導致的。memleak 工具運行在容器之外,並不能直接訪問進程路徑 /app。

比方說,在終端中直接運行 ls 命令,你會發現,這個路徑的確不存在:
$ ls /app
ls: cannot access '/app': No such file or directory
類似的問題,我在 CPU 模塊中的 perf 使用方法中已經提到好幾個解決思路。最簡單的方法,就是在容器外部構建相同路徑的文件以及依賴庫。這個案例只有一個二進制文件,所以只要把案例應用的二進制文件放到 /app 路徑中,就可以修復這個問題。

比如,你可以運行下面的命令,把 app 二進制文件從容器中復制出來,然后重新運行 memleak 工具:
$ docker cp app:/app /app
$ /usr/share/bcc/tools/memleak -p $(pidof app) -a
Attaching to pid 12512, Ctrl+C to quit.
[03:00:41] Top 10 stacks with outstanding allocations:
    addr = 7f8f70863220 size = 8192
    addr = 7f8f70861210 size = 8192
    addr = 7f8f7085b1e0 size = 8192
    addr = 7f8f7085f200 size = 8192
    addr = 7f8f7085d1f0 size = 8192
    40960 bytes in 5 allocations from stack
        fibonacci+0x1f [app]
        child+0x4f [app]
        start_thread+0xdb [libpthread-2.27.so] 
這一次,我們終於看到了內存分配的調用棧,原來是 fibonacci() 函數分配的內存沒釋放。

定位了內存泄漏的來源,下一步自然就應該查看源碼,想辦法修復它。我們一起來看案例應用的源代碼 app.c:
$ docker exec app cat /app.c
...
long long *fibonacci(long long *n0, long long *n1)
{
    // 分配 1024 個長整數空間方便觀測內存的變化情況
    long long *v = (long long *) calloc(1024, sizeof(long long));
    *v = *n0 + *n1;
    return v;
}
 
 
void *child(void *arg)
{
    long long n0 = 0;
    long long n1 = 1;
    long long *v = NULL;
    for (int n = 2; n > 0; n++) {
        v = fibonacci(&n0, &n1);
        n0 = n1;
        n1 = *v;
        printf("%dth => %lld\n", n, *v);
        sleep(1);
    }
}
... 
你會發現, child() 調用了 fibonacci() 函數,但並沒有釋放 fibonacci() 返回的內存。所以,想要修復泄漏問題,在 child() 中加一個釋放函數就可以了,比如:

void *child(void *arg)
{
    ...
    for (int n = 2; n > 0; n++) {
        v = fibonacci(&n0, &n1);
        n0 = n1;
        n1 = *v;
        printf("%dth => %lld\n", n, *v);
        free(v);    // 釋放內存
        sleep(1);
    }
} 
我把修復后的代碼放到了 app-fix.c,也打包成了一個 Docker 鏡像。你可以運行下面的命令,驗證一下內存泄漏是否修復:

# 清理原來的案例應用
$ docker rm -f app
 
# 運行修復后的應用
$ docker run --name=app -itd feisky/app:mem-leak-fix
 
# 重新執行 memleak 工具檢查內存泄漏情況
$ /usr/share/bcc/tools/memleak -a -p $(pidof app)
Attaching to pid 18808, Ctrl+C to quit.
[10:23:18] Top 10 stacks with outstanding allocations:
[10:23:23] Top 10 stacks with outstanding allocations:
現在,我們看到,案例應用已經沒有遺留內存,證明我們的修復工作成功完成。

小結

應用程序可以訪問的用戶內存空間,由只讀段、數據段、堆、棧以及文件映射段等組成。其中, 堆內存和內存映射,需要應用程序來動態管理內存段,所以我們必須小心處理。不僅要會用標准庫函數 malloc() 來動態分配內存,還要記得在用完內存后,調用庫函數 _free() 來 _ 釋放它們。

今天的案例比較簡單,只用加一個 free() 調用就能修復內存泄漏。不過,實際應用程序就復雜多了。比如說,
malloc() 和 free() 通常並不是成對出現,而是需要你,在每個異常處理路徑和成功路徑上都釋放內存 。
在多線程程序中,一個線程中分配的內存,可能會在另一個線程中訪問和釋放。
更復雜的是,在第三方的庫函數中,隱式分配的內存可能需要應用程序顯式釋放。
所以,為了避免內存泄漏,最重要的一點就是養成良好的編程習慣,比如分配內存后,一定要先寫好內存釋放的代碼,再去開發其他邏輯。還是那句話,有借有還,才能高效運轉,再借不難。
當然,如果已經完成了開發任務,你還可以用 memleak 工具,檢查應用程序的運行中,內存是否泄漏。如果發現了內存泄漏情況,再根據 memleak 輸出的應用程序調用棧,定位內存的分配位置,從而釋放不再訪問的內存。



我比較關心老版本的Linux怎么做同樣的事,畢竟沒有辦法升級公司服務器的內核。
作者回復: 另一個用的比較多的是valgrind


老師,很多同學都問這個問題了,麻煩解答一下吧
ubuntu 4.15.0-29
# /usr/share/bcc/tools/memleak -a -p 21642
Attaching to pid 21642, Ctrl+C to quit.
perf_event_open(/sys/kernel/debug/tracing/events/uprobes/p__lib_x86_64_linux_gnu_libc_2_27_so_0x97070_21642_bcc_21882/id): Input/output error
Traceback (most recent call last):
  File "/usr/share/bcc/tools/memleak", line 416, in <module>
    attach_probes("malloc")
  File "/usr/share/bcc/tools/memleak", line 406, in attach_probes
    pid=pid)
  File "/usr/lib/python2.7/dist-packages/bcc/__init__.py", line 989, in attach_uprobe
    raise Exception("Failed to attach BPF to uprobe")
Exception: Failed to attach BPF to uprobe
作者回復: 內核中需要開啟 CONFIG_UPROBE_EVENTS=y


如果是java應用程序,也可以用這個方法定位么?
作者回復: Java 看到的是JVM 的堆棧。其實,jmap這些Java原生的工具更好用


老師,memleak只能檢測用戶程序的內存泄漏吧?如果檢測內核態謀和模塊內存泄漏呢,Kmemleak能否講一下呢?
展開
作者回復: 也支持內核的,看它的源碼可以發現,kmalloc/kfree/kmem_cache_alloc等等也都在TRACEPOINT_PROBE里面


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM