近期Imgsrc一處內存泄露問題的查找和解決

最近一直在查我們的imgsrc的內存泄露問題,事實上都是其所使用的ImageMagick庫的bug。前些天又查了一個bug,涉及面較廣,覺得有必要總結一下。

簡要說明一下,imgsrc上部署的是apache模塊,cdn通過其來訪問tfs,並且做一些圖像處理工作。有內存泄露是在線上發現的,內存不停的在漲。要找到問題所在,首先需要能夠在線下重現,知道在什麼情況下會泄露。線上系統當然不可能用valgrind來跑啦,還好我們有tcpcopy(贊一下網易的 @wangbin579 同學,真是個好東西),我們可以將線上流量鏡像到跑着valgrind的機器上來,從而重現問題。跑了一晚上之後問題重現了,這時候需要做的是找到具體能觸發問題的http請求。在訪問日誌和錯誤日誌的幫助下,可以重放這些請求,這樣就可以隨時重現。一個晚上的訪問日誌有80多萬條之多,我注意到其中有43條是在做圖像處理時失敗的。這些請求的原圖往往都是一些不合法的或已損壞的圖。先從這43個請求入手,運氣不錯,問題已經重現了。這告訴我們多注意一下不法分子總是好的。接下來就是使用2分法來找到具體的某一個訪問,可以用腳本來幹。最終確定是一個png圖片。

經常用valgrind的人肯定知道,有時候打印出來的堆棧是不全的,會有一些是???。這次我也遇到了同樣的問題,有人說用addr2line可以看到,試了一下無果。就上網查了下valgrind打印堆棧不全的原因,在這裏有描述:http://valgrind.org/docs/manual/faq.html#faq.unhelpful

如果檢查的程序是共享庫,如果這個共享庫在程序退出前被unload,那麼valgrind會把它的debug信息給拋棄,導致調用堆棧那裏會變成???的記錄。解決方法是不調用dlclose。

首先是嘗試將httpd源碼apr庫中的dso模塊調用dlclose的地方註釋掉,無果。想到這些堆棧還是有打出來的,之前沒打出來的是ImageMagick的編解碼庫的堆棧,因此在ImageMagick的代碼中搜了一下,果然有調用dlclose的地方。這個還不好改,嘗試了幾種方法(它有一個平臺獨立的ltdl庫,由於調用dlclose的地方太多,我首先想到將dlclose函數給替換成一個空函數)都無果,甚至還導致進程不能正常啓動。後來想到應該使改動儘可能小,就用SystemTap查看了一下kill httpd的過程中IamgeMagick調了dlclose的地方,然後將這兩個地方的dlclose給註釋掉,這樣valgrind就打全調用堆棧了。

現在可以看到是具體哪個資源沒有釋放了,但是還不知道在什麼情況下這個資源沒有釋放。

經過初步調試,可以斷定問題出在循環讀圖片的行列像素這個過程中,現在的問題在於到底是這個過程中哪個函數、何時(哪次循環)出來的?首先得確定何時出來,然後在那個點調試就能知道是哪個函數出來的了。由於循環次數很多(800次),通過修改代碼,在每次循環時都打一行日誌到一個文件裏頭,這樣就找到了出問題的循環點。然後在gdb中直接跳過正常循環,在那次循環過程中單步,確定了出問題的函數:這是一個libpng庫裏頭的函數png_read_row,在執行完它之後就跟蹤不了了。首先想到的是這個函數是否拋異常了,google了一下搜到一個我朝網友向ImageMagick反應的讀png圖時內存泄露的問題(http://www.imagemagick.org/discourse-server/viewtopic.php?f=3&t=20522),這哥們看來和我一樣苦逼,不過這個哥們比我牛逼一點,知道png_read_row之後會跑到什麼地方執行。事實上,他所反映的這個問題和我遇到的是同一個問題,不過是泄露的資源不同,ImageMagick的開發者們也不吸取教訓。

ImageMagick(事實上是因爲libpng這麼要求)這裏是使用setjmp/longjmp的機制來在讀到損壞的png圖片時釋放資源的。順便普及一下setjmp/longjmp機制:

這是C函數庫提供用來全局跳轉用的(goto只能函數內跳轉),通常用於錯誤處理。setjmp函數用來設置一個跳轉點,之後調用longjmp就能從其他函數跳轉到剛剛調用setjmp的地方。setjmp函數有兩種返回值,直接調用時會返回0,如果是從longjmp返回的則會返回非0。在實現上,setjmp會將當前棧的上下文信息保存在其參數(一個jmp_buf數據結構)中,然後longjmp會恢復指定的jmp_buf所保存的棧上下文信息,從剛剛調用setjmp的地方繼續執行。注意,如果調用setjmp的函數返回了,那麼保存的棧上下文信息就失效了。

ImageMagick在解碼png前調用了setjmp(png_jmpbuf(ping)),這個png_jmpbuf應該是libpng庫裏面的一個全局變量。因此,可以推測libpng在其函數png_read_row中,如果遇到解碼失敗的情況,會調用longjmp(png_jmpbuf, 1),以便庫的使用者進行錯誤處理。

我在setjmp返回非0的代碼中設了個斷點,果然進來了,調試發現雖然代碼裏面有釋放資源的代碼,但是並沒有執行。懷疑是gcc O2優化的問題,修改ImageMagick的優化級別(改成O0),內存泄露就沒有了。再仔細看了下代碼,就發現問題了,gcc把這行釋放資源的代碼給優化掉了。代碼如下:

  1. unsigned char
  2.     *ping_pixels;
  3.   ping_pixels=(unsigned char *) NULL;

  4.   if (setjmp(png_jmpbuf(ping)))
  5.     {
  6.       /*
  7.         PNG image is corrupt.
  8.       */
  9.       png_destroy_read_struct(&ping,&ping_info,&end_info);

  10. #ifdef PNG_SETJMP_NOT_THREAD_SAFE
  11.       UnlockSemaphoreInfo(ping_semaphore);
  12. #endif

  13.       if (ping_pixels != (unsigned char *) NULL)
  14.         ping_pixels=(unsigned char *) RelinquishMagickMemory(ping_pixels);

這裏ping_pixels就是引起泄露的資源,是在這之後的代碼中分配的,因此一開始初始化爲NULL,作者原意是要在之後如果通過longjmp回來之後對其進行釋放。關鍵就在這裏,longjmp返回之後,ping_pixels的值是多少?查看了相關資料,並且通過做簡單的小實驗之後總結如下:

放在內存中的變量,在longjmp返回時,仍然是調用longjmp這時候的值;而如果是放在寄存器中的變量,通過longjmp返回的時候,它的值會恢復成原來setjmp的時候的值。

這在《UNIX環境高級編程》一書中講得非常清楚。

因此,這裏我目前的解決方法是將ping_pixels變量的值變成volatile,這樣gcc在優化時就不會將其放到寄存器中,也就不會在longjmp返回的時候恢復其爲NULL。這裏注意應該使用

unsigned char  * volatile ping_pixels,而不是 volatile unsigned char* ping_pixels。

經過驗證,這樣修改之後,這個內存泄露就修復了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章