1. 程式人生 > >記憶體釋放時的死鎖bug導致lock_wait

記憶體釋放時的死鎖bug導致lock_wait

http://www.chawenti.com/articles/6761.html

通常我們認為一旦記憶體寫溢位,程式就很容易崩潰。所以伺服器上通常會對一些重要程序做指令碼保護,一旦崩潰立即重新拉起。
  最近發現我們一個公共服務記憶體寫溢位時程式沒有崩潰,而是卡死了。
  為了深入分析原因,我們仔細review了glibc的程式碼,並發現一個較為隱蔽的bug。
    
  先來看這個卡死的程式堆疊(64位環境,下同): #0 0x00002b059302ac38 in __lll_mutex_lock_wait () from /lib64/libc.so.6
#1 0x00002b0592fcee5f in _L_lock_4026 () from /lib64/libc.so.6
#2 0x00002b0592fcbdf1 in free () from /lib64/libc.so.6
#3 0x00002b0592fe4148 in tzset_internal () from /lib64/libc.so.6
#4 0x00002b0592fe49d0 in tzset () from /lib64/libc.so.6
#5 0x00002b0592fe8e44 in strftime_l () from /lib64/libc.so.6
#6 0x00002b059301c701 in __vsyslog_chk () from /lib64/libc.so.6
#7 0x00002b0592fc56d0 in __libc_message () from /lib64/libc.so.6
#8 0x00002b0592fca77e in malloc_printerr () from /lib64/libc.so.6
#9 0x00002b0592fcbdfc in free () from /lib64/libc.so.6
#10 0x00002b05929ed657 in deflateEnd () from /lib64/libz.so.1
#11 0x00000000004884b8 in CHttpResp::GetOutput (this=0x2b059dd414f8,
#12 ……

  可以看到在free函式中使用了鎖。

  那麼再來看看glibc中free函式的主要程式碼: #define public_fREe free
void public_fREe(Void_t* mem)
{
mchunkptr p = mem2chunk(mem);
mstate ar_ptr = arena_for_chunk(p);

……

(void)mutex_lock(&ar_ptr->mutex);
_int_free(ar_ptr, mem);
(void)mutex_unlock(&ar_ptr->mutex);
}

  這段程式碼相當簡單,不用過多解釋。

  再對比上面的堆疊,可以推測流程大概是這樣的:frame 9釋放記憶體時發現記憶體資料校驗有誤所以進行出錯輸出,當寫syslog時需要取本地時間,而在取時區資訊的函式裡面也有free函式呼叫,所以到frame 2釋放記憶體想要再次獲取鎖的時候程式死鎖了。
  這應該屬於glibc的bug了,雖然這個bug首先要由程式設計師的bug來觸發。   為了進一步確認glibc的這個問題,我們繼續深入閱讀glibc的程式碼以重現之。
  首先,為什麼記憶體寫越界會導致free出錯?解答這個問題前我們先簡單說說一些相關的malloc分配記憶體原理。
  跟一些人想象不同的是,並不是每次malloc呼叫一定導致記憶體分配,因為當記憶體釋放時glibc會將記憶體先保留到空閒隊列當中,下次有malloc呼叫時可以找一個合適的記憶體塊直接返回,這樣就避免了真正從系統分配記憶體的系統呼叫開銷。glibc需要管理這些空閒記憶體塊,那麼就需要一個相應的資料結構,這個資料結構定義如下: struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links — used only if free. */
