查找并修复 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 是一个由内存页组成的双向链表, 这些内存页存储着终端内容(字符、样式、超链接等)。
Figure 1: PageList:一个由内存页组成的双向链表
这里的"页"并不是单个 虚拟内存页, 而是一个按页边界对齐的连续内存块,由若干个系统页的整数倍组成。2
这些页通过 mmap 来分配。 mmap 速度不是特别快,所以为了避免频繁的系统调
用,我们使用了一个 内存池 。当我们需要新页时,就从池中取;用完一页后,
就将其归还给池以供复用。
池中的页使用 标准尺寸 。可以把它想象成购买标准尺寸的快递盒:大多数人 寄的东西都能装进标准盒子里,而使用标准尺寸能带来各种效率提升。
但有时终端需要的内存超过标准页提供的容量。如果一组行包含很多 emoji、样
式或超链接,我们就需要更大的页。在这些情况下,我们直接用 mmap 分配 非
标准页 ,完全绕过内存池。这通常是一种罕见的情况。
Figure 2: 两种页面分配方式
当我们"释放"一页时,我们应用以下简单逻辑:
- 如果页大小
<=标准尺寸:归还给池 - 如果页大小
> 标准尺寸:调用munmap释放它
这就是 Ghostty 终端内存管理的核心背景,这个设计思想本身是没有问题的。 泄漏是由一项优化的逻辑 bug 引发的,我们接下来就会看到。
回滚裁剪优化
要理解这个 bug,我们还需要了解一个背景细节:回滚裁剪(scrollback pruning)。
Ghostty 有一个 scrollback-limit 配置,用于限制保留的历史记录量。当
达到这个限制时,我们会删除回滚缓冲区中最旧的页来释放内存。
但这经常发生在超热路径中(比如快速输出大量数据时),而分配和释放内存页 的开销很大,即使有内存池也一样。因此,我们有一个优化: 达到限制时,将 最旧的页重用为最新的页 。
Figure 3: 回滚裁剪:重用最旧的页
这个优化效果很好。它不需要任何分配,只需少量快速的指针操作就能将页 从链表头部移到尾部。我们会对元数据做一些清理来"清空"该页,但除此之外 保留原有内存不变。
它很快,并且经验证能显著加速回滚密集的工作负载。
Bug 分析
在回滚裁剪优化过程中,我们总是*将页大小重置为标准尺寸*。但我们并没有实
际调整底层内存分配的大小,只是在元数据中记录了尺寸变更。底层内存仍然是
那个大的非标准 mmap 分配,但现在 PageList 以为 它是标准尺寸的。
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,并且符合我们当前的假设。
Ghostty 中的泄漏预防
我们在 Ghostty 项目中做了大量工作来发现和防止内存泄漏:
- 在调试构建和单元测试中,我们使用能够检测泄漏的 Zig 分配器。
- CI 在每次提交时对我们的完整单元测试套件运行
valgrind,以便发现比泄 漏更多的问题,比如未定义内存使用。 - 我们定期通过 macOS Instruments 运行 macOS GUI 来查找泄漏,特别是在 Swift 代码中。
- 我们对每个 GTK 相关的 PR 使用 Valgrind(完整 GUI)来查找 GTK 代码 路径中未经单元测试的泄漏。
到目前为止,这些措施效果很好,但不幸的是它们没有捕捉到这个特定的泄漏, 因为它只在非常特定的条件下才会触发,而我们的测试没有复现这些条件。 合并的 PR 中包含了能复现该泄漏的测试,以防止未来出现回退。
结语
这是迄今为止 Ghostty 中已知的最大的内存泄漏,也是唯一一个被不止一个 用户确认的报告泄漏。我们将继续监控并处理收到的内存报告,但请记住, 复现是诊断和修复内存泄漏的关键!
非常感谢 @grishy, 他终于给了我一个可靠的复现方法,让我可以自己分析问题。他自己的分析得出 了与我相同的结论,而复现让我能够独立验证我们双方的判断。
也感谢所有报告此问题并提供详细诊断信息的人。社区的分析,特别是围绕
footprint 输出和 VM 区域计数,给了我重要的线索,指向 PageList 是
罪魁祸首。