寻找并修复 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
• 不可复用
释放页时,我们会执行一套简单的逻辑:
- 如果页大小
<= 标准大小:归还到池中 - 如果页大小
> 标准大小:调用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。
脚注
随机一篇博客
评论
登录后参与讨论