C/C++程序內存問題分析

2021-02-13 百度智能化測試

在《C/C++程序core dump問題分析》[1]中,我們分析了程序出core的問題的類型和幾類原因。本次,FourExperts小組整理了一些內存問題,希望通過對這些問題的分析,大家能夠了解到程序內存問題的類型以及一些基礎知識。

程序的內存問題根據現象劃分,主要有兩類:

內存洩漏:表現為程序佔用的內存不斷變多,沒有釋放的趨勢或特徵;

程序性能問題:我們知道,當程序的內存碎片太多時,程序申請內存的成本大增,I/O消耗變多,會嚴重影響程序的性能。極端情況,會出現程序出core的現象。

下面我們就對這兩類問題進行討論。

程序內存洩漏問題是十分常見的一類程序問題,有很多原因都會導致程序表現出內存洩漏的現象。從造成洩漏的根本原因上劃分,我們可以簡單地將問題分為兩類:

程序設計不合理:這類問題的表現為程序中丟失了申請內存的引用,導致這些內存再也無法引用,從而引起洩漏。這類原因也是非常基本的一類原因。

內存碎片過多:由於不合理的內存使用方式,導致內存碎片變多,程序只有不斷申請新的物理內存才能滿足需要。這類問題往往伴隨著程序性能的惡化。

從分析結果來看,這類問題主要可以分為幾類:

指向內存空間的指針丟失

申請的內存沒有釋放或釋放不完全

內存無限申請而不釋放

使用的數據結構不釋放內存

指針丟失問題,是最常見的一類內存洩露的原因。在C/C++程序中,所有的內存空間一般都是通過指針(pointer)進行引用的,一旦指針丟失,則失去了申請內存的訪問入口,那塊內存就「丟失」了,也就是我們說的洩露。

對於一個有一定編程經驗的碼農來說,直接的指針丟失發生的情況比較少。這種類型問題的發生,往往都和程序的框架、複雜的數據結構有關。比如有這樣一個例子,由於開發者對程序框架不了解,導致了內存洩漏。這個案例具體為:在程序的框架中,當程序執行到某個階段,都會對線程中的數據執行memset操作,也就是將所有的數據都重置為0。一個開發者對框架的這種行為不了解,在線程數據中直接定義了圖1所示的結構:
                                          (圖1 代碼示意圖)

在para變量是向量結構,裡面存放了指針。當框架調用memset對整個module_data_t進行重置時,這些指針都置為0,內存就這樣洩漏了。直接的內存洩露大多是這種問題。

在程序設計中,我們都知道內存要遵循誰申請、誰釋放的原則,申請之後必須釋放,否則就會洩露。但是在一些情況下,往往會出現申請了,但沒有釋放或完全釋放。

這類問題中,最常見的一類是在程序的異常分支中沒有對之前申請的內存進行及時的清理,導致內存洩漏。比如代碼片段1中的第12行代碼處理的就存在問題。
 另外一類常見的內存未正確釋放的問題就是析構函數沒有定義為虛函數。在C++的書籍中都會提到這個概念,當子類的對象在析構時,如果父類的析構函數沒有定義為虛函數,那麼父類中析構函數將得不到執行,父類中申請的動態內存也就得不到釋放。這類問題非常多,不再贅述。

更複雜的一個例子是:在程序中使用了memorypool對內存進行統一的管理,但在內存管理的過程中出現了非memorypool管理的內存申請,導致獨立申請的內存沒有得到妥善的處理,引起了洩漏。這類問題的代碼原型一般如代碼片段2所示。在父類Foo中的內存申請並沒有在memorypool中進行,所以當使用memorypool提供的clear函數進行統一的內存回收時,父類中的內存出現了洩漏。這類問題的解決方法是:顯示的調用對象的析構函數,如 p_obj->~Bar()。建議:使用replacement構造函數時,需要顯式調用該對象的析夠函數。

無限申請內存的情況,一般發生程序出現死循環或類似死循環的情況中。程序不斷地在申請內存,而釋放內存的動作總是得不到執行,內存佔用越來越多,出現洩漏。在代碼片段3中的第6行,程序可能會出現死循環。uint8_t的最大值是255,如果vector中元素的個數大於255,那麼程序就會無限循環,從而導致內存洩漏。

