We Rewrote the Ghostty GTK Application

Mitchell Hashimoto

我們重寫了 Ghostty GTK 應用程式

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

我們剛完成了 Ghostty GTK 應用程式的重寫,從 Zig 完整擁抱了 GObject 型別系統,並且在過程中的每一步都用 Valgrind 進行驗證。成果是在 Linux 與 BSD 上功能更豐富、更穩定、也更易於維護的 Ghostty。

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

背景

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

在 macOS 上,Ghostty 是一個由數千行 Swift 寫成的應用程式,用 Xcode 建構。在 Linux 與 BSD 上,Ghostty 則是一個由數千行程式碼組成的 GTK 應用程式,直接整合了 X11Wayland 等。將這一切串連起來的,是一個以 Zig 撰寫、非常龐大的共用核心,它對外提供與 C ABI 相容的 API。

關於 Ghostty 過去為何採用原本的架構,以及我們為何決定現在重寫 GTK 應用程式的完整動機,請參閱最初的「gtk-ng」PR。這篇文章我會聚焦在收穫與心得,而非動機。

GObject 型別系統與 Zig

不管你對物件導向和記憶體管理抱持什麼看法,現實是只要你選擇了 GTK,就勢必得用某種方式與 GObject 型別系統介接。你躲不掉。

好吧,你其實可以躲掉,而我們也真的躲掉了。結果就是得費盡心力把非參考計數物件的生命週期,和參考計數物件的生命週期綁在一起,搞得一團亂。在 Ghostty 的 GTK 應用程式中,有一整類 bug 不斷出現,基本上可以總結為:Zig 端的記憶體或 GTK 端的記憶體已經被釋放,但另一邊還沒。

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

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

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

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

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

用 Valgrind 驗證 GTK 與 Zig

這個主題本身就值得另寫一篇部落格文章。重點是,從第一個 PR 到最後一個 PR,我們都讓每一項變更和每一項 Ghostty 功能跑過 Valgrind,並處理所有發現的問題,以確保沒有記憶體洩漏、未定義的記憶體存取等問題。

在 GTK 應用程式上跑 Valgrind 相當麻煩。我們需要一個相當大的 suppression 檔案。我知道看起來很多,但其中 80% 是 GTK 本身提供的。剩下的主要是第三方函式庫和 GPU 驅動程式。其中或許有一兩個 suppression 讓我覺得可疑(也有在註解中標明)。

重要的是,我們在過程中得以找出許多原本肯定會被忽略的 bug。舉例來說,我學到如果在 dispose 期間忘記清除 GObject WeakRef,未來當目標(被參考的)物件在某個時間點(可能是幾小時、幾天後)dispose 時,就會造成未定義的記憶體存取。那樣的未定義記憶體存取有 99% 的時間剛好沒事,但偶爾就會導致當機。真有趣!Valgrind 輕鬆就找出了這個問題。

記憶體安全這個話題似乎……呃……會引發某些熱烈的討論。所以讓我先說兩件事:

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

    Zig 有一個可偵測洩漏的 debug allocator,以及各種安全檢查,但在 Ghostty 專案中只會在 debug 和測試建構時啟用。此外,Zig 還整合了 Valgrind。例如,每當你在 Zig 中將某個值設為 undefined(關鍵字)時,Zig 就會發出一個 Valgrind client request,將該段記憶體標記為未定義。這有助於找出更多問題。

    這次經驗真的讓我看到,儘管我們的 release 建構中沒有這些保護機制,這套做法確實是有效的

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

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

Zig 在記憶體安全方面提供的功能已有充分的文件說明。關於 Zig 做了什麼、沒做什麼,以及這樣是好是壞,有很多學術性或理論性的討論。那些討論很有價值,但實證結果同樣重要。這個過程展現了在一個大型、複雜、多執行緒、跨平台的 Zig 專案中,當每個功能都經過 Valgrind 嚴格檢視時的實證結果。你想從中得到什麼結論都可以,我可不想引發筆戰!

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

結論

這已經是我第五次從零開始撰寫 Ghostty 的 GUI 部分:第一次用 GLFW,第二次在 macOS 上用 SwiftUI,接著在 macOS 上用 AppKit 加 SwiftUI,第四次在 Linux 上用 GTK 以程序式的方式撰寫,而現在則是在 Linux 上使用 GTK 並完整導入 GObject 型別系統。

每一次,我都學到了新的、寶貴的東西,並將這些經驗帶到下一次的迭代中(也跨平台應用)。即使是這一次,我也學到了一些新技巧,計畫帶回 macOS 上運用。

我也想特別強調,整個 GTK 子系統的維護團隊都加入幫忙完成了這次重寫。他們也付出了大量的心力。

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

註腳

  1. Linux 的使用者一聽到我說「平台原生」就很容易激動。在 Linux 上根本沒有這種東西,但講道理的人都會同意,像 GTK 應用程式(或 Qt)這類的東西,在多數桌面環境上比其他應用程式感覺更「原生」。

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

留言