We Rewrote the Ghostty GTK Application

Mitchell Hashimoto

我們重寫了 Ghostty GTK 應用程式

我們剛剛完成了以 Zig 全面採用 GObject type system(GObject 型別系統)重寫 Ghostty GTK 應用程式,並在每個步驟都透過 Valgrind 驗證。成果是在 Linux 和 BSD 上功能更豐富、更穩定且更易於維護的 Ghostty。

這個過程中有許多有趣的技術主題,但我想聚焦在兩點:(1) 從 Zig 介接 GObject type system,以及 (2) 使用 Valgrind 驗證 GTK 應用程式,並反思 Valgrind 在 Zig 程式碼庫中找到的記憶體問題。

背景

首先,快速介紹一下背景。Ghostty 是一款跨平台(macOS、Linux、FreeBSD)的終端機模擬器。Ghostty 與其他跨平台終端機模擬器不同的地方在於,它為每個平台使用平台原生的應用程式或 GUI 框架1

在 macOS 上,Ghostty 是以 Xcode 打造、由數千行程式碼組成的 Swift 應用程式。在 Linux 和 BSD 上,Ghostty 是直接整合 X11Wayland 等、由數千行程式碼組成的 GTK 應用程式。將這一切串連起來的是以 Zig 撰寫、規模龐大的 共用核心,它匯出了與 C ABI 相容的 API。

如需了解 Ghostty 先前為何採用原本的架構,以及我們為何決定現在重寫 GTK 應用程式的完整動機,請參閱最初的 「gtk-ng」PR。本文將聚焦於心得收穫,而非動機。

GObject Type System 與 Zig

無論你對 OOP 和記憶體管理有何看法,現實是如果你選擇 GTK,就必定得用某種方式與 GObject type system 介接。你無法避開它。

當然,你其實可以避開它——而我們也確實避開了。結果卻造成一團混亂,得設法將非參考計數物件的生命週期與參考計數的物件綁在一起。在 Ghostty GTK 應用程式中,反覆出現一整類錯誤,基本上可以歸納為:Zig 端的記憶體或 GTK 端的記憶體已被釋放,但另一端沒有。

除了正確性問題之外,避開物件系統也讓我們無法使用 GTK 原生功能,例如 signals(事件)、properties(可被 GUI 元件綁定的屬性)、actions(可從遠端觸發單向行為的動作)等。

我們來看一個具體範例:可重新載入的設定。Ghostty 中的設定由 Zig 擁有的 Config 結構體來表示。GUI 的許多不同部分都需要感知設定:視窗、分頁、選單、分割畫面等。

重新載入設定曾是一項複雜、(相對)耗費 CPU 且容易出錯的任務,因為我們必須確保整個 GUI 都已更新,才能釋放舊的 Config

現在,Zig 的 Config 結構體被包裝在參考計數的 GhosttyConfig GObject 中。當我們重新載入設定時,只需覆寫我們的屬性,並讓 GObject 的屬性變更通知系統在應用程式中擴散(有時會橫跨多個事件迴圈週期)。當舊設定不再有任何參照時,就會被釋放。概念上簡單許多。

除了記憶體管理之外,我們現在也能更輕鬆地建立自訂的 GTK 元件。這讓我們得以全面採用現代的 GTK UI 技術,例如 Blueprint。舉例來說,這是我們的終端機視窗 Blueprint 檔案。這已經讓我們更容易引入 GUI 功能,例如新的 GTK 標題列分頁選項、響鈴時的動畫邊框等。

Valgrind 與 GTK 和 Zig

這個主題本身就值得另寫一篇完整的部落格文章。簡而言之,從第一個 PR 到最後一個,我們對每一項變更與每一項 Ghostty 功能都透過 Valgrind 執行並處理所有問題,以確保沒有記憶體洩漏、未定義的記憶體存取等問題。

在 GTK 應用程式上執行 Valgrind 相當棘手。我們需要一個相當龐大的 suppression file(抑制檔)。我知道內容很多,但其中 80% 是 GTK 本身提供的。剩下的主要是第三方函式庫和 GPU 驅動程式。其中或許有一兩個抑制設定讓我覺得可疑(並已如此註記)。

