在《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.回復關鍵詞「移動測試」查看移動測試系列工具點評