Finding and Fixing Ghostty's Largest Memory Leak

Mitchell Hashimoto

找出並修復 Ghostty 史上最大的記憶體洩漏

原文由 Mitchell Hashimoto 發布,訂閱此部落格

幾個月前,陸續有使用者回報 Ghostty 佔用了離譜的記憶體用量,其中一位使用者回報在持續執行 10 天後,記憶體用量高達 37 GB。今天,很高興能宣布修正已經找到並合併了。這篇文章將概述造成洩漏的原因、介紹 Ghostty 的部分內部運作機制,並簡要說明我們是如何追蹤到問題的。1

這個洩漏至少從 Ghostty 1.0 就已經存在,但直到最近,熱門的 CLI 應用程式(特別是 Claude Code)才開始大規模地產生觸發此洩漏所需的特定條件。正是因為觸發條件相當有限,才讓這個問題特別難以診斷。

修正已合併,目前已可在tip/nightly 版本中取得,並將包含在三月釋出的 1.3 正式版本中。


PageList

要理解這個錯誤,首先需要了解 Ghostty 如何管理終端機的記憶體。Ghostty 使用一種名為 PageList 的資料結構來儲存終端機內容。PageList 是一個由記憶體頁面組成的雙向鏈結串列,用來存放終端機的內容(字元、樣式、超連結等)。

PageList:由記憶體頁面組成的雙向鏈結串列

第 1 頁 最舊的捲動緩衝區 ↔ 第 2 頁 ↔ 第 3 頁 ↔ 第 4 頁 最新的作用中畫面

底層的「頁面」並非單一的 虛擬記憶體分頁,而是一塊對齊至分頁邊界、且大小為系統分頁整數倍的連續記憶體區塊。2

這些頁面是透過 mmap 來配置的。mmap 的速度並不特別快,因此為了避免頻繁的系統呼叫,我們使用了一個 記憶體池。需要新頁面時,就從池中取出;用完頁面後,則歸還至池中以便重複使用。

記憶體池中的頁面採用 標準大小。可以想像成購買標準尺寸的紙箱:大多數要寄送的物品都能裝進標準紙箱,而使用統一規格也能帶來各種效率上的好處。

但有時候終端機需要的記憶體超過了標準頁面所能提供的大小。如果一組行包含大量的 emoji、樣式或超連結,就需要更大的頁面。在這種情況下,我們會直接透過 mmap 配置一個 非標準頁面,完全跳過記憶體池。這通常是相當少見的情況。

兩種頁面配置方式

標準頁面(來自記憶體池)

• 固定大小

• 釋放時歸還至記憶體池

• 可供未來配置重複使用

非標準頁面(直接透過 mmap)

• 大小可變(大於標準大小)

• 必須呼叫 munmap 才能釋放

• 無法重複使用

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

  1. 如果頁面是 <= standard size:歸還至記憶體池
  2. 如果頁面是 > standard size:呼叫 munmap 來釋放

以上就是 Ghostty 終端機記憶體管理的核心背景知識,其設計概念本身是合理的。接下來我們會看到,真正造成洩漏的是一個與最佳化相關的邏輯錯誤。


捲動緩衝區的最佳化

要理解這個錯誤,還有一個背景細節需要說明:捲動緩衝區的修剪(scrollback pruning)。

Ghostty 有一個 scrollback-limit 設定,用來限制保留的歷史紀錄數量。當達到這個上限時,我們會刪除捲動緩衝區中最舊的頁面以釋放記憶體。

但這個操作經常發生在極為頻繁的熱路徑上(例如快速輸出大量資料時),即使有記憶體池,配置與釋放記憶體頁面的成本仍然很高。因此,我們做了一項最佳化:當達到上限時,將最舊的頁面重複利用為最新的頁面

捲動緩衝區修剪:重複利用最舊的頁面

修剪前:已達捲動緩衝區上限

第 1 頁 將被修剪 ↔ 第 2 頁 ↔ 第 3 頁 ↔ 第 4 頁