重要的是,我們在過程中得以找出不少原本肯定會被忽略的錯誤。舉例來說,我學到如果在 dispose 期間忘記清除 GObject WeakRef,將來某個時間點(可能是數小時、數天後!)當目標(被參照的)物件進行 dispose 時,就會造成未定義的記憶體存取。而這種未定義的記憶體存取有 99% 的時間看似正常,但偶爾會導致當機。真是有趣!Valgrind 毫無困難地找出了這個問題。

記憶體安全性似乎……呃……會引發某些討論。所以我想說兩件事:

  1. 我們的 Zig 程式碼庫只有一個洩漏和一個未定義的記憶體存取。這讓我非常驚訝(是好的那種驚訝)。我們的 Zig 程式碼庫龐大、複雜,並使用了大量為了效能而設計的記憶體技巧,很容易導致不安全的行為。老實說,我以為會有更多問題。此外,發現的那一個洩漏是在呼叫第三方 C API 期間發生的(所以 Zig 無法偵測到)。所以這是一次巨大的成功。

    Zig 擁有可偵測洩漏的除錯配置器和各種安全檢查,在 Ghostty 專案中僅於 debug 和測試建置中啟用。此外,Zig 也與 Valgrind 整合。例如,每當你在 Zig 中將某個值設為 undefined(關鍵字)時,Zig 就會發出 Valgrind client request,將該記憶體標記為未定義。這有助於找出更多問題。

    這次經驗確實讓我看到,這套機制是有效的,即使在我們的正式建置中並未啟用任何這些保護。

  2. 所有其他記憶體問題都圍繞在 C API 邊界。我們發現的其他所有問題(有數十個)都直接與 GObject 系統中複雜的生命週期或 C API 邊界有關。我在此得到的結論是,要安全地跨 C API 呼叫(即使對方並非以 C 撰寫),你絕對需要像 Valgrind 這樣的工具。

    對於大多數提供 C API 的複雜函式庫而言,C API 代表著物件生命週期被轉移或變得模糊的邊界。無論你使用何種語言與之互動,能獲得的安全性僅取決於你對 API 語意理解的程度,以及能否寫出良好的包裝層。

Zig 圍繞記憶體安全所提供的功能已有完善的文件記載。關於 Zig 做了什麼、沒做什麼,以及這樣是好是壞,有許多學術或理論上的討論。這些討論固然有價值,但實證結果同樣重要。這個過程展現了來自一個大型、複雜、多執行緒、跨平台的 Zig 專案的實證結果,其中每一項功能都在 Valgrind 的嚴格檢視下執行。各位自行解讀,我不想引發任何論戰!

未來,我計畫繼續讓每一個 GTK PR 都在 Valgrind 下執行,並改進我們的專案文件,讓維護者與貢獻者也能這麼做(我們已經有幾位開始執行了!)。

結論

這已經是我第 5 次從零開始撰寫 Ghostty 的 GUI 部分:一次使用 GLFW,一次在 macOS 上使用 SwiftUI,接著在 macOS 上使用 AppKit 加上 SwiftUI,一次在 Linux 上以程序式方式使用 GTK,現在則是在 Linux 上使用 GTK 與完整的 GObject type system。

每一次,我都學到了新的寶貴經驗,並將這些經驗帶入每一次的迭代(並跨平台應用)。即使是這一次,我也學到了一些新技巧,打算帶回 macOS 運用。

我也想特別強調,整個 GTK 子系統維護團隊都加入協助完成了這次重寫。他們也付出了許多努力。

全新重寫的 Ghostty GTK 應用程式現在已是在 main 分支上從原始碼建置 Ghostty 時的預設版本,並將在幾週後推出的 1.2 版本中提供給所有使用者。

註腳

  1. 當我說「platform-native(平台原生)」時,Linux 使用者往往會很激動。在 Linux 上根本沒有這種東西,但理性的人都會同意,像 GTK 應用程式(或 Qt)這類的東西,在大多數桌面環境中,比起其他應用程式更讓人感覺是「原生的」。

原文由 Mitchell Hashimoto 發布

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