linux下定位多線程內存越界問題實踐總結

最近定位了在一個多線程服務器程序(OceanBase MergeServer)中,一個線程非法篡改另外一個線程的內存而致使程序core掉的問題。定位這個問題歷經曲折,嘗試了各類內存調試的辦法。每每感受就要柳暗花明了,卻發現又進入了另外一個死衚衕。最後,使用強大的mprotect+backtrace+libsigsegv等工具成功定位了問題。整個定位過程遇到的問題和解決辦法對於多線程內存越界問題都很典型,簡單總結一下和你們分享。linux

現象

core是在系統集成測試過程當中發現的。服務器程序MergeServer有一個50個工做線程組成的線程池,當使用8個線程的測試程序經過MergeServer讀取數據時,後者偶爾會core掉。用gdb查看core文件,發現core的緣由是一個指針的地址非法,當進程訪問指針指向的地址時引發了段錯誤(segment fault)。見下圖。golang

linux下定位多線程內存越界問題實踐總結

發生越界的指針ptr_位於一個叫作cname_的對象中,而這個對象是一個動態數組field_columns_的第10個元素的成員。以下圖。數組

linux下定位多線程內存越界問題實踐總結

復現問題

以後,花了2天的時間,終於找到了重現問題的方法。重現屢次,能夠觀察到以下一些現象:緩存

  1. 隨着客戶端併發數的加大(從8個線程到16個線程),出core的機率加大;
  2. 減小服務器端線程池中的線程數(從50個到2個),就不能復現core了。
  3. 被篡改的那個指針,老是有一半(高4字節)被改成了0,而另外一半看起來彷佛是正確的。
  4. 請看前一節,重現屢次,每次出core,都是由於field_columns_這個動態數組的第10個元素data_[9]的cname_成員的ptr_成員被篡改。這是一個很差解釋的奇怪現象。
  5. 在代碼中插入檢查點,從field_columns_中內容最初產生到讀取致使越界的這段代碼序列中「埋點」,既使用二分查找法定位篡改cname_的代碼位置。結果發現,程序有時core到檢查點前,有時又core到檢查點後。

綜合以上現象,初步判斷這是一個多線程程序中內存越界的問題。安全

使用glibc的MALLOC_CHECK_

由於是一個內存問題,考慮使用一些內存調試工具來定位問題。由於OB內部對於內存塊有本身的緩存,須要去除它的影響。修改OB內存分配器,讓它每次都直接調用c庫的malloc和free等,不作緩存。而後,可使用glibc內置的內存塊完整性檢查功能。服務器

使用這一特性,程序無需從新編譯,只須要在運行的時候設置環境變量MALLOC_CHECK_(注意結尾的下劃線)。每當在程序運行過程free內存給glibc時,glibc會檢查其隱藏的元數據的完整性,若是發現錯誤就會當即abort。用相似下面的命令行啓動server程序:數據結構

export MALLOC_CHECK_=2
bin/mergeserver -z 45447 -r 10.232.36.183:45401 -p45441

使用MALLOC_CHECK_之後,程序core到了不一樣的位置,是在調用free時,glibc檢查內存塊前面的校驗頭錯誤而abort掉了。以下圖。多線程

linux下定位多線程內存越界問題實踐總結

但這個core能帶給咱們想信息也不多。咱們只是找到了另一種稍高效地重現問題的方法而已。或許最初看到的core的現象是延後顯現而已,其實「更早」的時刻內存就被破壞掉了。架構

valgrind

glibc提供的MALLOC_CHECK_功能太簡單了,有沒有更高級點的工具不光可以報告錯誤,還能分析出問題緣由來?咱們天然想到了大名鼎鼎的valgrind。用valgrind來檢查內存問題,程序也不須要從新編譯,只須要使用valgrind來啓動:併發

nohup valgrind --error-limit=no --suppressions=suppress bin/mergeserver -z 45447 -r 10.232.36.183:45401 -p45441 >nohup.out &

