我喜歡花一些時間研究和實驗自訂的記憶體配置器,以及我自己的 malloc 實作。

雖然單元測試對於正確性很有用,但最終的測試是觀察配置器在實際程式中的表現。

在 Linux 上,覆寫預設的 malloc 相當簡單:包裝標準的配置函式(例如 malloc、calloc、realloc、free,以及 malloc_usable_size 等工具),將你的實作編譯成共享函式庫,然後使用 LD_PRELOAD 強制程式先載入它。

例如,你可以使用類似這樣的簡單指令來測試你的配置器:

為了更了解程式如何配置記憶體,我建立了一個除錯工具,可以將每次配置請求的大小記錄到檔案中。

在實作 malloc 時,建立像這樣的除錯工具必須小心,不要在內部使用 malloc 來記錄輸出。否則,你可能會面臨無限迴圈和崩潰的風險。

為了解決這個問題,我使用了堆疊配置的緩衝區,並結合了 creat、write 和 snprintf 等低階函式來安全地擷取資料。

在分析不同程式的配置模式時,我注意到一件不尋常的事:第一次配置總是 73728 位元組(72 KB)。

我測試過的每個程式都表現出這種行為,這由我的除錯日誌證實了:

為了追蹤第一次呼叫 malloc,我使用 gdb 在我自己的 malloc 函式中設定中斷點來檢查呼叫堆疊。

快速補充說明:設定「malloc」符號的中斷點不僅會觸發我們自己的 malloc,還會觸發動態連結器的內部 malloc,所以我們必須更具體。

在 libc(或我們自己的 malloc)載入之前,RTLD 使用自己的最小 malloc 實作來進行早期記憶體配置。

我鼓勵你看看 glibc 的 elf/dl-minimal-malloc.c,它非常容易理解。

呼叫堆疊顯示,第一次 72 KB 的配置源自 libstdc++。

雖然加入除錯符號有助於縮小範圍,但由於內嵌(inlining),很難精確指出負責 malloc 呼叫的確切函式。

我們只知道 malloc 呼叫來自 __pool_alloc_base::_M_allocate_chunk 的某個下游。

確定確切的呼叫者花了一些時間,但我透過將組合碼中已知的函式與 libstdc++ 原始碼進行交叉比對來縮小範圍。

這項調查引導我找到了 libstdc++-v3/libsupc++/eh_alloc.cc,其中「eh」代表「例外處理」(exception handling)。

這很有道理,因為 _M_allocate_chunk 可能是第一個可能拋出例外的地方,所以例外處理基礎設施必須初始化,這大概是惰性完成的。

libstdc++-v3/src/c++98/pool_allocator.cc(為求簡潔已簡化):

我們看到的 72 KB malloc 呼叫是為所謂的「緊急記憶體池」(emergency pool)分配的記憶體,它是在建構子中配置的:

libstdc++-v3/libsupc++/eh_alloc.cc(為求簡潔已簡化):

正常情況下,例外會直接透過 malloc 配置,但如果 malloc 呼叫失敗,例外就會從緊急記憶體池中配置。

這確保了即使 malloc 失敗,例外仍然可以被拋出(在緊急記憶體池的大小範圍內),為錯誤處理提供最後一道防線。

緊急記憶體池是在程式啟動時惰性配置的,因為那時記憶體更有可能可用,這解釋了為什麼我們如此一致地看到這個配置。

libstdc++-v3/libsupc++/eh_alloc.cc(為求簡潔已簡化):

在原始碼檔案中,對緊急記憶體池的大小如何計算有簡短的解釋。

物件大小和物件數量都基於字長(wordsize),在 64 位元系統上是 8 位元組。

libstdc++-v3/libsupc++/eh_alloc.cc:

物件大小(obj_size)和物件數量(obj_count)可以透過 GLIBCXX_TUNABLES 環境變數手動調整。

我們可以透過實證來驗證初始配置實際上是為了緊急記憶體池,方法是改變記憶體池中的物件數量。

正如預期的那樣,當我們改變物件數量時,初始配置大小會下降:

順帶一提,緊急記憶體池也可以被禁用(即不配置),方法是將物件數量設為 0。

或者,你也可以透過在建構 libstdc++ 時設定 --enable-libstdcxx-static-eh-pool 來選擇使用固定大小的靜態緩衝區作為緊急記憶體池。

與我們的發現相符的是 Valgrind 中觀察到的行為,Valgrind 是一個流行的工具,可以偵測記憶體管理錯誤。

Reddit 使用者 ismbks 在 r/cpp_questions 上發文「為什麼我的程式會配置約 73 KB 的記憶體,即使它什麼都沒做?」,這與我們看到的為緊急記憶體池無條件配置的位元組數相同。

然而,在舊版的 Valgrind 中,這塊記憶體會顯示為「仍可達」(still reachable),而不是被正確釋放。

雖然「仍可達」的記憶體嚴格來說不是記憶體洩漏(程式仍然有對它的引用),但它可能具有誤導性。

請參閱 Stack Overflow 上關於此行為的貼文。

有趣的是,這個人看到的是 71 KB 的配置,而不是 72 KB。

許多開發者錯誤地將這種行為解釋為記憶體洩漏,導致不必要的困惑。

為了解決這個問題,新版的 Valgrind 現在會在清理過程中明確釋放緊急記憶體池,提供更清晰的報告。

這是透過以下所示的機制實現的,這些機制是專門為 Valgrind 等工具添加的:

libstdc++-v3/libsupc++/eh_alloc.cc(為求簡潔已簡化):

為緊急記憶體池分配的記憶體解釋了為什麼我在測試我的自訂配置器時,能夠持續觀察到 72 KB 的配置。

由於我用 C++ 實作了我的自訂配置器,它本質上依賴於 libstdc++,而 libstdc++ 會在每次程式啟動時初始化緊急記憶體池。

有趣的是,如果我用 C 語言來編寫我的配置器(許多流行的 malloc 實作,如 mimalloc 和 jemalloc,都是用 C 語言編寫的),我只會在測試明確連結到 libstdc++ 的 C++ 二進位檔時看到這個初始配置。

你可能會看到不同的配置大小(例如 71 KB 而不是 72 KB),或者根本沒有配置。

像不同的 libstdc++ 版本、使用 libc++ 而不是 libstdc++,甚至編譯器旗標都可能導致差異。

不過,在大多數情況下,你很可能會看到緊急記憶體池的記憶體在早期被配置,其大小或行為可能因環境而異。

當你開始處理記憶體配置時,你會很快發現幾乎所有東西都需要配置記憶體。

從古至今,RTLD 需要自己的 malloc,因為它還沒有載入 libc,或者對於緊急記憶體池,它只使用 malloc 來配置它自己的記憶體池配置器的記憶體!

深入研究程式碼並將這些拼湊在一起是很有益且有趣的。

希望你和我一樣享受這次旅程!