发现并修复 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 释放
• 无法复用
当我们“释放”一页时,会应用以下简单逻辑:
- 如果页大小
<= 标准大小:将其归还至内存池 - 如果页大小
> 标准大小:调用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。
脚注
随机一篇博客