默認狀況下,當valgrind發現了1000中不一樣的錯誤,或者總數超過1000萬次錯誤後,會中止報告錯誤。加了--error-limit=no之後能夠禁止這一特性。--suppressions用來屏蔽掉一些不關心的誤報的問題。通過一翻折騰,用valgrind復現不了core的問題。valgrind報出的錯誤也都是一些與問題無關的誤報。大概是由於valgrind運行程序大約會使程序性能慢10倍以上,這會影響多線程程序運行時的時序,致使core不能復現。此路不通。

須要C/C++ Linux高級服務器架構師學習資料後臺加羣812855908(包括C/C++,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等)

linux下定位多線程內存越界問題實踐總結

magic number

既然MALLOC_CHECK_能夠檢測到程序的內存問題,咱們其實想知道的是誰(哪段代碼)越了界。此時,咱們想到了使用magic number填充來標示數據結構的方法。若是咱們在被越界的內存中看到了某個magic number,就知道是哪段代碼的問題了。

首先,修改對於malloc的封裝函數,把返回給用戶的內存塊填充爲特殊的值(這裏爲0xEF),而且在開始和結束部分各多申請24字節,也填充爲特殊值(起始0xBA,結尾0xDC)。另外,咱們把預留內存塊頭部的第二個8字節用來存儲當前線程的ID,這樣一旦觀察到被越界,咱們能夠據此斷定是哪一個線程越的界。代碼示例以下。

linux下定位多線程內存越界問題實踐總結

而後,在用戶程序經過咱們的free入口釋放內存時,對咱們填充到邊界的magic number進行檢查。同時調用mprobe強制glibc對內存塊進行完整性檢查。

linux下定位多線程內存越界問題實踐總結

最後,給程序中全部被懷疑的關鍵數據結構加上magic number,以便在調試器中檢查內存時能識別出來。例如

linux下定位多線程內存越界問題實踐總結

好了,都加好了。用MALLOC_CHECK_的方式從新運行。程序如咱們所願又core掉了,檢查被越界位置的內存:

linux下定位多線程內存越界問題實踐總結

如上圖,紅色部分是咱們本身填充的越界檢查頭部,能夠看到它沒有被破壞。其中第二行存儲的線程號通過確認確實等於咱們當前線程的線程號。

藍色部分爲前一個動態內存分配的結尾,也是完整的(24個字節0xdc)。0x44afb60和0x44afb68兩行所示的內存爲glibc malloc存儲自身元數據的地方,程序core掉的緣由是它檢查這兩行內容的完整性時發現了錯誤。由此推斷,被非法篡改的內容小於16個字節。仔細觀察這16字節的內容,咱們沒有看到熟悉的magic number,也就沒法推知有bug的代碼是哪塊。這和咱們最初發現的core的現象相互印證,極可能被非法修改的內容僅爲4個字節(int32_t大小)。

另外,雖然咱們加寬了檢查邊界,程序仍是會core到glibc malloc的元數據處,而不是咱們添加的邊界裏。並且,咱們總能夠觀察到前一塊內存(圖中藍色所示)的結尾時完整的,沒被破壞。這說明,這不是簡單的內存訪問超出邊界致使的越界。咱們能夠大膽的作一下猜想:要麼是一塊已經釋放的內存被非法重用了;要麼這是經過野指針「空投」過來的一次內存修改。

若是咱們的猜想是正確的,那麼咱們用這種添加內存邊界的方式檢查內存問題的方法幾乎必然是無效的。

打怪利器electric-fence

至此,咱們知道某個時間段內某個變量的內存被其餘線程非法修改了,可是卻沒法定位到是哪一個線程哪段代碼。這就比如你明明知道將來某個時間段在某個地點會發生兇案,卻沒辦法看到兇手。無比鬱悶。

有沒有辦法能檢測到一個內存地址被非法寫入呢?有。又一個大名鼎鼎的內存調試庫electric-fence(簡稱efence)就華麗登場了。

使用MALLOC_CHECK_或者magic number的方式檢測的最大問題是,這種檢查是「過後」的。在多線程的複雜環境中,若是不能發生破壞的第一時間檢查現場,每每已經不能發現罪魁禍首的蛛絲馬跡了。

