一種不一樣的 bug:什麼都沒當機
前兩篇給了你手動堆積管理的兩半。第 1 篇教了那份契約:malloc() 從堆積交給你一塊記憶體,而 free() 把它交還,你拿走的每一塊最終都必須剛好歸還一次。第 2 篇點名了讓這件事得以成立的紀律——所有權:對每一塊活著的記憶體,你的程式碼裡恰好有一個部分負責釋放它。本篇談的,是當那份責任悄悄從縫隙裡漏掉時會發生什麼事。沒有人呼叫 free()。那塊記憶體永遠沒被歸還。這就是一個記憶體洩漏。
讓洩漏令人不安的地方在於,它打破了你已學會對 C 的 bug 所抱的預期。一個壞指標給你一個響亮的區段錯誤;一個 off-by-one 也許汙染了資料、但你終究追得到。洩漏兩者都不是。程式跑得正確。輸出是對的。什麼都沒當機、什麼都沒警告、測試也過了。唯一的徵兆,是程式的記憶體佔用量隨時間慢慢往上爬——一個你平常根本沒在盯著看的數字。洩漏是少數那種 bug,它的整個本質就是:當下完全不產生任何徵兆。
到底是什麼洩漏了,一個位元組一個位元組地看
我們把「到底失去了什麼」講精確一點,因為「洩漏」是個鬆散的詞。當你呼叫 malloc(64),你拿回一個指向 64 位元組區塊的指標,而配置器在它自己的記帳裡記下:這 64 個位元組現在「正在使用中」。那筆記錄是唯一在追蹤這塊記憶體的東西。你變數裡的那個指標,是你對它唯一的把手——回到它的唯一途徑。洩漏發生的精確時刻,是當你弄丟了那個把手、卻「沒有」先釋放那塊記憶體:裝著指標的變數被覆寫、離開了作用範圍、或函式返回了,於是你整個程式裡再也沒有任何一個指標叫得出那 64 個位元組。想像 `char *p = malloc(64);` 之後接著 `p = malloc(128);`、中間沒有任何 free()——光是這一次重新指派,就讓原本那塊記憶體擱淺了,因為它唯一的把手剛剛被蓋掉了。
從配置器的角度看,那些位元組仍在使用中——它仍替你把它們留著。從你的角度看,它們無法觸及:你沒有指標,所以你永遠無法釋放它們、也永遠無法再碰它們。它們被擱淺了。它們會一直被保留著、什麼也不做,直到你的行程結束、作業系統把「一切」一次收回。最後這個事實是唯一的慈悲:在一個跑完就結束的短命程式裡,洩漏大多無害,因為行程結束反正會釋放整個位址空間。危險的是那種不會結束的程式。
為什麼長命程式裡的洩漏是一種慢性死亡
把那行會洩漏的程式跑一次,你損失 64 個位元組——沒人會注意。麻煩在於,真正的洩漏通常住在一個迴圈或一個請求處理器裡,住在某個一遍又一遍執行的地方。一個每次請求都洩漏一小塊的網頁伺服器、一個每一影格都洩漏幾個位元組的遊戲、一個跑上好幾個月的常駐程式——每一個都在每一趟裡多擱淺一點記憶體。佔用量不會跳上去;它會「成長」,穩定地,像一道緩慢的潮水。這正是為什麼洩漏常常不是被某個測試抓到、而是被一張圖抓到的:有人注意到監控儀表板上那條記憶體曲線一路往上爬、再也沒下來過。
這會在哪裡終結?最終,行程向核心要求的堆積超過它被允許的份額,malloc() 開始回傳 NULL,而——如果程式碼盡責地檢查了它的回傳值,就像第 1 篇堅持必須做的那樣——程式就會在一個它本來應該能滿足的配置上失敗。如果它「沒有」檢查,它就解參考那個 NULL、在離真正洩漏很遠的地方以一個區段錯誤死去。無論哪一種,那場當機真正到來時,指向的是一個無辜的配置,而不是幾個小時前耗盡堆積的那行洩漏程式。起因與徵兆被時間分隔開來,這正是為什麼洩漏難以僅靠讀程式碼來追捕。
用 Valgrind 抓洩漏
因為洩漏把弄丟的把手藏了起來,要找到它,辦法就是讓「別的東西」去保存你丟掉的那筆記帳。這正是 Valgrind 的 Memcheck 所做的事。你照常建置你的程式——最好帶著除錯資訊,所以用 `gcc -g -O0 main.c` 編譯——然後在 Valgrind 底下執行它、而不是直接執行:`$ valgrind --leak-check=full ./a.out`。Valgrind 在一顆模擬的 CPU 上跑你的程式,盯著每一次 malloc() 和每一次 free()。當你的程式結束時,它確切知道哪些區塊被配置了卻從未被釋放,並把它們印出來。
報告本身就是魔法。對每一塊洩漏的區塊,Valgrind 會顯示位元組數,以及——這正是把幾小時變成幾分鐘的部分——配置它的那次 malloc() 呼叫的完整堆疊回溯,含檔名與行號。它還把洩漏分類:「definitely lost」(確定遺失)意思是真正無法觸及(一個你必須修的真實洩漏),而「still reachable」(仍可觸及)意思是一塊你從未釋放、但結束時仍有指標叫得出它的區塊(通常無害,由結束時隱式釋放,但值得看一眼)。你讀那段回溯、走到那一行、補上那個擁有者本來該呼叫的 free()。
==12345== HEAP SUMMARY: ==12345== in use at exit: 64 bytes in 1 blocks ==12345== ==12345== 64 bytes in 1 blocks are definitely lost ==12345== at 0x4848899: malloc (vg_replace_malloc.c:...) ==12345== by 0x1091AE: main (main.c:7) <- the leaking line ==12345== ==12345== LEAK SUMMARY: ==12345== definitely lost: 64 bytes in 1 blocks
AddressSanitizer,以及在洩漏壯大前就找到它
Valgrind 周全但慢——它可以讓程式跑慢十到三十倍,因為它在模擬 CPU。現代的替代方案是 AddressSanitizer(ASan),編譯器會在你加上一個旗標時把它烤進你的程式:`gcc -g -fsanitize=address main.c`。產生的執行檔在執行時自我檢查,代價輕得多(大約慢兩倍),而結束時它內建的洩漏偵測器——LeakSanitizer——會印出同一種報告:大小、數量、以及配置時的堆疊回溯。同一個旗標也會抓到下一篇要談的釋放後使用與緩衝區溢位,所以它是開發時最值得一直開著的單一旗標。
但最深層的修法根本不是工具——而是你在第 2 篇已經見過的那個習慣。洩漏,追根究柢,是一個沒被回答的所有權問題:誰來釋放它,以及何時?如果你能在寫下 malloc() 的當下,就指出由哪一行確切地擁有這塊記憶體的 free(),你就很少會洩漏。工具是給那些紀律滑掉、或程式庫大到記不進腦袋裡的情況用的。用以下步驟走過一個疑似的洩漏。
- 在工具底下重現:用 `gcc -g -fsanitize=address` 重新建置(或在 `valgrind --leak-check=full` 底下跑現有的執行檔),接著走一遍你懷疑的路徑,並讓程式乾淨地結束,好讓洩漏報告印出來。
- 順著配置的堆疊回溯讀到洩漏那次 malloc() 的檔名與行號——那是這塊記憶體誕生之處,是它生命週期的起點。
- 問那個所有權問題:程式碼裡由哪一個部分負責這塊記憶體的 free(),而在每一條離開的路徑上——提早返回、錯誤分支、迴圈的每一輪——那個 free() 是否真的有跑到?
- 在缺少 free() 的那些路徑上恰好補上它,若指標之後可能再被用到就在釋放後把它設為 NULL,重新建置,並在工具底下重跑,直到報告讀出零位元組遺失為止。
這把你帶到了哪裡
你現在能精確地點名堆積裡最安靜的那種 bug 了。洩漏是一個弄丟的把手:一塊配置器仍替你留著、但你程式裡沒有任何指標能再觸及的記憶體,所以它永遠無法被釋放、會一直擱淺到行程結束。它不當機、不汙染、不警告——它只是累積,而這正是為什麼它在任何長命的東西裡都危險、在任何短命的東西裡都無害。而你手上有兩件儀器——Valgrind 和 AddressSanitizer——能把那看不見的東西變回一份印出來的檔名與行號。
繼續往前時,請把一條界線保持清楚。本篇的 bug 來自釋放得「太少」——你從未把那塊記憶體還回去。下一篇是它的鏡像:來自在「錯誤的時間」使用或釋放一塊記憶體的 bug——在記憶體被釋放之後還去碰它(釋放後使用)、或把同一塊釋放兩次(重複釋放)。那些跨過了界線、進入未定義行為,在那裡,優化器可以自由地假設那個 bug 從未發生,於是徵兆變得真的很怪。我們會誠實地會一會那份怪異,而同一個旗標 `-fsanitize=address` 也會在那裡等著抓它。