程序的結構設計的不合理,也會導致內存永遠得不到釋放的情況發生,其發生的類型與1.1.2中的第一個例子非常相像。在這個例子中,如果程序的邏輯在走到12行時都退出,後面的內存釋放操作一直得不到執行,內存就洩露了。一般來說,這類問題都比較簡單,排查代價也較小。

大家都知道,C++中的標準容器vector是一種自增長結構,隨著插入數據的不斷變多,其佔用的內存會呈現指數級的增長,且不會自動釋放。關於vector的增長因子的設置,在這篇文章[2]中有較好的討論,一般認為增長因子設置為1.5能夠取得較好的性能。在使用這種自增長的數據結構時,要特別防止由於偶然出現的大數據,導致程序展鴻內存過多的情況。

protobuf是一種google開源的一種數據通訊協議,具有效率高,兼容性好的特點,在程序中廣泛的使用。在protobuf中運用了std::string,同時RepeatedField採用了類似vector的內存管理方式,在生命周期內,內存分配都是只增不減的。一個對象如果長時間不釋放,而且內部結構變化很大且非常頻繁,會導致內存佔用逐漸上升。增加在nova發生了一個oom,追查了一個月,才查出這個原因。對於這種問題可以定期的對其佔用的內存進行清理。如果是指針,可以delete後在new一個;如果是成員變量,可以使用swap接口,與一個全新的對象進行內存交換以釋放內存。

內存碎片過多造成的洩露比較難以排查,需要對作業系統及基礎庫的內存分配原理有一定的了解,並且熟練掌握一些分析的工具才行。


(圖2 tcmalloc內存分配示意圖)

圖2是tcmalloc的內存釋放示意圖。tcmalloc中空閒的內存分為free list和large list。free list中存放大小為1到128 span的內存塊。large list中存放大小超過128span的內存塊。當內存空閒的時,內存會根據釋放的內存塊的大小將其放入free list或large list中。tcmalloc的內存釋放機制會逐個遍歷free list和large list中的內存塊大小,當large list中的一個span得到釋放時,有會重新返回到free list的頭部。因此large list中的span得到釋放的機會很少。

在一個案例中,我們發現程序在流量小的時候,伴隨著不斷地reload詞典,內存不斷的出現上漲,而流量大的時候返回內存上漲不明顯。結合tcmalloc的機制,可以解釋為:流量小時,程序的內存申請釋放操作不頻繁,詞典reload釋放的大量的內存都被掛載到了large list中。由於large list中的span很少能被輪訓到,因此遲遲得不到釋放。在流量大時,由於內存動作頻繁,這些釋放的大塊內存很快就被消耗掉了。另外,reload動作釋放的內存也不容易被後續的reload重複利用,這裡主要是碎片問題。如果在程序內部,一次開闢大量的空間,那麼large list中的span很難滿足,只能重新向系統申請。

內存問題引起的程序性能惡化,主要原因是存在內存碎片。如果程序頻繁的向作業系統申請/釋放內存,就到造成內存碎片以及作業系統的缺頁動作,從而造成程序的性能下降。在介紹具體的案例前,我們簡短回顧下glibc進行內存分配的原理。

(圖3 使用 sbrk 和 mmap 分配內存示意圖[1])

「.bss」段與堆棧之間的空間是空閒的. 這個區域可以供用戶自由使用, .bss 段之上的這塊分配給用戶程序的空間被稱為 heap (堆),start_brk 指向 heap 的開始,而 brk 指向 heap 的頂部,可以使用系統調用 brk 和 sbrk 來增加標識heap頂部的brk值,從而線性的增加分配給用戶的 heap 空間,在使用malloc之前,brk的值等於start_brk,也就是說 heap 大小為0。

默認情況下,malloc函數分配內存,如果請求內存較小(小於128k),則通過brk分配,如果請求內存大於128K(可由M_MMAP_THRESHOLD選項調節)並且當前mmap()分配的內存塊小於設定的最大值(max_map_count值),則利用mmap系統調用,即從堆和棧的中間分配一塊虛擬內存(圖中mmaped area)。

使用 mmap 直接映射的 chunk 在釋放時直接解除映射,而不再屬於進程的內存空間,任何對該內存的訪問都會產生段錯誤。而在heap中分配的空間則可能會留在進程內存空間內,也就是說調用free()時,並沒有將內存真正地歸還給OS,free只是將這塊內存放到了free list裡,brk分配的內存需要等到高地址內存釋放以後才能釋放。

