🏠

查找并修复 Ghostty 最大的内存泄漏

Table of Contents

几个月前,用户开始报告 Ghostty 消耗了惊人的内存量——有位用户报告运行 10 天后占用了 37 GB 。今天,我很高兴地说修复已经找到并合入了。这篇文章 概述了泄漏的根因、Ghostty 内部机制的一些细节,以及我们如何追踪到这个问 题的简要说明。1

这个泄漏至少从 Ghostty 1.0 就存在了,但直到最近,流行的 CLI 应用(特别 是 Claude Code)才开始大规模产生触发它的合适条件。正是触发泄漏的苛刻 条件让这个问题特别难以诊断。

修复已经合入,可以在 tip/nightly 版本 中获得, 并将成为 3 月发布的 1.3 正式版的一部分。

PageList

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

pagelist.png

Figure 1: PageList:一个由内存页组成的双向链表

这里的"页"并不是单个 虚拟内存页, 而是一个按页边界对齐的连续内存块,由若干个系统页的整数倍组成。2

这些页通过 mmap 来分配。 mmap 速度不是特别快,所以为了避免频繁的系统调 用,我们使用了一个 内存池 。当我们需要新页时,就从池中取;用完一页后, 就将其归还给池以供复用。

池中的页使用 标准尺寸 。可以把它想象成购买标准尺寸的快递盒:大多数人 寄的东西都能装进标准盒子里,而使用标准尺寸能带来各种效率提升。

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

page-types.png

Figure 2: 两种页面分配方式

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

  1. 如果页大小 <= 标准尺寸 :归还给池
  2. 如果页大小 > 标准尺寸 :调用 munmap 释放它

这就是 Ghostty 终端内存管理的核心背景,这个设计思想本身是没有问题的。 泄漏是由一项优化的逻辑 bug 引发的,我们接下来就会看到。

回滚裁剪优化

要理解这个 bug,我们还需要了解一个背景细节:回滚裁剪(scrollback pruning)。

Ghostty 有一个 scrollback-limit 配置,用于限制保留的历史记录量。当 达到这个限制时,我们会删除回滚缓冲区中最旧的页来释放内存。

但这经常发生在超热路径中(比如快速输出大量数据时),而分配和释放内存页 的开销很大,即使有内存池也一样。因此,我们有一个优化: 达到限制时,将 最旧的页重用为最新的页

scrollback-pruning.png

Figure 3: 回滚裁剪:重用最旧的页

这个优化效果很好。它不需要任何分配,只需少量快速的指针操作就能将页 从链表头部移到尾部。我们会对元数据做一些清理来"清空"该页,但除此之外 保留原有内存不变。

它很快,并且经验证能显著加速回滚密集的工作负载。

Bug 分析

在回滚裁剪优化过程中,我们总是*将页大小重置为标准尺寸*。但我们并没有实 际调整底层内存分配的大小,只是在元数据中记录了尺寸变更。底层内存仍然是 那个大的非标准 mmap 分配,但现在 PageList 以为 它是标准尺寸的。

bug-sequence.png

Figure 4: 元数据与实际不符是如何导致泄漏的

最终,我们会在各种情况下释放该页(比如用户关闭终端,但还有其他场景)。 此时,我们看到页内存大小在标准尺寸以内,就认为它属于池的一部分,于是 永远不会对其调用 munmap 。经典的内存泄漏。

这一切看起来很明显,但问题在于非标准页在设计上就是罕见的。我们设计和 优化的目标是让标准页成为常见情况并提供快速路径。只有非常特定的场景 才会产生非标准页,而且通常不会大量产生。

Claude Code 的兴起改变了这一点。出于某种原因,Claude Code 的 CLI 产生了大量的 多码位字形(multi-codepoint grapheme)输出,这迫使 Ghostty 频繁使用 非标准页。此外,Claude Code 使用主屏幕并产生大量的回滚输出。这些因素 共同构成了完美的风暴,大规模地触发了泄漏。

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

修复方案

修复方案在概念上很简单: 永远不要重用非标准页 。如果在回滚裁剪时遇到非 标准页,我们就正确地销毁它(调用 munmap ),并从池中分配一个新的标准尺 寸页。

修复的核心在下面的代码片段中,但我们还需要做一些额外的工作来修正一些 记账逻辑:

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

我们也可以选择重用非标准页并保留较大的内存大小,但在有数据证明之前, 我们仍然基于标准页是常见情况的假设来操作,将其重置为标准的池中页是 合理的。

其他用户建议了更复杂的策略(比如维护一些指标来跟踪非标准页的使用频率, 并相应地调整假设),但在做这些改变之前还需要更多研究。这个改动很简单, 修复了 bug,并且符合我们当前的假设。

利用 VM 标签定位泄漏

作为修复的一部分,我为 macOS 添加了虚拟内存标签的支持,这是由 Mach 内核提供的功能。这让我们可以为 PageList 的内存分配打上特定的标识符, 这个标识符可以在各种工具中显示。

inline fn pageAllocator() Allocator {
    // 在测试中,我们使用测试分配器以便检测泄漏。
    if (builtin.is_test) return std.testing.allocator;

    // 在非 macOS 上,我们使用标准的 Zig 页分配器。
    if (!builtin.target.os.tag.isDarwin()) return std.heap.page_allocator;

    // 在 macOS 上,我们给内存打标签,以便将其归类为核心终端使用。
    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 是 罪魁祸首。

脚注

Footnotes:

1

本文不使用 AI 撰写。AI 被用来协助部分图表,但所有图表都经过了 人工正确性审核。文本内容均非 AI 生成。

2

其原因对于本博文不重要,但它本身是一个有趣的细节。