從前端移除,並在後端重複利用

修剪後:頁面在末端被重複利用

第 2 頁 現在為最舊 ↔ 第 3 頁 ↔ 第 4 頁 ↔ 第 1 頁 已重複利用!

這項最佳化的效果很好。它不需要任何新的配置,只需透過快速的指標操作,就能將頁面從串列前端移到後端。我們會清理一些中繼資料來「清空」該頁面,但基本上會保留原有的記憶體內容。

它的速度很快,且根據實際測試,能顯著提升大量捲動緩衝區操作的工作負載效能。


錯誤成因

在執行捲動緩衝區修剪的最佳化時,我們總是會 將頁面大小重設回標準大小。但我們並沒有真正調整底層的記憶體配置,只是在中繼資料中記錄了大小的變更。底層的記憶體仍然是原本透過 mmap 配置的大型非標準配置,但此時 PageList 卻認為它是標準大小。

中繼資料不同步如何造成洩漏

1 配置非標準頁面 中繼資料:2× 標準大小 mmap:標準大小 + 額外空間

2 捲動緩衝區修剪並重複利用 中繼資料:std_size mmap:標準大小 + 額外空間 錯誤:中繼資料被重設為 std_size,但 mmap 保持不變!

3 釋放頁面 中繼資料:std_size mmap:已洩漏 標準大小,誤以為來自記憶體池。從未呼叫 munmap!

標準 非標準 已洩漏

最終,我們會在各種情況下釋放該頁面(例如使用者關閉終端機時,但也不僅限於此)。此時,我們會看到該頁面的記憶體大小落在標準大小範圍內,便誤以為它是記憶體池的一部分,因而永遠不會對它呼叫 munmap。這就是一個典型的洩漏。

這一切看起來似乎相當明顯,但問題在於,非標準頁面在設計上本來就很少見。我們的設計與最佳化的目標,就是讓標準頁面成為常見情況並提供快速路徑。只有在非常特定的情境下才會產生非標準頁面,而且通常不會大量產生。

但 Claude Code 的興起改變了這一點。不知為何,Claude Code 的 CLI 會產生大量由多個碼點(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 核心所提供的虛擬記憶體標籤的支援。這讓我們能為 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 配置器(allocator)。
  • CI 會在每次提交時,對完整的單元測試套件執行 valgrind,不僅檢查洩漏,也會找出未定義的記憶體使用等問題。
  • 我們會定期透過 macOS Instruments 執行 macOS 圖形介面,特別檢查 Swift 程式碼中的洩漏。
  • 對於每個與 GTK 相關的 PR,我們都會使用 Valgrind 執行完整的圖形介面,以檢查未被單元測試涵蓋的 GTK 路徑是否有洩漏。

截至目前,這些措施都運作得相當順利,但遺憾的是,它們並未捕捉到這次的洩漏,因為這個洩漏只會在非常特定的條件下才會觸發,而我們的測試並未重現這些條件。已合併的 PR 中包含了一個能重現此洩漏的測試,以防止未來再次發生回歸。


結論

這是 Ghostty 迄今為止已知最大的記憶體洩漏,也是唯一一個獲得多位使用者確認回報的洩漏。我們會持續關注並處理後續的記憶體回報,但請記住,能夠重現問題,才是診斷與修正記憶體洩漏的關鍵!

特別感謝 @grishy,他最終提供了一個可穩定重現的案例,讓我能親自分析這個問題。他自己的分析也得出了與我相同的結論,而這個重現案例讓我們能各自獨立地驗證彼此的理解。

也感謝所有提供詳細診斷資訊回報此問題的人。社群的分析,特別是圍繞 footprint 輸出與 VM 區域計數的討論,為我提供了重要的線索,讓我得以將 PageList 鎖定為罪魁禍首。

註腳

  1. 這篇文章撰寫時並未使用 AI。AI 僅用於協助製作部分圖表,且所有圖表皆已由人工審核其正確性。文章的文字內容皆非由 AI 產生。

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

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

留言