頻繁的申請/釋放內存,一個很明顯的結果就是程序的CPU惡化。程序在動態申請內存時,一般會通過(s)brk和mmap這兩個系統調用來分配內存,具體的過程可以參考這篇文章[4]。當程序真正的訪問內存空間時,作業系統會產生缺頁中斷。這是CPU進入內核態,執行以下的操作:

1. 檢查要訪問的虛擬地址是否合法

2. 查找/分配一個物理頁

3. 填充物理頁內容(讀取磁碟,或者直接置0,或者啥也不幹)

4. 建立映射關係(虛擬地址到物理地址)

5. 重新執行發生缺頁中斷的那條指令

如果只進行到第2步,叫做minflt(minor fault),如果進行到第5步則為majflt(major fault)。如果發生minflt過多,說明在進程空間內分配虛擬內存和物理內存時,不能很好的利用原來已經分配好的地址空間,需要大量的重新申請。如果發生majflt,則說明程序的I/O過於頻繁了,程序的性能肯定會差。表現為CPU等待I/O的時間特別長。

有這樣一個案例:在程序的每個線程中分配2M的內存,在結束時釋放。根據glibc的內存分配規則,我們知道當申請的內存大於128K時,使用mmap分配空。在munmap釋放內存時,作業系統會將虛擬內存及物理內存全部釋放。在下次程序申請內存時,重新申請。由於這個程序每秒鐘要處理2000個請求,假設每次申請2M內存產生10個缺頁中斷,那麼每秒會產生10*2000=2W次缺頁中斷,程序的內核消耗的CPU將十分的大。相對於mmap,在heap上直接分配內存,可以使分配過的內存得到重複的利用,不會產生過多的缺頁中斷。如果發現程序中有大量的minflt,可以採用如代碼片段4所示的方法來關閉mmap分配內存。
當然,更好的方法使用內存池來管理內存,如tcmalloc、jemalloc等。

/proc/<pid>/mapsmaps這個文件裡包含著進程的代碼段,數據段,堆,棧等的虛擬內存地址,maps文件有多少行,就以意味著該進程的虛擬內存空間被分割成了多少塊。當用malloc分配內存時,maps文件將發生變化,並不是說調用一次malloc,maps文件就會增長一行,它可能只是將堆的尾地址變大了一點,maps行數並不改變,或者如果連續使用malloc分配內存,分配的空間連在一起,maps文件可能只會增長一行。對於動態申請的內存,Linux系統是有限制的,在/proc/sys/vm/max_map_count中設置了最大的map數量。如果maps文件中的行數大於max_map_count,則會分配內存失敗。

曾經有這樣一個例子,程序佔用的內存為74G,但伺服器的內存為96G,程序依然會發生申請內存失敗的情況。那麼造成maps文件中的行數變大的原因是什麼呢?真實的原因是,程序在多線程中存在大量申請內存、釋放內存的操作。由於申請釋放的內存相對較小,所以理論上都是在堆上申請內存的。真實的情況是,程序通過mmap申請了大量內存,作為sub heap。程序在進行了大量的內存操作後,存在大量的碎片。每個碎片都映射為maps文件中的一行,最終超過了max_map_count文件中設定的範圍。

對於這類問題,改進的方法還是 要使用tcmalloc等內存池,更好的申請,回收內存,減少內存中的碎片。當然,也可以臨時性的通過調大max_map_count文件中的數值來暫時解決問題。

整體上看,與程序出core問題比較,程序的內存問題的排查難度相對較大,涉及到的作業系統方面的知識也較多。為了提高程序的內存利用率,降低內存方面的使用問題,建議使用成熟的內存池對內存進行管理。當然,在使用內存池時也要對內存池的行為進行研究,避免出現性能及洩露問題。程序設計不合理,依然是內存問題產生的重要原因,希望大家繼續重視。

參考文獻:

[1] http://mp.weixin.qq.com/s?__biz=MzAwNTI4NzIxMQ==&mid=2651473046&idx=1&sn=5134cd8ef2edb1f8bff3786c3d97fd2e&scene=0#wechat_redirect 

[2] http://wiki.baidu.com/pages/viewpage.action?pageId=187510170 

[3] http://blog.csdn.net/phenics/article/details/777053#node_sec_6

[4] http://bbs.csdn.net/topics/330179712

關注百度質量部訂閱號,回復以下內容立馬查看乾貨哦~

