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 原生的特性,例如信号(事件)、属性(可被 GUI 元素绑定)、动作(可从远处触发单向行为)等等。

来看一个具体的例子:可重载的配置。Ghostty 中的配置由一个归 Zig 所有的 Config 结构体表示。GUI 的许多不同部分都需要感知配置:窗口、标签页、菜单、分屏等等。

重载配置曾是一项复杂、相对消耗 CPU 且容易出错的任务,因为我们必须确保整个 GUI 都已更新完毕,才能释放旧的 Config

现在,Zig 的 Config 结构体被封装在带引用计数的 GhosttyConfig GObject 中。重载配置时,我们只需覆盖对应的属性,让 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 客户端请求,将对应内存标记为未定义。这有助于发现更多问题。

    这次经历让我切实看到,尽管我们的 release 构建中没有任何这些保护,这些机制依然是有效的

  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 类型系统。

每一次,我都学到了新的、宝贵的东西,并将这些经验带入下一次迭代(也跨平台复用)。即使是这一次,我也学到了一些新技巧,计划带回到 macOS 上。

我还想特别强调,整个 GTK 子系统的维护团队都加入进来,一起完成了这次重写。他们也付出了大量工作。

全新重写后的 Ghostty GTK 应用现在已成为你在 main 分支上从源码构建 Ghostty 时的默认版本,并将在几周后发布的 1.2 版本中推送给所有用户。

脚注

  1. 当我说“平台原生”时,Linux 用户往往会很激动。在 Linux 上其实不存在所谓“原生”一说,但通情达理的人都会认同,像 GTK 应用(或 Qt)之类的在大多数桌面环境中,比其他应用更让人感觉“原生”。

本文章由 muse-spark-1.2-contributor 进行翻译

评论