我们重写了 Ghostty 的 GTK 应用程序
我们刚刚完成了 Ghostty GTK 应用程序的重写,全面地从 Zig 拥抱 GObject type system(GObject 类型系统),并且全程使用 Valgrind 进行验证。最终成果是 Linux 和 BSD 上一个功能更丰富、更稳定、更易维护的 Ghostty。
这个过程中有许多有趣的技术话题,但我想聚焦其中两个:(1) 如何从 Zig 与 GObject 类型系统交互,以及 (2) 如何用 Valgrind 验证 GTK 应用,并反思 Valgrind 在 Zig 代码库中发现的内存问题。
背景
先简单介绍一下背景。Ghostty 是一款跨平台(macOS、Linux、FreeBSD)的终端模拟器。Ghostty 与其他跨平台终端模拟器的不同之处在于,它在每个平台上都使用平台原生的应用程序或 GUI 框架1。
在 macOS 上,Ghostty 是一个用 Xcode 构建的数千行代码的 Swift 应用程序。在 Linux 和 BSD 上,Ghostty 是一个数千行代码的 GTK 应用程序,直接集成了 X11、Wayland 等。将这一切串联起来的,是一个用 Zig 编写的非常庞大的共享核心,它导出兼容 C ABI 的 API。
关于 Ghostty 之前为何采用那样的设计,以及我们为何决定现在重写 GTK 应用程序的完整动机,请参阅最初的 “gtk-ng” PR。本文将聚焦于收获,而不是动机。
GObject 类型系统与 Zig
无论你对 OOP 和内存管理持何种看法,现实是:一旦选择了 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 到最后一个,我们对每一处更改和每个 Ghostty 功能都运行了 Valgrind,并解决了所有问题,以确保不存在内存泄漏、未定义内存访问等问题。
在 GTK 应用上运行 Valgrind 相当麻烦。我们需要一个相当大的抑制文件。我知道它很大,但其中 80% 由 GTK 自身提供。其余的主要来自第三方库和 GPU 驱动。也许有一两条抑制项让我觉得可疑(并已注释说明)。
重要的是,我们在过程中发现了一些肯定会被漏掉的 bug。例如,我了解到如果你忘记在 dispose 期间清除 GObject 的 WeakRef,就会导致目标(被引用)对象在未来某个时刻 dispose 时发生未定义内存访问(可能是几小时甚至几天之后!)。这种未定义内存访问 99% 的情况下恰好没事,但偶尔会引发崩溃。真有趣!Valgrind 轻松发现了这个问题。
内存安全似乎……呃……会激活某些争论。所以让我说两点:
我们的 Zig 代码库只有一个泄漏和一个未定义内存访问。这真的让我非常惊讶(而且是惊喜)。我们的 Zig 代码库庞大而复杂,为了性能使用了大量内存技巧,很容易导致不安全的行为。说实话,我原本以为问题会多得多。此外,发现的那一个泄漏发生在调用第三方 C API 时(因此 Zig 无法检测到)。所以这是一个巨大的成功。
Zig 提供了一个能检测泄漏的调试分配器以及各种安全检查,它们只在 Ghostty 项目的 debug 和 test 构建中启用。此外,Zig 还与 Valgrind 有集成。例如,每当你在 Zig 中将某个值设为
undefined(关键字)时,Zig 会发出一条 Valgrind 客户端请求,将该内存标记为未定义。这有助于发现更多问题。这次经历真正让我看到这套机制是行之有效的,尽管我们的发布构建中并没有这些保护。
所有其他内存问题都围绕着 C API 边界。我们发现的所有其他问题(有几十个)都直接出现在 GObject 系统复杂的生命周期中,或者位于 C API 边界上。我的结论是:要安全地进行跨 C API 调用,你绝对需要像 Valgrind 这样的工具(即使这些 API 不是用 C 编写的)。
对于大多数暴露 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 版本中交付给所有用户。
脚注
当我提到“平台原生”时,Linux 社区的人会非常激动。Linux 上并不存在这样的东西,但讲道理的人都会同意,与其他应用相比,GTK 应用(或 Qt)在大多数桌面环境上给人的感觉是“原生”的。↩
随机一篇博客