找出並修復 Ghostty 史上最大的記憶體洩漏
幾個月前,使用者開始回報 Ghostty 會消耗異常龐大的記憶體,有一位使用者回報在連續執行 10 天後記憶體使用量達到 37 GB。今天,很高興地宣布修正已經找到並完成合併。本文將概述造成洩漏的原因、介紹 Ghostty 的部分內部機制,以及簡要說明我們如何追蹤到問題。1
這個洩漏自 Ghostty 1.0 以來就已存在,但直到最近熱門的 CLI 應用程式(特別是 Claude Code)開始大規模產生觸發該問題的條件,才被顯現出來。正是因為觸發條件相當侷限,才讓這個問題特別難以診斷。
修正已完成合併,可在tip/nightly 版本中取得,並將包含在預定於三月發布的 1.3 正式版本中。
PageList
要理解這個錯誤,首先需要了解 Ghostty 如何管理終端機記憶體。Ghostty 使用一種名為PageList的資料結構來儲存終端機內容。PageList 是一個儲存終端機內容(字元、樣式、超連結等)的雙向鏈結串列,由多個記憶體分頁組成。
PageList:由記憶體分頁組成的雙向鏈結串列
Page 1 最舊的回滾內容 ↔ Page 2 ↔ Page 3 ↔ Page 4 最新的作用中畫面
底層的「分頁」並非單一的虛擬記憶體分頁,而是一塊對齊至分頁邊界、且大小為系統分頁偶數倍的連續記憶體區塊。2
這些分頁是透過 mmap 來配置。mmap 並不是特別快,因此為了避免頻繁的系統呼叫,我們使用了記憶體池。當需要新的分頁時,我們從池中取出;用完分頁後,則將其歸還至池中以便重複使用。
記憶體池中的分頁採用標準大小。可以想像成購買標準尺寸的運送紙箱:多數要寄送的物品都能裝進標準紙箱,而使用標準尺寸也能帶來各種效率上的好處。
但有時終端機需要的記憶體超過標準分頁所能提供的大小。如果一組行包含大量 emoji、樣式或超連結,就需要更大的分頁。在這些情況下,我們會直接透過 mmap 配置非標準分頁,完全繞過記憶體池。這通常是相當少見的情況。
兩種分頁配置方式
標準分頁(來自記憶體池)
• 固定大小
• 釋放時歸還至記憶體池
• 可供後續配置重複使用
非標準分頁(直接透過 mmap)
• 可變大小(大於標準)
• 必須呼叫 munmap 才能釋放
• 無法重複使用
當我們「釋放」一個分頁時,會套用以下簡單的邏輯:
- 如果分頁為
<= standard size:歸還至記憶體池 - 如果分頁為
> standard size:呼叫munmap來釋放
以上是 Ghostty 終端機記憶體管理的核心背景,其設計概念本身是合理的。接下來會看到,真正造成洩漏的是圍繞一項最佳化所產生的邏輯錯誤。
捲動回溯最佳化
要理解這個錯誤,還有一個背景細節需要說明:回滾緩衝的修剪(scrollback pruning)。
Ghostty 有一項 scrollback-limit 設定,用來限制保留的歷史紀錄量。當達到此上限時,我們會刪除回滾緩衝區中最舊的分頁以釋放記憶體。
但這經常發生在極度頻繁的熱路徑上(例如快速輸出大量資料時),而即使有記憶體池,配置與釋放記憶體分頁的成本仍然很高。因此,我們做了一項最佳化:當達到上限時,將最舊的分頁重複使用為最新的分頁。
回滾緩衝修剪:重複使用最舊的分頁
修剪前:已達回滾上限
Page 1 待修剪 ↔ Page 2 ↔ Page 3 ↔ Page 4
從前端移除,於後端重複使用
修剪後:分頁於末端重複使用
Page 2 現為最舊 ↔ Page 3 ↔ Page 4 ↔ Page 1 已重複使用!
這項最佳化效果很好。它不需要任何額外的配置,只需透過快速的指標操作,就能將分頁從串列前端移到後端。我們會清理部分中繼資料來「清空」該分頁,但其他先前的記憶體內容則保持不變。
它的速度很快,實測上能顯著加速大量依賴回滾緩衝的工作負載。
錯誤成因
在回滾緩衝修剪的最佳化過程中,我們總是會將分頁的大小重設回標準大小。但我們並未實際調整底層記憶體配置本身,僅在中繼資料中記錄了大小的變更。底層記憶體仍是原本較大的非標準 mmap 配置,但此時 PageList 卻認為它是標準大小。
中繼資料不同步如何造成洩漏
1 配置非標準分頁 中繼資料:2× 標準大小 mmap:標準大小 + 額外空間
2 回滾修剪並重複使用 中繼資料:std_size mmap:標準大小 + 額外空間 錯誤:中繼資料重設為 std_size,但 mmap 未改變!
3 釋放分頁 中繼資料:std_size mmap:已洩漏 標準大小,誤判為池化分頁。從未呼叫 munmap!
標準 非標準 已洩漏
最終,我們會在各種情況下釋放該分頁(例如使用者關閉終端機時,但也包含其他時機)。此時,我們會看到該分頁的記憶體大小落在標準範圍內,誤以為它是記憶體池的一部分,因而永遠不會對其呼叫 munmap。這就是典型的洩漏。
這一切看起來相當明顯,但問題在於非標準分頁在設計上本就很少見。我們的設計與最佳化目標,是讓標準分頁成為常見情況並提供快速路徑。只有在非常特定的情境下才會產生非標準分頁,而且通常不會大量產生。
但 Claude Code 的興起改變了這一點。不知何故,Claude Code 的 CLI 會產生大量 multi-codepoint grapheme(多碼位字素)輸出,迫使 Ghostty 頻繁使用非標準分頁。此外,Claude Code 會使用主畫面並產生大量的回滾輸出。這些因素加在一起,形成了觸發大規模洩漏的完美風暴。
我要特別強調,這個錯誤並非 Claude Code 的責任。Claude Code 只是以某種方式操練 Ghostty,進而暴露了這個長期存在的錯誤。
修正方式
修正的概念很簡單:永不重複使用非標準分頁。如果在回滾修剪過程中遇到非標準分頁,就將其正確銷毀(呼叫 munmap),並從記憶體池中配置一個全新的標準大小分頁。
修正的核心如下方程式碼片段所示,但我們還需要額外處理一些其他的簿記邏輯:
if (first.data.memory.len > std_size) {
self.destroyNode(first);
break :prune;
}我們原本也可以重複使用非標準分頁並保留較大的記憶體大小,但在有數據顯示應改變做法之前,我們仍維持原本的假設:標準分頁是常見情況,因此重設回標準的池化分頁是合理的。
其他使用者曾建議更複雜的策略(例如統計非標準分頁的使用頻率並據此調整假設),但在做出這類變更之前,還需要更多研究。這次的變更簡單、能修復錯誤,也符合我們目前的假設。
透過 VM 標籤找出洩漏
作為修正的一部分,我在 macOS 上新增了對 Mach 核心所提供的 virtual memory tags(虛擬記憶體標籤)的支援。這讓我們能為 PageList 的記憶體配置加上特定識別碼,並在各種工具中顯示出來。
inline fn pageAllocator() Allocator {
// In tests we use our testing allocator so we can detect leaks.
if (builtin.is_test) return std.testing.allocator;
// On non-macOS we use our standard Zig page allocator.
if (!builtin.target.os.tag.isDarwin()) return std.heap.page_allocator;
// On macOS we want to tag our memory so we can assign it to our
// core terminal usage.
const mach = @import("../os/mach.zig");
return mach.taggedPageAllocator(.application_specific_1);
}現在在 macOS 上除錯記憶體時,Ghostty 的 PageList 記憶體會以特定標籤顯示,而不會與其他所有配置混在一起。這讓我們能輕易識別洩漏、將其與 PageList 關聯起來,並透過觀察標記的記憶體是否被正確釋放來驗證修正是否生效。
預防 Ghostty 的洩漏
在 Ghostty 專案中,我們投入了大量工作來尋找並預防記憶體洩漏:
- 在除錯版本與單元測試中,我們使用具備洩漏偵測功能的 Zig 配置器。
- CI 會在每次提交時,對完整的單元測試套件執行
valgrind,以找出不只是洩漏,還包括未定義的記憶體使用等問題。 - 我們會定期透過 macOS Instruments 執行 macOS 圖形介面,以特別檢查 Swift 程式碼中的洩漏。
- 對於每個與 GTK 相關的 PR,我們都會使用 Valgrind(完整的圖形介面)來執行,以檢查未被單元測試涵蓋的 GTK 路徑中的洩漏。
迄今為止,這些做法都運作得相當好,但遺憾的是,它們未能捕捉到這次特定的洩漏,因為該洩漏僅在非常特定的條件下才會觸發,而我們的測試並未重現這些條件。已合併的 PR 中包含一個能重現此洩漏的測試,以防止未來再次發生回歸。
結論
這是 Ghostty 迄今已知最大的一次記憶體洩漏,也是唯一一個經由不只一位使用者確認回報的洩漏。我們會持續關注並處理後續的記憶體回報,但請記住,能夠重現問題是診斷與修復記憶體洩漏的關鍵!
特別感謝@grishy最終提供了可靠的重現方式,讓我能親自分析這個問題。他們自己的分析也得出了與我相同的結論,而該重現案例讓我們得以各自獨立驗證彼此的理解。
也要感謝所有提供詳細診斷資訊回報此問題的人。社群的分析,特別是關於 footprint 輸出與 VM 區域計數的探討,提供了重要線索,讓我得以將矛頭指向 PageList。
附註
隨機一篇部落格