跳到內容

18|找出記憶體洩漏

還沒完成只限 CCPU 時間上限 1 秒計算 heap 用量

相關主題: 控制記憶體的必備指令

只限 C|CPU 時間上限 1 秒|計算 heap 用量,有記憶體洩漏就不通過

這個資料夾裡有一個 buggy.c,是一個「單字統計」程式。它的輸出完全正確,但是有 3 個記憶體洩漏(配置了、卻到程式結束都沒有釋放的記憶體),而且 3 個的成因都不一樣。

你的工作:

  1. 把 buggy.c 複製成 main.c(buggy.c 留著不要改,方便對照)。
  2. 找出並修好所有記憶體洩漏。
  3. 輸出必須和原本完全一樣。

不需要重寫程式,只要在正確的地方補上 free(或稍微調整程式碼的順序)。

讀入多行英文文字,統計每個單字出現幾次:

  • 空行(只有空白字元的行)和註解行(第一個非空白字元是 # 的行)會被跳過。
  • 其他的行用空白、Tab 和標點符號 . , ! ? ; : " ( ) 切成一個個單字,英文字母一律轉成小寫再統計。所以 The 和 THE 算同一個單字;It's 是一個單字 it's;出現在行中間的 # 不是註解,會被當成單字(或單字的一部分)。

第 1 步:看記憶體報告。 在終端機輸入(一行):

run exercises\3-memory\18-find-the-leak\buggy.c --mem --input exercises\3-memory\18-find-the-leak\tests\01.in

--mem 會關掉 Sanitizer、改用記憶體記帳器執行,--input 讓程式從測試檔讀輸入。也可以在 VS Code 開著 buggy.c,按 Ctrl+Shift+P → Tasks: Run Task → 「執行並顯示記憶體報告」,再自己打輸入(打完按 Ctrl+Z 再按 Enter 表示輸入結束)。

程式的輸出後面會接著一份報告,最後幾行長這樣:

⚠ 記憶體洩漏:程式結束時還有 N 塊、共 M bytes 沒有釋放:
buggy.c:行號(函式名稱):多少 bytes(幾塊)
…

每一行的意思是:「在 buggy.c 的這一行(這個函式裡)配置的記憶體,有這麼多塊到程式結束都沒有釋放」。

第 2 步:沿著指標追。 報告告訴你的是記憶體在哪裡配置,不是應該在哪裡釋放。對每一個位置,問自己:

  • 配置出來的指標被存到哪個變數、傳給了誰?
  • 最後一次用到它是在哪裡?那裡之後就應該 free。
  • 從配置到 free 之間,有沒有哪條路(continue、break、return)會跳過 free?

第 3 步:修改 main.c,再看一次報告。 把上面指令裡的 buggy.c 換成 main.c 再執行,直到報告顯示「✔ 程式結束時所有配置的記憶體都已經釋放」。也可以換別組測試(tests\04.in 等)多試幾次。

注意:

  • 平常的 run 和 Ctrl+Shift+B 用的 AddressSanitizer 在 Windows 上不會檢查記憶體洩漏(它抓的是越界、use-after-free、double free),所以這一題要用 --mem 的記憶體報告。
  • 修的時候小心不要「修過頭」:釋放之後又使用(use-after-free)、或同一塊釋放兩次(double free),檢查的第一階段(Sanitizer)會直接報錯,並指出是哪一行。
  • 在題目資料夾裡,開著 main.c 按 Ctrl+Shift+B 是檢查答案;開著 buggy.c 按 Ctrl+Shift+B 則是直接執行 buggy.c(Sanitizer 模式,不會顯示記憶體報告,要看報告請用上面的 --mem 指令)。

buggy.c 要處理的輸入:

  • 多行英文文字,最多 5000 行,每行最多 1000 個字元。
  • 只包含英文字母、數字、空白、Tab 和標點符號。
  • 不同的單字最多 1000 個。
  • 第一行 lines L:有內容的行數(不含空行和註解行;只有標點符號的行也算一行)。
  • 第二行 words W:單字的總數。
  • 第三行 distinct D:不同單字的個數。
  • 接下來 D 行,每行 單字 次數,依照次數由多到少排列;次數相同時依照字母順序(strcmp 的順序)。

輸入:

# a tiny example
The cat saw the dog.
the DOG saw a cat!
# end

輸出:

lines 2
words 10
distinct 5
the 3
cat 2
dog 2
saw 2
a 1

C:

  • 誰配置、誰釋放:函式回傳一塊 malloc 來的記憶體,就等於把「釋放的責任」交給呼叫端。寫這種函式時,在註解寫清楚「回傳新配置的字串,呼叫端要 free」
  • 提早離開的路徑:continue、break、return 會跳過後面的程式碼,包括後面的 free。每加一條提早離開的路,都要想一次「現在手上有哪些還沒釋放的記憶體?」
  • 結構裡的指標:free(p) 只釋放 p 指向的那一塊,不會順便釋放那一塊裡面的指標指向的東西。要先釋放裡面的,再釋放外面的
  • 為什麼要 free,程式結束時作業系統不是會回收嗎? 這個程式很快就結束所以看不出影響,但伺服器、遊戲這類長時間執行的程式,在迴圈裡每次漏一點,記憶體就會一直增加直到用完。養成「每個 malloc 都有對應的 free」的習慣,也讓記憶體工具的報告保持乾淨,真正的問題才不會被淹沒
  • 記帳器追蹤的是 malloc / calloc / realloc / free(C++ 還有 new / delete)

C++ 對照:

  • std::string、std::vector 和智慧指標 std::unique_ptr 在離開範圍時會自動釋放自己管理的記憶體(RAII),這一題的三種洩漏在 C++ 裡大多可以靠它們避免。第 19 題會練習這個觀念
提示 1:先把報告的每一行對應到原因

報告會列出 3 個位置。看「塊數」可以幫助你猜原因:用 tests\01.in(範例)時,

  • 哪一個位置的塊數,剛好等於空行加註解行的數量?
  • 哪一個位置的塊數,剛好等於不同單字的數量?(有兩個都是!)

再看看這些位置分別在哪個函式裡、那個函式是誰呼叫的。

提示 2:lower_copy 和 format_entry

這兩個函式都回傳新配置的字串,呼叫它們的都是 main:

  • lower_copy 的結果在迴圈最後有 free(text)……但每一行都會走到那裡嗎?
  • format_entry 的結果拿去 puts 之後就再也沒用了,有人釋放它嗎?
提示 3:add_word 裡配置的字串

add_word 把每個新單字複製一份,存到 table.items[i].word。程式最後的 free(table.items) 釋放的是「放 Entry 的那個陣列」,陣列裡每個 word 指向的字串是另外配置的,不會跟著被釋放。

所以在 free(table.items) 之前,要先用迴圈把每個 table.items[i].word 釋放掉。順序不能反過來:先釋放 table.items 的話,之後再讀 table.items[i].word 就是 use-after-free。