Finding and Fixing Ghostty's Largest Memory Leak

Mitchell Hashimoto

寻找并修复 Ghostty 有史以来最大的内存泄漏

原文由 Mitchell Hashimoto 发布,订阅该博客

几个月前,用户开始报告 Ghostty 占用了离谱的内存,有用户反馈在连续运行 10 天后内存占用达到了 37 GB。今天,很高兴地说修复已经找到并合并。本文将概述泄漏产生的原因,介绍 Ghostty 的部分内部机制,并简要说明我们是如何追踪到问题的。1

该泄漏至少从 Ghostty 1.0 起就已存在,但直到最近,一些流行的命令行应用(尤其是 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 的命令行会产生大量多码点字形输出,迫使 Ghostty 频繁使用非标准页。此外,Claude Code 会使用主屏幕并产生大量的回滚输出。这些因素叠加在一起,形成了大规模触发该泄漏的完美风暴。

我想特别说明,这个缺陷不是 Claude Code 的错。Claude Code 只是以一种暴露了这个长期存在的缺陷的方式在使用 Ghostty。


修复方案

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

修复的核心如下面的代码片段所示,不过还有一些额外的簿记工作需要修正:

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

我们本也可以重用非标准页并保留其较大的内存大小,但在拿到能推翻现有假设的数据之前,我们仍然认为标准页是常见情况,重置回标准的池化页是合理的。

也有用户建议了更复杂的策略(例如统计非标准页的使用频率并据此调整我们的假设),但在做出这类改动之前还需要更多研究。这次改动简单、修复了缺陷,也符合我们目前的假设。


利用 VM 标签定位泄漏

作为修复的一部分,我还为 Mach 内核在 macOS 上提供的虚拟内存标签添加了支持。这让我们可以用特定的标识来标记 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 图形界面,特别是在 Swift 代码库中查找泄漏。
  • 对于每一个与 GTK 相关的 PR,我们都会使用 Valgrind 运行完整的图形界面,以查找未经单元测试覆盖的 GTK 路径中的泄漏。

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


结语

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

特别感谢 @grishy,他最终提供了一个可靠的复现,让我得以亲自分析该问题。他的分析与我的结论一致,而这个复现也让我能够独立验证我们各自的理解。

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

脚注

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

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

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

评论