進程使用內存概念
對普通進程來說,能看到的其實是內核提供的虛擬內存,這些虛擬內存還需要通過頁表,由系統映射為物理內存。當進程通過 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。
實驗
機器配置:2 CPU,8GB 內存預先安裝 sysstat、Docker 以及 bcc 軟件包,
sysstat 軟件包中的 vmstat ,可以觀察內存的變化情況;而 Docker 可以運行案例程序。
$ docker run --name=app -itd feisky/app:mem-leak
確認案例應用已經正常啟動。如果一切正常,應該可以看到下面這個界面:
$ docker logs app 2th => 1 3th => 2 4th => 3 5th => 5 6th => 8 7th => 13
運行下面的 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 基本不變,這說明,系統中使用的內存一直在升高。但這並不能說明有內存泄漏,因為應用程序運行中需要的內存也可能會增大。比如說,程序中如果用了一個動態增長的數組來緩存計算結果,占用內存自然會增長。
專門用來檢測內存泄漏的工具,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.最簡單的方法,就是在容器外部構建相同路徑的文件以及依賴庫。這個只有一個二進制文件,所以只要把應用的二進制文件放到 /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() 函數分配的內存沒釋放。定位了內存泄漏的來源,下一步自然就應該查看源碼,想辦法修復它
$ 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); } }
修復后重新運行
# 清理原來的案例應用 $ 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() 通常並不是成對出現,在每個異常處理路徑和成功路徑上都釋放內存 。在多線程程序中,一個線程中分配的內存,可能會在另一個線程中訪問和釋放。更復雜的是,在第三方的庫函數中,隱式分配的內存可能需要應用程序顯式釋放。所以,為了避免內存泄漏,最重要的一點就是養成良好的編程習慣,比如分配內存后,一定要先寫好內存釋放的代碼,再去開發其他邏輯。還是那句話,有借有還,才能高效運轉,再借不難。當然,如果已經完成了開發任務,你還可以用 memleak 工具,檢查應用程序的運行中,內存是否泄漏。如果發現了內存泄漏情況,再根據 memleak 輸出的應用程序調用棧,定位內存的分配位置,從而釋放不再訪問的內存。