electric-fence利用底層硬件(CPU提供的虛擬內存管理)提供的機制,對內存區域進行保護。實際上它就是使用了下一節咱們要本身編碼使用的mprotect系統調用。當被保護的內存被修改時,程序會當即core掉,經過檢查core文件的backtrace,就容易定位到問題代碼。

這個庫的版本有點混亂,容易弄錯。搜索和下載這個庫時,我才發現,electric-fence的做者也是大名鼎鼎的busybox的做者,牛人一枚。可是,這個版本在linux上編譯鏈接到個人程序的時候會報WARNING,並且後面執行的時候也會出錯。後來,找到了debian提供的一個更高版本的庫,估計是社區針對linux作了改進。

使用efence須要從新編譯程序。efence編譯後提供了一個靜態庫libefence.a,它包含了可以替代glibc的malloc, free等庫函數的一組實現。編譯時須要一些技巧。首先,要把-lefence放到編譯命令行其餘庫以前;其次,用-umalloc強制g++從libefence中查找malloc等原本在glibc中包含的庫函數:

g++ -umalloc –lefence …

用strings來檢查產生的程序是否真的使用了efence:

linux下定位多線程內存越界問題實踐總結

和不少工具相似,efence也經過設置環境變量來修改它運行時的行爲。一般,efence在每一個內存塊的結尾放置一個不可訪問的頁,當程序越界訪問內存塊後面的內存時,就會被檢測到。若是設置EF_PROTECT_BELOW=1,則是在內存塊前插入一個不可訪問的頁。一般狀況下,efence只檢測被分配出去的內存塊,一個塊被分配出去後free之後會緩存下來,直到一下次分配出去纔會再次被檢測。而若是設置了EF_PROTECT_FREE=1,全部被free的內存都不會被再次分配出去,efence會檢測這些被釋放的內存是否被非法使用(這正是咱們目前懷疑的地方)。但由於不重用內存,內存可能會膨脹地很厲害。

我使用上面2個標記的4種組合運行咱們的程序,遺憾的是,問題沒法復現,efence沒有報錯。另外,當EF_PROTECT_FREE=1時,運行一段時間後,MergeServer的虛擬內存很快膨脹到140多G,致使沒法繼續測試下去。又進入了一個死衚衕。

終極神器mprotect + backtrace + libsigsegv

electric-fence的神奇能力其實是使用系統調用mprotect實現的。mprotect的原型很簡單,

int mprotect(const void *addr, size_t len, int prot);

mprotect可使得[addr,addr+len-1]這段內存變成不可讀寫,只讀,可讀寫等模式,若是發生了非法訪問,程序會收到段錯誤信號SIGSEGV。

但mprotect有一個很強的限制,要求addr是頁對齊的,不然系統調用返回錯誤EINVAL。這個限制和操做系統內核的頁管理機制相關。

linux下定位多線程內存越界問題實踐總結

如圖,咱們已經知道這個動態數組的第10個元素會被非法越界修改。review了代碼,發現從這個數組內容初始化完畢之後,到使用這個數組內容這段時間,不該該再有修改操做。那麼,咱們就能夠在數組內容被初始化以後,當即調用mprotect對其進行只讀保護。

嘗試一

由於mprotect要求輸入的內存地址頁對齊,因此我修改了動態數組的實現,每次申請內存塊的時候多分配一個頁大小,而後取頁對齊的地址爲第一個元素的起始位置。

linux下定位多線程內存越界問題實踐總結

如上圖,淺藍色部分爲爲了對齊內存地址而作的padding。代碼見下

linux下定位多線程內存越界問題實踐總結

動態數組申請的最小內存塊的大小爲64KB。這裏,動態數組中每一個元素的大小爲80字節,咱們只須要從第1個元素開始保護一個頁的大小便可:

linux下定位多線程內存越界問題實踐總結

既然這個保護區域是程序中自動插入的,須要在內存釋放給系統前回復它爲可讀寫,不然必然會因mprotect產生段錯誤。

linux下定位多線程內存越界問題實踐總結

好了,編譯、重啓、運行重現腳本。悲劇了。程序運行了好久都再也不出core了,沒法復現問題。咱們在分配動態數組內存時,爲了對齊在內存塊前添加的padding致使程序運行時的內存分佈和原來產生core的運行環境不一樣了。這多是沒法復現的緣由。要想復現,咱們不能破壞原來的內存分配方式。

