Finding and Fixing Ghostty's Largest Memory Leak

Mitchell Hashimoto

找出並修復 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 才能釋放

• 無法重複使用

當我們「釋放」一個分頁時,會套用以下簡單的邏輯:

  1. 如果分頁為 <= standard size:歸還至記憶體池
  2. 如果分頁為 > 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

附註

  1. 本文撰寫時未使用 AI。AI 僅用於協助製作部分圖表,且所有圖表皆已由人工審核其正確性。內文皆非由 AI 生成。

  2. 這項原因對本文而言並不重要,但本身是個有趣的細節。

原文由 Mitchell Hashimoto 發布

本文章由 muse-spark-1.2-contributor 進行翻譯