----

 1.回復關鍵詞評測查看評測系列文章
 2.
回復關鍵詞「CI」查看CI系列經驗分享
 3.
回復關鍵詞移動測試查看移動測試系列工具點評

相關焦點

  • 【藏經閣】C/C++程序性能案例分析-內存問題分析
    ▌3.1 內存洩露案例分析程序內存洩漏問題是十分常見的一類程序問題,有很多原因都會導致程序表現出內存洩漏的現象。從造成洩漏的根本原因上劃分,我們可以簡單地將問題分為兩類:▌3.1.1 程序設計不合理從分析結果來看,這類問題主要可以分為幾類:指向內存空間的指針丟失申請的內存沒有釋放或釋放不完全、內存無限申請而不釋放使用的數據結構不釋放內存
  • C/C++程序CPU問題分析
    本次我們收集了14個CPU類的問題,和大家一起分析下這些問題的種類和原因。另外,對於C/C++程序而言,目前已經有了很多CPU問題定位的工具,本文也會進行比較分析。程序CPU類問題的主要現象是:程序佔用的CPU過高,比程序升級前有很大的升高。導致程序CPU佔用過高的主要原因是程序設計不合理,絕大部分的CPU問題都是程序設計的問題。因此,提高程序的設計質量是避免CPU問題的主要手段。
  • Linux平臺中調試C/C++內存洩漏方法 (騰訊和MTK面試的時候問到的)
    本文將從靜態分析和動態檢測兩個角度介紹在 Linux 環境進行內存洩漏檢測的方法,並重點介紹靜態分析工具 BEAM、動態監測工具 Valgrind 和 rational purify 的使用方法。相信通過本文的介紹,能給大家對處理其它產品或項目內存洩漏相關的問題時提供借鑑。從 歷史上看,來自計算機應急響應小組和供應商的許多最嚴重的安全公告都是由簡單的內存錯誤造成的。
  • C/C++程序調試和內存檢測
    為了能夠調試程序,需要在編譯和連結時為每個源文件加上編譯選項參數。這些選項的作用是讓編譯器在程序中添加額外的調試信息。這些信息包括符號和原始碼行號,調試器將利用這些信息向用戶顯示程序已經執行到的原始碼的位置。-g標誌是對程序調試性編譯時常用的一個選項。
  • 如何通過wrap malloc定位C/C++程序的內存洩漏
    我們可以review代碼,但從海量代碼裡找到隱藏的問題,這如同大海撈針,往往兩手空空。所以,我們需要藉助工具,比如valgrind,但這些找內存洩漏的工具,往往對你使用動態內存的方式有某種期待,或者說約束,比如常駐內存的對象會被誤報出來,然後真正有用的信息會掩蓋在誤報的汪洋大海裡。
  • C/C++程序core dump分析(一)
    那麼程序出core的情況有哪些的?如果程序core了之後,我們應該如何對這類問題進行定位呢?FourExperts小組的同學經過大量的案例收集、篩選和分析,產出了這份分析報告。希望大家從中了解程序出core的常見原因和定位方法。為了給大家一個直觀的認識,我們首先分析一下程序出core的常見原因及分類方法。通過這些分類,我們可以對分core的原因、定位方法有初步的認識。
  • python+C、C++混合編程的應用
    我看到的一個很好的Python與c/c++混合編程的應用是NS3(Network Simulator3)一款網絡模擬軟體,它的內部計算引擎需要用高性能,但在用戶建模部分需要靈活易用。NS3的選擇是使用C/C++來模擬核心部件和協議,用python來建模和擴展。這篇文章介紹python和c/c++三種混合編程的方法,並對性能加以分析。
  • C 2 C++進階篇(1)
    首先談談筆者的水平,只學過c和數據結構,接觸過指針,對於取地址&從來沒有接觸過(因為據說是老師說不符合嚴謹的c....), python
  • c++之內存分配、命名空間、強制類型轉換學習總結
    一、C++動態內存分配:在學習c語言的時候,我們一般都是使用庫函數malloc()來進行內存的申請分配,然後使用庫函數free()來進行釋放申請到的內存;現在在c++裡面採用了另外一種內存申請的方法:c++中通過
  • C/C++優勢究竟在哪裡?是什麼讓他們經久不衰?看看這個你就懂了
    相較於C語言,c++誕生於1983年,緊隨c語言的步伐,c++是C語言的超集,大家所知道的C語言是面向過程的,java是面向對象的,那麼C語言為了面向對象,所以誕生出現在大家所熟知的c++,被廣泛視為大規模應用構建軟體。
  • 九大程式語言優缺點第四期:c++
    C++:c++誕生於1983年,緊隨c語言的步伐,c++是C語言的超集,大家所知道的C語言是面向過程的,java是面向對象的,那麼C語言為了面向對象,所以誕生出現在大家所熟知的c++,被廣泛視為大規模應用構建軟體。
  • C/C++刁鑽問題各個擊破之細說sizeof
    其實n等於4,因為a是指針,在特性2中講過:在32位平臺下,所有指針的大小都是4byte!切記,這裡的a與特性3中的a並不一樣!很多人(甚至一些老師)都認為數組名就是指針,其實不然,二者有很多區別的,要知詳情,請看《c專家編程》。
  • C/C++常見面試題整理
    1、C++裡面如何聲明const void f(void)函數為C程序中的庫函數?2、c++中類和c語言中struct的區別(至少兩點)3、變量的聲明和定義有什麼區別?4、memset ,memcpy 的區別5、程序什麼時候應該使用線程,什麼時候單線程效率高。6、介紹一下模板和容器。如何實現?
  • GDB與Valgrind ,調試C++代碼內存的工具
    ,常常被 C++的內存問題攪的焦頭爛額。1、利用 GDB 調試 CoreDumpCoreDump時一個二進位的文件,進程發生錯誤崩潰時,內核會產生一個瞬時的快照,記錄該進程的內存、運行堆棧狀態等信息保存在core文件之中。做個簡單的類比,core 文件相當於飛機運行時的"黑匣子",能夠幫助我們更好的調試 C++程序的問題。
  • C 語言會比 C++ 快?
    和面向過程的 C 語言相比,其繼承者 C++ 不僅可以進行 C 語言的過程化程序設計,還可以進行以繼承和多態為特點的面向對象的程序設計。要論兩者上手的難易度,對此,有網友評價道,學好 C 只要 1 年,而學好 C++ 需要的可能不止 10 年。
  • c++ 內存,虛函數,運算函數,三角函數
    C++ 動態內存棧:在函數內部聲明的所有變量都將佔用棧內存。堆:這是程序中未使用的內存,在程序運行時可用於動態分配內存。很多時候,您無法提前預知需要多少內存來存儲某個定義變量中的特定信息,所需內存的大小需要在運行時才能確定。
  • c++11新特性,所有知識點都在這了!
    本文基本上涵蓋了c++11的所有新特性,並有詳細代碼介紹其用法,對關鍵知識點做了深入分析,對重要的知識點我單獨寫了相關文章並附上了相關連結,我整理了完備的c++新特性腦圖(由於圖片太大,我沒有放在文章裡,同學可以在後臺回復消息「新特性」,即可下載完整圖片)。
  • 面試:C/C++常見庫函數實現
    ,memcpy函數的功能是從源src所指的內存地址的起始位置開始拷貝n個字節到目標dest所指的內存地址的起始位置中void* memcpy(void* dest,void* src,size_t n){    assert(dest !
  • 一文帶你了解c++和c中字符串的使用
    說完了c,那麼對於我們的c++來說,它定義字符串就簡單多了,因為有關鍵字來定義,你一看就知道。那麼下面大家就隨著我的筆步一起來看看究竟吧!有可能有些網友還沒怎麼接觸到c++(c++它是一門面向對象的語言,而c是一門面向過程的語言,所以這裡可能沒接觸過那個面向對象的網友不習慣這個用法,不過還是建議至少要掌握一門面向對象的語言,在這個發展快速的時代,不能太固步自封了(我這裡也是簡單的介紹一下c++中的字符串,不會設計到類和對象什麼的,只是和c語言做個對比)。)1、什麼是字符串?
  • 使用 mtrace 分析 「內存洩露」
    1 內存洩漏導論在工作中,特別是採用 C 語言編寫程序時,動態內存分配是常有的事,而伴隨動態內存分配而來的最大的問題就是所謂 「內存洩漏」。mtrace 工具的主要思路是在我們的調用內存分配和釋放的函數中裝載 「鉤子(hook)」 函數,通過 「鉤子(hook)」 函數列印的日誌來幫助我們分析對內存的使用是否存在問題。