嘗試二

不改變更態數組的內存塊申請方式,又要知足mprotect保護的地址必須頁對齊的要求,怎麼作呢?咱們換一個思路,從第10個元素向前,找到包含它且離它最近的頁對齊的內存地址。以下圖

linux下定位多線程內存越界問題實踐總結

但這樣會形成一個問題。圖中淺藍色部分本不是這個動態數組對象所擁有的內存,它可能被其餘任何線程的任何數據結構在使用。咱們使用這種方式保護紅色區域,會有不少無關的落入藍色區域的修改操做致使mprotect產生段錯誤。

實驗了一下,果真,程序跑起來不久就在其餘無關的代碼處產生了段錯誤。這種保護方式的代碼以下:

linux下定位多線程內存越界問題實踐總結

成功

在上一節的保護方式下,咱們由於保護了無關內存區域,會致使程序過早產生SIGSEGV而退出。咱們可否截獲信號,不讓程序在非法訪問mprotect保護區域後仍然能繼續執行呢?固然。咱們能夠定製一個SIGSEGV段錯誤信號的處理函數。在這個處理函數中,若是能打印段錯誤時候的當前調用棧,就能夠找到罪魁禍首了。

linux下定位多線程內存越界問題實踐總結

代碼如上圖。注意,處理SIGSEGV的handler函數有一些小技巧(坑不少):

  1. SIGSEGV通常是內核處理的(page fault)。使用庫libsigsegv能夠簡化用戶空間撰寫處理函數的難度。
  2. 處理函數中,不能調用任何可能再分配內存的函數,不然會引發double fault。例如,在這段處理函數中,使用open系統調用打開文件,不能使用fopen;buff是從棧上分配的,不能從heap上申請;不能使用backtrace_symbols,它會向glibc動態申請內存,而要使用安全的backtrace_symbols_fd把backtrace直接寫入文件。
  3. 最重要的,在SIGSEGV的處理函數中,咱們須要恢復引發段錯誤的內存塊爲可讀寫的。這樣,當處理函數返回被中斷的代碼繼續執行時,纔不能再次引發段錯誤。從新編譯代碼,運行重現腳本。查看記錄了backtrace的文件sigsegv.bt,咱們看到了熟悉的被篡改的指針地址(一半爲0):

linux下定位多線程內存越界問題實踐總結

這個段錯誤會最終致使程序core掉,由於這個SIGSEGV信號不是由咱們使用mprotect的保護而產生的。查看core文件,能夠查到被越界的內存(即ptr_)的地址。從sigsegv.bt文件中查找,果真找到了那一次非法訪問:

linux下定位多線程內存越界問題實踐總結

使用addr2line檢查上面這個調用棧中的地址,咱們終於找到了它。又通過一番代碼review和驗證,才總算肯定了錯誤緣由。有一個動態new出來的對象的指針在兩個有關聯的線程中共享,在某種極端狀況下,其中一個delete了對象以後,另外一個線程又修改了這個對象。

小結

小結一下,遇到棘手的內存越界問題,可使用下面順序逐個嘗試:

  1. code review分析代碼。
  2. valgrind用起來最簡單,幾乎是傻瓜式的。能用盡可能用。
  3. glibc的MALLOC_CHECK_使用起來和很簡單,不須要重現編譯代碼。能夠用來發現問題,可是其自己沒法定位問題。和magic number結合起來,能夠用來定位一類內存越界的問題。
  4. 和electric-fence齊名的還有一個內存調試庫叫作dmalloc。雖然在本次解決問題的過程當中沒有用到,這個庫對於檢測內存泄露等其餘問題頗有用。推薦你們學習一下,放到本身的工具庫中。
  5. electric-fence是定位一類「野指針」訪問問題的利器,強烈推薦使用。
  6. 若是上述全部工具都幫不了你,那麼只好在熟悉代碼邏輯的基礎上,使用終極武器了。
  7. code review。經過嘗試代碼庫中不一樣版本編譯出來的程序復現bug,用二分法定位引入bug的最先的一次代碼提交。
相關文章
相關標籤/搜索