struct malloc_chunk* bk;
/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links — used only if free. */
struct malloc_chunk* bk_nextsize;
};

  對映到記憶體示意圖上如下圖所示:

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <–真正的chunk首指標
| prev_size, 前一個chunk的大小 | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| size, 低位作標誌位,高位存放chunk的大小 |M|P|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <–malloc成功返回的首指標
| 正常時存放使用者資料; .
. 空閒時存放malloc_chunk結構後續成員變數。 .
. .
. |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <–下一個chunk的首指標
| prev_size …… |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

  可以看到,我們每次malloc返回的指標並不是記憶體塊的首指標,前面還有兩個size_t大小的引數,對於非空閒記憶體而言size引數最為重要。size引數存放著整個chunk的大小,由於實體記憶體的分配是要做位元組對齊的,所以size引數的低位用不上,便作為flag使用。

  記憶體寫溢位,通常就是把後一個chunk的size引數寫壞了。
  size被寫壞,有兩種結果。一種是free函式能檢查出這個錯誤,程式就會先輸出一些錯誤資訊然後abort;一種是free函式無法檢查出這個錯誤,程式便往往會直接crash。
  根據最上面的堆疊推測,誘發bug的是前一種情況。我們的測試程式將會直接分配兩塊記憶體,並對第二塊記憶體chunk的size引數做簡單修改,以觸發情況一。
  順便說一句,windows記憶體分配跟linux比較類似,也是將記憶體塊大小存放在malloc返回的指標位置之前。DEBUG模式下,編譯器還會在實際分配記憶體的兩端放兩個特殊值,這樣在記憶體回收時就可以檢測到記憶體寫溢位的問題。
    
  其次,當free函式檢查到size異常以後,會呼叫malloc_printerr輸出一些錯誤資訊,但它並不一定會寫syslog。
  檢視__libc_message的程式碼可以發現,出現錯誤以後,glibc會先嚐試將錯誤資訊寫入到stderr或tty,如果寫入失敗,才會去寫syslog(程式碼有點囉嗦就不貼了)。
  要模擬這個情況,只需將環境變數”LIBC_FATAL_STDERR_”設為1迫使出錯時寫stderr,然後將stderr關閉即可。通常daemon程式很容易處在這樣的狀態。
    
  再次,檢視tzset_internal的程式碼,我們發現導致free操作的原因是靜態變數static char* old_tz釋放導致的。
  old_tz存放了上一次呼叫tzset_internal時使用的時區字串。如果再次呼叫tzset_internal時,時區不變就複用;如果不同,則free掉舊的字串,strdup新的字串,而strdup裡面malloc了新字串所需的記憶體塊。
  要模擬這個情況只需先設法給old_tz一個初值,然後再做記憶體釋放觸發free(old_tz)即可。要給old_tz設初值只需先呼叫相關的時間函式即可,例如localtime這個函式經常就被用到,old_tz會初始化為預設值”/etc/localtime”。當malloc_printerr一步步呼叫到tzset_internal時,glibc會從環境變數”TZ”讀取新的時區字串,通常大多數伺服器是沒設定這個環境變數的,所以新tz就是空,從而導致”free(old_tz); old_tz = NULL;”這樣的操作。
    
  所以我們的簡單演示程式碼如下: // file: test.cpp
#include <time.h>
#include <unistd.h>
#include <stdlib.h>

int main(int argc, char** argv) 
{
// 設定環境變數,強制錯誤輸出到stderr,而不是tty
setenv(“LIBC_FATAL_STDERR_”, “1”, 1);
close(STDERR_FILENO); // 關閉stderr

time_t now = time(NULL);
tm *t = localtime(&now); // 觸發old_tz初始化

char *p1 = new char[102400];
char *p2 = new char[4096];
p1[102400 + sizeof(size_t)] = 1;// 模擬記憶體寫溢位
delete [] p2; // 程式在這裡死鎖
delete [] p1;
return 0;
}

  g++ -pg -g test.cpp編譯得到可執行程式a.out。

  使用gdb執行此程式,如預期般的死鎖。
  檢視堆疊如下: (gdb) bt
#0 0x00002ba6519a4c38 in __lll_mutex_lock_wait () from /lib64/libc.so.6
#1 0x00002ba651948e5f in _L_lock_4026 () from /lib64/libc.so.6
#2 0x00002ba651945df1 in free () from /lib64/libc.so.6
#3 0x00002ba65195e148 in tzset_internal () from /lib64/libc.so.6
#4 0x00002ba65195e9d0 in tzset () from /lib64/libc.so.6
#5 0x00002ba651962e44 in strftime_l () from /lib64/libc.so.6
#6 0x00002ba651996701 in __vsyslog_chk () from /lib64/libc.so.6
#7 0x00002ba65193f6d0 in __libc_message () from /lib64/libc.so.6
#8 0x00002ba65194477e in malloc_printerr () from /lib64/libc.so.6
#9 0x00002ba651945dfc in free () from /lib64/libc.so.6
#10 0x000000000040094e in main (argc=1, argv=0x7fff5974c828) at test1.cpp:18

  程式堆疊跟文首的完全相同。至此問題得到確認。

    
  我簡單查看了一下glibc的歷史版本程式碼,這個bug在2.4到2.8的版本上都存在。當然這個bug首先需要程式設計師犯了記憶體寫溢位錯誤才會誘發,所以這並不是嚴重bug,大家只要知道了自然也可結合實際情況做防範。比如檢查程序是否正常不能光看程序是否存在,還需用工具做收發包檢測,或者檢視定時日誌是否一直有輸出之類。
  就這個問題本身來看,glibc確實還可以做得更好,例如應該進一步縮小鎖的作用域,既提升併發效能,又可降低作用域內其他函式交叉呼叫引發的死鎖風險;另外,個人認為tzset_internal中完全沒必要動態分配記憶體,給old_tz一個固定大小的記憶體比如256byte應該基本上就可以了。