利用程序資訊追查記憶體洩漏
一、問題現象
Bigpipe是Baidu公司內部的分散式傳輸系統,其伺服器模組Broker採用非同步程式設計框架來實現,並大量使用了引用計數來管理物件資源的生命週期和釋放時機。在對Broker模組進行壓力測試過程中,發現Broker長時間執行後,記憶體佔用逐步變大,出現了記憶體洩漏問題。
二、初步分析
針對近期Broker的升級改造點,確定Broker中可能出現記憶體洩漏的物件。Broker新增了監控功能,其中一項是對伺服器各個引數的監控統計,這必然對引數物件有讀取操作,每次操作都將引用計數“加一”,並在完成操作後“減一”。當前,引數物件有數個,需要確定是哪個引數物件洩漏了。
三、程式碼&業務分析
1. 為證明之前的初步分析的結果,可能的方法有是:使用Valgrind執行Broker並啟動壓力程式復現可能的記憶體洩漏。但是,使用這種方法:
1) 由於記憶體洩漏的觸發條件並不簡單,可能導致復現週期很長,甚至無法復現同樣的記憶體洩漏;
2) 記憶體洩漏的物件放置在容器中,valgrind正常退出後不報告相關的記憶體洩漏;
經過另外的測試叢集短時間的執行嘗試進行復現,果然Valgrind報告未出現異常。
2. 分析現有擁有的條件:幸好,出現“記憶體洩漏”問題的Broker程序仍然在執行中,真相就在這個程序內部。應該充分利用已有的現場,完成問題的定位。初步希望使用GDB除錯。
3. 挑戰:使用GDB attach pid的方法將會導致程序掛起,按Broker的設計,一當配對另一個主/從Broker不互相傳送心跳, Broker也將自動退出程式,退出後現場就無法儲存,這意味著使用GDB的機會只有一次。
4. 方案:利用gdb列印記憶體資訊並從資訊中觀察可能的記憶體洩漏點。
5. 步驟一:pmap -x {PID}檢視記憶體資訊(如:pmap -x 24671);得到類似如下資訊,注意標記為anon的位置:
6. 步驟二:啟動gdb ./bin/broker並使用 attach {PID}命令載入現有程序;例如上述程序號為24671,則使用:attach 24671
7. 步驟三:使用setheight 0和 setlogging on開啟gdb日誌,日誌將儲存於gdb.txt檔案中;
8. 步驟四:使用x/{記憶體位元組數}a {記憶體地址} 打印出一段記憶體資訊,例如上述的anon為堆頭地址,佔用了144508kb記憶體,則使用:x/18497024a0x000000000109d000;若命令列較多,可以在外圍編輯好命令列直接張貼至gdb命令列提示符中執行,或者將命令列寫到一個文字檔案中,例如command.txt中,然後再gdb命令列提示符中使用 sourcecommand.txt來執行檔案中的命令集合,下面是command.txt檔案的內容;
9. 步驟五:分析gdb.txt檔案中的資訊,gdb.txt中的內容如下:
Gdb.txt中內容的說明和分析:第一列為當前記憶體地址,如0x22c2f00;第二、三、四列分別為當前記憶體地址對應所儲存的值(使用十六進位制表示),以及gdb的debug的符號資訊,例如:0x10200d0<_ZTVN7bigpipe15BigpipeDIEngineE+16> 0x4600000001,分別表示:“前16位元組”、“符號資訊(注意有+16的偏移)”、“後16位元組”,但不是所有地址都會列印gdb的debug符號資訊,有時符號資訊顯示在第三列,有時顯示在第二列。上述這行記憶體地址0x22c2f00 儲存了bigpipe::BigpipeDiEngine 類的生成的其中一個物件的虛解構函式的函式指標,即虛擬函式表指標(vptr),其中地址0x10200d0附近記憶體儲存的應該是BigpipeDiEngine類的虛擬函式表(vtbl),如下所示:
地址0x10200d0中的值是指向BigpipeDiEngine類的解構函式的地址,即真正的解構函式程式碼段頭地址0x53e2c6。可以從上述執行結果看到,地址0x53e2c6的“符號資訊”是解構函式名<bigpipe::BigpipeDIEngine::~BigpipeDIEngine()>,其彙編命令為push。因此,可以知道最初看到的0x22c2f00地址是物件的一個虛解構函式指標,並且有“符號資訊”BigpipeDIEngine顯示出來,可以根據這種資訊確定出這個類(帶虛解構函式的類)生成了多少個例項,然後根據排出來的例項個數做進一步判斷。
因此,對gdb.txt排序並做適當處理獲得符號(類名/函式名稱)出現的次數的列表。例如將上述內容過濾出帶尖括號的“符號資訊”部分並按出現次數排序,可以使用類似如下命令,catgdb.txt |grep "<"|awk -F '<' '{print $2}' |awk -F '>''{print $1}' |sort |uniq -c|sort -rn > result.txt,過濾出專案相關的變數字首(如bmq、Bigpipe、bmeta等)cat result.txt|grep -P"bmq|Bigpipe|bigpipe|bmeta"|grep "_ZTV" > result2.txt,獲得類似如下的列表:
10. 然後找出和本工程專案相關的且出現次數最多的為CConnect物件;判斷出可能洩漏的物件後,還需要定位在非同步框架下,哪個引用計數出現了問題導致CConnect物件無法正常減一併得到釋放。
11. 經過追查新增的“監控”功能與CConnect相關的程式碼,如下。
四、真相大白
檢視atomic_add函式的實現(如下),可以得知,返回值是自增(減)之前的值,而由於函式名稱atomic_add並未特別的表現出這樣的含義,導致呼叫者誤用了這個函式,認為是自增之後的值,最終引用計數誤認為不為0,導致未執行_free操作,進而導致記憶體洩漏。通常,和__sync_fetch_and_add對應的函式還有__sync_add _and_fetch,這兩者的區別在於“先獲得值再加”還是“先加值在獲取”。
五、解決方案
因此,程式的改進如下:
六、總結
1. 由於非同步框架實現的程式對問題定位跟蹤難度較高,需要綜合:日誌,gdb,pmap等手段完成問題復現和定位;
2. Valgrind檢測記憶體洩漏並不是唯一的方法,且具有一定的侷限性;
3. 函式名稱定義儘量直觀表明函式功能,能夠避免呼叫方的一部分錯誤;
4. 應當仔細閱讀庫函式的說明文件,瞭解使用方法;
5. 本方法運用的場景和侷限:1)使用gdb列印記憶體資訊中,必須符合例項數和記憶體資訊符號有一對一關係的情形,上述實踐中CConnect類有虛解構函式,因此在記憶體資訊中能檢視到虛擬函式表指標,且和出現的符號有一一對應的關係,由此能作為記憶體洩漏存在於此類的推測條件;若洩漏的記憶體在記憶體資訊中沒有留下“痕跡”則無法獲得記憶體洩漏的有效資訊;2)線上下嘗試記憶體洩漏復現失敗後,但有記憶體洩漏的程序(現場)在線上仍然存在,可以嘗試使用上述方法,從已有的程序(現場)中更多獲取記憶體洩漏資訊;3)此方法可以利用現有的已經產生記憶體洩漏的程序(現場)進行分析,充分利用了已有的問題程序;4)上述方法作為其他記憶體洩漏除錯方法的一種補充,一種值得嘗試的方法,可以作為參考。