題目與適用情境
一個使用 glibc malloc 的 64 執行緒 Linux 服務,啟動時 RSS 為 800 MiB。一次週期性批次工作把 RSS 推高到 6 GiB。批次結束 20 分鐘後,應用程式的 heap profiler 顯示存活配置已回落到 1.1 GiB,RSS 卻仍停在 5.2 GiB。程序位於 8 GiB 的 cgroup 限制內,p99 延遲 SLO 為 120 ms。請解釋為什麼 free() 不保證 RSS 下降,證明這段差值來自洩漏、配置器保留、碎片或其他映射,並選擇安全的改善方案。
執行緒數、記憶體數值、閒置時間、限制與 SLO 都是題目假設。假設這是原生程序,使用 glibc malloc、cgroup v2,沒有子程序,而且批次工作可重現。受管理執行環境還會增加自己的 heap、垃圾回收器與原生配置層。本題歸入 general,因為核心能力是 Linux 程序記憶體核算與配置器行為,不是某一種應用語言的語法。
目前的面試資料會把 free list、size class、thread-local 配置與碎片列為系統面試討論點。Redis 的正式文件也描述相同現象:刪除邏輯資料後,RSS 可能仍接近先前峰值,因為空閒區塊被配置器保留供重用,或仍有存活物件占用頁面。Linux 與 glibc 文件則提供建立證據鏈需要的指標與控制面。這些來源可證明題目有實務基礎和技術機制,但不能證明某家公司會問這道原題,也不能證明特定面試頻率。
面試官評估重點
第一,候選人能否分清所有權與駐留狀態。執行 free(p) 後,呼叫端不再擁有這塊配置,配置器可以重用它;C 的配置契約並未承諾執行 munmap、降低 RSS 或立即回收實體頁面。「free 一定歸還作業系統」和「free 從不歸還作業系統」都忽略了配置器的不同路徑。
第二,候選人能否分清四層指標:
- 應用程式仍存活的配置;
- 配置器的使用中、空閒、已映射與可釋放位元組;
- 程序映射與駐留頁面;
- cgroup 範圍的記憶體計費與壓力。
RSS 不是存活 heap 計數器。Linux 定義的關係是 VmRSS = RssAnon + RssFile + RssShmem。檔案映射、共享記憶體、執行緒堆疊、配置器中繼資料與原生函式庫都會擴大差值。因此,一份 heap profile 加上一筆 top 讀值,既不能證明洩漏,也不能排除洩漏。
第三,候選人能否做鑑別診斷。洩漏表示配置仍可到達,或因其他原因仍處於存活狀態;保留表示空閒記憶體仍維持映射,以便有效率地重用;碎片表示配置器雖有空閒位元組,卻無法形成可釋放的完整頁面,常見原因是少量存活物件固定住頁面,或空閒空間散落在多個 arena 與 size class。幾種狀態可以同時存在,不能看到單一比率就直接下結論。
最後,面試官要看正式環境決策。減少 arena、頻繁 trim 或替換配置器,可能降低常駐記憶體,卻增加鎖競爭、缺頁、系統呼叫或 CPU 成本。高品質回答會同時守住 8 GiB 限制與 120 ms p99 SLO,並提出 canary、工作負載回放、驗收門檻與回復方案。
回答前需要釐清的問題
- 實際使用哪一個配置器與版本? glibc 的 tunable 與
malloc_trim不能直接套用到 jemalloc、tcmalloc、mimalloc、語言執行環境配置器或靜態連結替代品。先確認已載入的配置器與部署映像。 - 1.1 GiB 究竟由哪一個工具統計? 抽樣 heap profile、精確配置器計數、受管理執行環境 heap 與業務快取指標涵蓋的位元組不同。要確認是否包含原生函式庫、執行緒堆疊、直接映射與配置器中繼資料。
- 哪一個 RSS 分量維持高點?
RssAnon較指向 heap、stack 與匿名映射;RssFile指向檔案映射;RssShmem指向共享記憶體。如果增量不是匿名記憶體,先調配置器就找錯方向。 - 每次相同批次後是穩定平台,還是階梯上升? 高水位穩定,而且下一輪能重用、不需再增加 5 GiB,較像保留。存活配置或 RSS 逐輪上升,則需要解釋洩漏、負載或新映射。
- 執行緒數、配置尺寸或物件生命週期是否改變? 多執行緒與跨執行緒釋放會把區塊分散到 arena 或 cache;長短生命週期物件混放,可能讓每個原本幾乎空閒的頁面都留下一個存活物件。
- cgroup 是否真的處於壓力? 對照
memory.current、memory.events、PSI、swap 與鄰近程序。RSS 高但餘裕充足可能只是效率問題;持續觸發memory.high或接近 8 GiB,釋放行為才有營運急迫性。 - 下一輪批次多久後到來? 為五分鐘後的重用保留 4 GiB 可能合理;在緊限制下閒置十二小時仍保留,則更適合評估批次後 purge 或不同配置模式。
- 可以接受多少效能退步? 如果服務可多用 2% CPU,卻不能讓 p99 增加 5 ms,方案會不同;若記憶體成本優先級更高,取捨也會改變。
30 秒回答架構
「free() 是把區塊交還給配置器,並不保證解除頁面映射,所以 RSS 不下降也不一定是洩漏。我會對齊一次完整批次的時間軸,同時比較存活配置、配置器使用中與空閒位元組、RssAnon、逐映射 smaps 與 cgroup 用量,再把同一批次重複三次。存活位元組逐輪增加較像洩漏;存活位元組穩定、RSS 平台穩定且下一輪直接重用,較像配置器保留;空閒位元組很多但可釋放量很少,頁面又被殘留物件固定,則較像碎片。我只會把 malloc_trim(0) 當成 glibc 的 canary 診斷,不會當成通用修復。最後可能修正所有權、隔離生命週期、在批次邊界做受控 trim、測試 arena 參數或替換配置器,只有正式環境形態回放同時達到記憶體目標且不超過 120 ms p99 才接受。」
分步深入解析
第一步:建立一致的記憶體時間軸
記錄部署版本、PID、cgroup 路徑、執行緒數、請求與批次規模、配置速率和尺寸分布、存活位元組、RSS 分量、memory.current、memory.peak、壓力與 OOM 事件。取樣點至少涵蓋批次前、6 GiB 峰值、釋放剛結束,以及之後 20 分鐘的閒置期。PID、cgroup 或負載不同,就不能直接比較。
先判斷這只是「RSS 高」,還是服務已接近限制並在壓力下回收。5.2 GiB 位於 8 GiB 限制內,表面餘裕為 2.8 GiB。這個減法只能做數量級檢查,因為 cgroup 還會計入本程序匿名 RSS 之外的記憶體;真正的資源域要看 memory.current 與 memory.stat。
第二步:解釋配置器與核心的邊界
配置器向作業系統申請較大的區域,再切成小區塊。glibc 可以擴展一般 arena,也可以為夠大的配置建立獨立匿名映射。應用程式釋放一塊記憶體時,配置器首先把它變成可重用區塊;之後可能合併相鄰空閒區塊、放入 bin 或 cache、保留以避免後續系統呼叫,或釋放適合的頁面。
獨立映射的大區塊通常能在釋放時單獨解除映射,一般 heap 則較困難。區域中間的空閒區塊不能縮短 heap 末端;一個頁面只要還含有一個被原始指標引用的存活物件,就不能直接解除映射。C 與 C++ 配置器通常無法壓縮任意存活物件,因為搬動物件會讓應用程式指標失效。
因此會出現三類成本:
- 內部碎片: 20 位元組請求可能占用較大的對齊區塊或 size-class 區塊。
- 外部或頁面層級碎片: 有空閒空間,但被切散或被存活物件固定,無法釋放完整頁面。
- 主動保留: 配置器預期還會重用,因此讓完整或部分頁面保持映射,避免釋放和重新申請的成本。
這些是機制名稱,不能只憑一次 RSS / 存活位元組 比率就下結論。
第三步:對齊四層指標
先看 Linux 程序核算:
VmRSS = RssAnon + RssFile + RssShmem用 /proc/PID/status 看大類拆分,用 /proc/PID/smaps_rollup 看彙總的 RSS、PSS、匿名、檔案、共享與延遲釋放資訊。只有需要定位哪一段匿名或檔案映射成長時,才讀取 /proc/PID/smaps。單次 pmap 或 RSS 總量,不如同一時間軸上的增量有價值。
再加入配置器統計。glibc 的 mallinfo2 能提供透過 sbrk 取得的位元組、獨立映射區塊、已交給呼叫端的位元組、空閒位元組與 heap 頂端可釋放區塊等資訊。這些欄位不涵蓋所有配置來源,必須用一致方式取樣,但可協助判斷匿名差值是否主要由配置器持有。如果應用程式使用的配置器原生分析能更完整提供 mapped、active、resident、retained 與 size-class 資料,應優先採用。
最後對齊 cgroup v2。cgroup 包含階層內所有被計費的記憶體,因此可能高於單一程序 RSS,也可能因其他原因變動。比較 memory.current、memory.stat 與程序證據,不要強迫它們相等。
第四步:執行三輪重用實驗
在正式環境形態的 canary 實例上,用相同 64 執行緒並行度和輸入尺寸分布,連續執行三輪相同批次。每輪結束後都等待 20 分鐘,並收集相同指標。
依曲線判讀:
| 觀察 | 較強假設 | 下一步檢查 |
|---|---|---|
| 每次閒置後存活配置都增加 | 洩漏或應用程式主動保留 | 比較存活物件與配置堆疊 profile |
| 存活位元組回到 1.1 GiB;RSS 停在 5.2 GiB 左右;後續批次無需相似幅度增加 | 可重用的配置器保留 | 測量缺頁、配置延遲與配置器空閒位元組 |
| 存活位元組穩定;配置器空閒位元組高;可釋放位元組低;生命週期或尺寸組合改變後平台也改變 | 碎片或 arena 分散 | 檢查 size class、arena、跨執行緒釋放與被固定的映射 |
差值主要由 RssFile 或 RssShmem 解釋 | 檔案映射或共享記憶體生命週期 | 找出映射與擁有者,不再調 malloc |
| 存活位元組與非 heap 匿名映射同時增加 | 多個原因並存 | 分別分析 heap 與原生映射 |
平台穩定不代表一定安全。如果下一輪尺寸分布不同,舊保留頁面無法重用,峰值仍可能超過 8 GiB。相反地,若同類負載能有效率地重用,高平台也可能比強制釋放後反覆缺頁更合適。
第五步:把 trim 當成受控診斷
只在 glibc canary 實例的批次結束邊界呼叫一次 malloc_trim(0),記錄回傳值、RSS 分量、配置器存活位元組、缺頁、CPU 與後續請求延遲。這個 GNU 介面會嘗試以 sbrk 或 madvise 釋放 heap 中的空閒記憶體,但不承諾 RSS 必然降低。
如果存活配置仍為 1.1 GiB,而 RssAnon 明顯下降,表示當時確實有一部分配置器頁面可以釋放。這能縮小診斷範圍,卻不能證明正式環境應頻繁 trim。如果 RSS 幾乎不動,可能沒有完整空閒頁面,成長來自 glibc 之外,或指標含有其他映射。trim 沒有釋放成功,也不能證明是洩漏。
不要在每一個請求上呼叫 trim。釋放頁面會把常駐記憶體換成系統呼叫、minor fault、清零、cache 損失,以及下一輪批次到來時的尾端延遲。明確而較長的批後閒置邊界較適合測試,但仍需 canary。
第六步:依已證實原因選方案
- 洩漏: 修正仍持有物件的參照、無上限快取、遺漏的 free 或函式庫生命週期。trim 無法釋放仍存活的記憶體。
- 近期會重用的主動保留: 可以保留,依實測峰值規劃容量,並針對逐輪階梯成長告警,不必強迫 RSS 等於存活位元組。
- 生命週期造成的碎片: 把短命批次物件與長期服務狀態分開;短命物件可使用 batch arena 或 region,一次整體釋放;避免一個長命物件散落在大量暫存頁面中。
- arena 或 thread cache 分散: 測試較少的 arena、降低配置熱點並行度,或調整所有權模式。arena 變少可能省記憶體,也可能增加競爭。
- 緊限制下的長閒置期: 測試一次明確的批後 trim 或配置器專用 purge,並加上頻率限制與回復開關。
- 配置器與負載不匹配: 用相同軌跡比較 glibc 與合適替代品。Microsoft Research 對 mimalloc 的設計說明呈現核心取捨:thread-local 頁面提升擴展性與區域性,隔離所有權也可能讓另一個執行緒無法立即重用空閒記憶體。
glibc 的 arenamax、trimthreshold 與 mmap_threshold 都是實驗變因,不是萬用常數。明確設定後,動態行為會更固定,也會改變競爭、映射數、釋放頻率與系統呼叫成本。每次只改一個因素,並保留原映像供回復。
第七步:同時驗證記憶體與延遲
在正式環境形態的硬體上回放 30 個相同週期。30 是本題的測試視窗,不是通用標準。持續記錄存活配置、配置器 mapped/free/releasable 位元組、RssAnon、總 RSS、memory.current、壓力、缺頁、配置延遲、CPU、吞吐量,以及 p50/p99 請求延遲。
本題可以採用一組範例驗收條件:閒置 20 分鐘後的 RssAnon 不超過 2.2 GiB;30 輪沒有上升階梯;沒有 OOM 或持續壓力;p99 不超過 120 ms;CPU 退步不超過 3%。這些都是練習假設,必須換成真實服務預算。方案若把記憶體降到 2.2 GiB,卻把 p99 推到 145 ms,就不合格;若守住延遲,卻在下一種合法尺寸組合下仍接近 8 GiB,也不合格。
高品質示範回答
「我不會只憑 RSS 就判斷洩漏。free() 結束的是應用程式對一塊記憶體的所有權,glibc 仍可能把區塊保留在 arena 中重用;少量存活物件也可能讓原本幾乎空閒的頁面保持駐留。我會先確認 1.1 GiB 是否涵蓋原生存活配置,再把它與 RssAnon、RssFile、RssShmem、smaps 映射、配置器統計,以及一次完整批次中的 cgroup 計費對齊。
接著我會用相同負載重複三輪。若每次閒置 20 分鐘後存活配置仍增加,就比較存活物件 profile 並修正所有權路徑。若存活位元組穩定在 1.1 GiB、RSS 穩定在 5.2 GiB 左右,下一輪又能直接重用而不再增加,配置器保留較可能。若配置器空閒位元組很多、可釋放頁面很少,而且平台值會隨物件生命週期或 64 執行緒並行度改變,我會檢查碎片與 arena 分散。
作為只適用於 glibc 的診斷,我會在 canary 實例的批後邊界呼叫一次 malloc_trim(0)。如果存活位元組不變而 RssAnon 下降,只能證明當時有配置器頁面可釋放,不能據此在每個請求上 trim。之後我會選最小的針對性變更:修洩漏、把批次物件放入可整體釋放的 region,或小流量驗證受控的批後 trim 與 arena 參數。回放 30 輪後,只有閒置記憶體達標、RSS 不階梯上升、cgroup 安全且 p99 仍在 120 ms 內,我才接受變更。」
常見錯誤
- 把 4.1 GiB 差值直接稱為洩漏 → RSS 包含配置器空閒頁面與非 heap 映射 → 用同步的存活物件或持有關係 profile 證明成長。
- 宣稱
free()一定降低 RSS → 空閒區塊可能留在 arena,或與存活區塊共用頁面 → 說明重用、完整頁面釋放與獨立映射路徑。 - 宣稱
free()從不歸還系統 → 配置器可以解除大型映射、trim heap 頂端,或用建議機制釋放完整空閒頁面 → 明確說明釋放取決於配置器、布局與策略。 - 用一個碎片率證明碎片 → 最近峰值、檔案映射、cache 或主動保留都會放大該值 → 跨多輪比較存活、空閒、映射、駐留與可釋放位元組。
- 每個請求都呼叫
malloc_trim(0)→ 強制釋放可能增加系統呼叫、缺頁與尾端延遲 → 只在自然閒置邊界做一次實驗,並測下一輪配置突發。 - 因為有 64 執行緒就把
arena_max設為 1 → 記憶體可能下降,鎖競爭卻上升 → 在相同並行度下掃描候選值,並守住 p99。 - 依照一個 benchmark 標題更換配置器 → 配置尺寸、生命週期與跨執行緒釋放模式會決定結果 → 用精確軌跡 A/B,同時設定記憶體與延遲驗收條件,並保留回復。
- 忽略 cgroup 核算 → 單一程序 RSS 不是完整資源域 → 把
memory.current、memory.stat、後代程序與壓力同程序指標對齊。
追問與回應
如果 malloc_trim(0) 把 RSS 從 5.2 GiB 降到 1.8 GiB,證明了什麼?
它證明 glibc 當時擁有大量可按頁面釋放的記憶體,而且高 RSS 並非全部是應用程式存活資料。它沒有證明不存在較小洩漏,也沒有解釋保留原因,更不能證明頻繁 trim 安全。要重新執行負載,測量缺頁、CPU、配置延遲與 p99 後再決定策略。
如果 trim 回傳零且 RSS 不動,就是洩漏嗎?
不是。空閒空間可能散落在仍有存活區塊的頁面,也可能由另一個配置器或映射持有,或當時沒有 glibc 可釋放頁面。繼續比較存活物件、配置器空閒與可釋放位元組、smaps 歸屬。洩漏需要存活或仍被持有的配置持續成長證據,不能由 trim 失敗推論。
如果 RSS 穩定,但 memory.current 持續成長呢?
應調查 cgroup 的增量,而不是 heap。memory.stat 可以揭露檔案快取、共享記憶體、socket 或核心記憶體,cgroup 也可能包含其他程序或後代。先確認階層與映射擁有者。單一程序 RSS 已穩定時繼續調 glibc,會處理錯誤層級。
為什麼不強制 glibc 只使用一個 arena?
一個 arena 可能減少分散,也會讓更多配置工作序列化。64 執行緒服務可能把常駐記憶體問題換成鎖競爭與 p99 退步。應在真實配置軌跡下測試幾個受限 arena 數,蒐集配置器競爭與延遲,選擇同時滿足兩項預算的最小值。
什麼時候 batch arena 或 region allocator 更合適?
當大部分物件具有清楚且一致的生命週期時很合適:批次中從同一個 region 配置,結束後整體釋放。若參照逃逸到長期服務狀態、需要逐物件解構,或同一批次混有多個無關生命週期,就不安全。依賴整體釋放前,必須建立可驗證的所有權邊界。
如何評估替換配置器?
除了配置器連結之外,保持 build、輸入軌跡、執行緒數、CPU 配置、預熱和 30 輪視窗一致。比較峰值與閒置後 RSS、存活到駐留差值、配置吞吐量、CPU、缺頁、p50/p99,以及接近 8 GiB 時的失敗行為。勝出方案也要先 canary 並保留部署回復;平均 RSS 較低本身不夠。