发现并修复Ghostty最大的内存泄漏
摘要
一篇详细的技术文章,介绍了诊断并修复Ghostty终端仿真器中一个重大内存泄漏的过程。该泄漏是由于在非标准页面大小下滚动缓冲区修剪逻辑错误导致的。修复已合并,将包含在即将发布的1.3版本中。
暂无内容
查看缓存全文
缓存时间: 2026/05/16 03:38
# 发现并修复 Ghostty 最大的内存泄漏
来源:https://mitchellh.com/writing/ghostty-memory-leak-fix
几个月前,用户开始报告 Ghostty 消耗了惊人的内存,有用户报告在运行 **10 天**后内存占用达到 **37 GB**。今天,我很高兴地宣布修复方案已找到并合并(https://github.com/ghostty-org/ghostty/pull/10251)。本文概述了泄漏的原因,展示了 Ghostty 的一些内部机制,并简要描述了我们的排查过程。¹(https://mitchellh.com/writing/ghostty-memory-leak-fix#user-content-fn-2)
该泄漏至少从 Ghostty 1.0 就已存在,但直到最近,流行的 CLI 应用(尤其是 Claude Code)才开始大量触发符合其条件的情况。触发泄漏的条件非常有限,这使得诊断尤为棘手。
修复方案已合并,并可在 **tip/nightly** 版本(https://ghostty.org/docs/install/pre)中获取,也将包含在 3 月份发布的 1.3 正式版中。
---
## PageList
要理解这个 bug,首先需要了解 Ghostty 如何管理终端内存。Ghostty 使用名为 `PageList` 的数据结构(https://github.com/ghostty-org/ghostty/blob/main/src/terminal/PageList.zig)来存储终端内容。PageList 是一个由内存页组成的双向链表,这些内存页存储了终端内容(字符、样式、超链接等)。
PageList:内存页的双向链表
Page 1 最旧的滚动缓冲区
Page 2
Page 3
Page 4 最新的活动屏幕
这些底层“页”并非单个虚拟内存页(https://en.wikipedia.org/wiki/Page_(computer_memory)),而是与页边界对齐、由系统页的偶数倍组成的一段连续内存块。²(https://mitchellh.com/writing/ghostty-memory-leak-fix#user-content-fn-1)
这些页使用 `mmap` 分配。由于 `mmap` 并不特别快,为避免频繁的系统调用,我们使用了 **内存池**。当需要新页时,从池中取出;当页使用完毕时,归还给池以便复用。
池使用 **标准大小** 的页。可以将其想象为购买标准尺寸的运输箱:大多数人的物品都适合标准箱子,使用标准箱子还能带来各种效率。
但有时终端需要比标准页更多的内存。如果一组行包含大量 emoji、样式或超链接,我们就需要更大的页。在这种情况下,我们直接使用 `mmap` 分配 **非标准页**,完全绕过池。这通常是一种罕见情况。
两种类型的页分配
标准页(来自池)
• 固定大小
• 释放时归还给池
• 可复用于未来分配
非标准页(直接 mmap)
• 可变大小(大于标准)
• 必须调用 munmap 才能释放
• 不能被复用
当我们“释放”一页时,会执行一些简单的逻辑:
1. 如果页大小 `<= 标准大小`:归还给池
2. 如果页大小 `> 标准大小`:调用 `munmap` 释放
这是 Ghostty 终端内存管理的核心背景,这个思路本身是合理的。一个优化相关的逻辑 bug 导致了泄漏,我们接下来会看到。
---
还有一个背景细节需要了解:滚动缓冲区修剪。
Ghostty 有一个 `scrollback-limit` 配置,用于限制保留的历史记录数量。当达到限制时,我们会删除滚动缓冲区中最旧的页以释放内存。
但这通常发生在热点路径中(例如快速输出大量数据时),即使有池,分配和释放内存页的开销也很大。因此,我们做了一个优化:**在达到限制时,将最旧的页复用为最新的页**。
滚动缓冲区修剪:复用最旧的页
之前:达到滚动缓冲区限制
从前面移除,复用到后面
之后:页被复用在末尾
Page 2 现在是最旧的
Page 3
Page 4
这个优化效果很好。它不需要任何分配,只需一些快速的指针操作就能将页从列表前面移到后面。我们会进行一些元数据清理来“清空”页,但其他内存保持不变。
这个操作很快,经验上能显著加快滚动缓冲区密集型工作负载的速度。
---
## Bug
在滚动缓冲区修剪优化过程中,我们**总是将页大小重置回标准大小**。但我们并没有调整底层的内存分配本身,只是在元数据中记录了大小变化。底层内存仍然是大的非标准 `mmap` 分配,但 PageList *认为*它是标准大小的。
元数据不同步如何导致泄漏
1
分配非标准页
2
滚动缓冲区修剪并复用
**BUG:** 元数据重置为 std_size,但 mmap 未改变!
3
释放该页
std_size,假设来自池。**从未调用 munmap!**
标准非标准泄漏
最终,我们会在各种情况下释放该页(例如用户关闭终端时,以及其他情况)。那时,我们会看到页内存大小在标准范围内,认为它属于池,于是*绝不调用 `munmap`*。经典的泄漏。
这一切看起来相当明显,但问题在于,非标准页在设计上很罕见。我们的设计和优化的目标是让标准页成为常见情况并提供快速路径。只有非常特定的场景才会产生非标准页,而且通常数量不大。
但是,Claude Code(https://claude.com/product/claude-code)的兴起改变了这一点。出于某些原因,Claude Code 的 CLI 会产生大量多码位字形的输出,迫使 Ghostty 频繁使用非标准页。此外,Claude Code 使用主屏幕并产生大量滚动缓冲区输出。这些因素结合在一起,形成了完美风暴,以巨大数量触发了泄漏。
我想明确说明,这个 bug 不是 Claude Code 的错。Claude Code 只是以某种方式使用 Ghostty,从而暴露了这个长期存在的 bug。
---
## 修复
修复方案在概念上很简单:**绝不复用非标准页**。如果在滚动缓冲区修剪过程中遇到非标准页,我们将其正确销毁(调用 `munmap`),然后从池中分配一个全新的标准大小页。
修复的核心在下面的代码片段中,但还需要额外的工作来修正一些其他统计信息:
``
if (first.data.memory.len > std_size) {
self.destroyNode(first);
break :prune;
}
``
我们也可以复用非标准页并保留其大内存大小,但在有数据显示相反情况之前,我们仍然假设标准页是常见情况,因此重置为标准的池化页是合理的。
其他用户建议采用更复杂的策略(例如维护非标准页使用频率的指标并相应调整假设),但在做出这些更改之前还需要更多研究。这个更改简单、修复了 bug,并且与我们当前的假设一致。
---
作为修复的一部分,我添加了对 macOS 上 Mach 内核提供的虚拟内存标签(tag)的支持。这使我们能够用特定的标识符标记 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 代码库中的泄漏。
- 我们使用 Valgrind(完整 GUI)运行每个 GTK 相关的 PR,以查找未经单元测试的 GTK 路径中的泄漏。
这些方法到目前为止效果很好,但不幸的是没有捕获到这次泄漏,因为它仅在非常特定的条件下触发,而我们的测试并未复现这些条件。合并的 PR 包含一个能够复现该泄漏的测试,以防止未来出现回归。
---
## 结论
这是迄今为止 Ghostty 中已知的最大内存泄漏,也是唯一一个被不止一名用户确认的泄漏报告。我们将继续监控和处理收到的内存报告,但请记住,可复现性是诊断和修复内存泄漏的关键!
非常感谢 @grishy(https://github.com/grishy),他终于为我提供了可靠的复现方法,让我可以自己分析问题。他本人的分析得出了与我相同的结论,而复现方法也使我能独立验证我们俩的理解。
还要感谢所有报告此问题并提供详细诊断信息的人。社区的剖析,特别是关于 `footprint` 输出和 VM 区域计数的分析,为我提供了重要线索,指向了 PageList 是罪魁祸首。
1. 本文撰写未使用 AI。AI 仅用于辅助部分图示,但这些图示都经过了人工审查以确保正确性。没有任何文本内容是 AI 生成的。↩(https://mitchellh.com/writing/ghostty-memory-leak-fix#user-content-fnref-2)
2. 这个原因对本博文并不重要,但其本身是一个有趣的细节。↩(https://mitchellh.com/writing/ghostty-memory-leak-fix#user-content-fnref-1)
相似文章
我们重写了 Ghostty GTK 应用程序
Mitchell Hashimoto 详细介绍了 Ghostty 的 GTK 应用程序重写,以完全拥抱来自 Zig 的 GObject 类型系统,从而提高了稳定性、功能和内存安全性,并通过 Valgrind 验证。
Ghostty: 反思1.0版本的发布
Mitchell Hashimoto 反思了他用 Zig 构建的终端模拟器 Ghostty 达到1.0版本的过程,讨论了项目的起源、成功但富有争议的内测版,以及他对这款终端的愿景。
Ghostty 1.0 即将到来
Ghostty 1.0 是一款面向 macOS 和 Linux 的开源终端模拟器,将于 2024 年 12 月以 MIT 许可证公开发布,旨在成为现有终端的替代品,追求最快、功能最丰富且原生平台体验。
Libghostty 即将到来
Mitchell Hashimoto 宣布了 libghostty 的计划,这是一个可嵌入的终端模拟库,首先推出 libghostty-vt,这是一个从 Ghostty 中提取的零依赖终端序列解析器。
Ghostty 即将离开 GitHub
Mitchell Hashimoto 宣布,由于持续的中断和挫败感,Ghostty 终端模拟器项目将离开 GitHub,此前他已有18年日常使用经历。