Finding and Fixing Ghostty's Largest Memory Leak

Mitchell Hashimoto

发现并修复 Ghostty 迄今最大的内存泄漏

几个月前,用户开始报告 Ghostty 占用了惊人的内存,有一位用户报告在运行 10 天后占用高达 37 GB。今天,我很高兴地宣布修复方案已找到并合并。本文将概述导致泄漏的原因、介绍 Ghostty 的部分内部机制,并简要说明我们如何追踪到该问题。1

该泄漏至少自 Ghostty 1.0 起就已存在,但直到最近,随着热门的 CLI 应用(尤其是 Claude Code)开始大规模产生触发该泄漏所需的特定条件,这一问题才凸显出来。触发条件非常有限,这也使得诊断变得格外棘手。

修复已合并,可在tip/nightly 版本中获取,并将包含在 3 月发布的 1.3 正式版本中。


PageList

要理解这个缺陷,首先需要了解 Ghostty 如何管理终端内存。Ghostty 使用一种名为PageList的数据结构来存储终端内容。PageList 是一个由存储终端内容(字符、样式、超链接等)的内存页组成的双向链表。

PageList:由内存页组成的双向链表

第 1 页 最旧的回滚内容 ↔ 第 2 页 ↔ 第 3 页 ↔ 第 4 页 最新的活动屏幕

底层的“页”并非单个虚拟内存页,而是一块按页边界对齐、大小为系统页整数倍的连续内存。2

这些页通过 mmap 分配。mmap 的速度并不快,因此为了避免频繁的系统调用,我们使用内存池。当需要新页时,我们从池中取出;用完后,再将其归还到池中以便复用。

池中的页采用标准大小。可以把它想象成购买标准尺寸的快递箱:大多数需要寄送的物品都能装进标准箱,而使用标准箱也带来了各种效率优势。

但有时终端需要的内存会超过标准页所能提供的。如果一组行包含大量 emoji、样式或超链接,就需要更大的页。在这些情况下,我们会直接通过 mmap 分配非标准页,完全绕过内存池。这通常是比较少见的情况。

两种页分配类型

标准页(来自内存池)

• 固定大小

• 释放时归还至内存池

• 可供后续分配复用

非标准页(直接 mmap)

• 大小可变(大于标准尺寸)

• 必须调用 munmap 释放

• 无法复用

当我们“释放”一页时,会应用以下简单逻辑:

  1. 如果页大小 <= 标准大小:将其归还至内存池
  2. 如果页大小 > 标准大小:调用 munmap 释放

以上是 Ghostty 终端内存管理的核心背景,其设计思路本身是合理的。正如接下来将看到的,泄漏是由围绕某项优化的逻辑缺陷所导致的。


回滚优化

要理解该缺陷,还需要了解另一个背景细节:回滚裁剪。

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 会产生大量多码点字形输出,迫使 Ghostty 频繁使用非标准页。此外,Claude Code 使用主屏幕并产生大量的回滚输出。这些因素叠加在一起,形成了触发大规模泄漏的完美风暴。

我想明确指出,这个缺陷并非 Claude Code 的过错。Claude Code 只是以一种暴露这一长期存在缺陷的方式在使用 Ghostty。


修复方案

修复方案在概念上很简单:永不重用非标准页。如果在回滚裁剪过程中遇到非标准页,我们会将其正确销毁(调用 munmap)并从池中分配一个全新的标准大小页。

修复的核心如下面的代码片段所示,但我们还需要做一些额外工作来修正其他相关的统计逻辑:

if (first.data.memory.len > std_size) {
    self.destroyNode(first);
    break :prune;
}

我们本也可以重用非标准页并保留其较大的内存大小,但在有数据证明并非如此之前,我们仍然基于标准页为常见情况这一假设,认为重置回标准池化页是合理的。

其他用户建议了更复杂的策略(例如维护非标准页使用频率的指标并据此调整假设),但在做出这些改变之前还需要更多研究。这次改动简单、修复了缺陷,且符合我们当前的假设。


通过 VM Tag 定位泄漏

作为修复的一部分,我添加了对 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 分配器。
  • CI 会在每次提交时对完整的单元测试套件运行 valgrind,以发现除泄漏之外的更多问题,例如未定义内存使用。
  • 我们会定期通过 macOS Instruments 运行 macOS GUI,以查找泄漏,尤其是在 Swift 代码库中。
  • 对于每一个与 GTK 相关的 PR,我们都会使用 Valgrind(完整 GUI)来运行,以查找未经单元测试的 GTK 路径中的泄漏。

迄今为止,这些措施都运行得很好,但遗憾的是,它们未能捕捉到这次特定的泄漏,因为该泄漏仅在我们的测试未能复现的非常特定的条件下才会触发。已合并的 PR 中包含了一个能够复现该泄漏的测试,以防止未来出现回归。


结论

这是迄今为止 Ghostty 中已知最大的内存泄漏,也是唯一一个被多名用户确认报告的泄漏。我们将继续关注并处理收到的内存问题报告,但请记住,复现是诊断和修复内存泄漏的关键!

特别感谢 @grishy,他最终为我提供了一个可靠的复现用例,使我能够亲自分析该问题。他自己的分析得出了与我相同的结论,而该复现用例让我得以独立验证我们双方的理解。

同时感谢所有提供了详细诊断信息报告此问题的用户。社区的分析,特别是围绕 footprint 输出和 VM 区域计数的分析,为我提供了重要线索,将矛头指向了 PageList。

脚注

  1. 本文写作未使用 AI。AI 曾协助制作部分图表,但均已由人工审核其正确性。所有文本内容均非 AI 生成。

  2. 这一原因对于本文并不重要,但本身是一个有趣的细节。

原文由 Mitchell Hashimoto 发布

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