Rust memory leak debugging on macOS
[!NOTE]
记一次 WezTerm (written in Rust) 内存泄露 debug。虽然题为 Rust,但是理论上所使用的工具以及思路基本是编程语言无关的。
为何 Rust 程序会有内存泄露?
即便是所谓“内存安全“的 Rust,也是几乎无法避免地会遇到内存泄露的问题。无非是从程序员不小心忘记 Free 的低级错误,变成了更加隐式的循环引用、逻辑问题,甚至是藏在了实际上 Unsafe 的所谓 Safe 包装的代码中。
循环引用导致的内存泄露问题会出现在以引用计数作为内存管理的语言中,如 Rust(Rc/Arc 引用计数智能指针)、Objective-C。如果两个对象始终持有对方的强引用,则它们的引用计数永远大于 0 而无法被释放,也就导致了内存泄漏。在 Java 等 GC 语言中则不会出现,是因为 GC 使用可达性分析,只要从 Root 不可达,就不用关心被回收对象之间的依赖关系。
1
2
3
4
5
6
7
8
9struct A(RefCell<Option<Rc<A>>>);
{
let a1 = Rc::new(A(RefCell::new(None))); // a1: 1
let a2 = A(RefCell::new(Some(Rc::clone(&a1)))); // a1: 2, a2:1
*a1.borrow_mut() = Some(Rc::clone(&a2)); // a1: 2, a2: 2
}
// Out of scope, dropping a1 and a2
// a1: 1, a2: 1逻辑内存泄露则可能出现在任何语言中,比如一个全局或者生命周期极长的数组只增加不减小。
Rust 中的 Unsafe 代码基本上完全脱离了 Rust 内存安全的约束 (Escape Hatch),得由开发者自行定夺代码的安全性,所以其中发生任何事情都不奇怪了。 Rust 的许多库做了对 Unsafe 代码 Safe 封装,因此库的安全性取决于库的开发者,而库的使用者有时也就难以确定 Safe-API 的实际安全性。
如何高效找出内存泄漏点
已知内存泄露的特征为:只要打开新的终端窗口(再关闭),内存占用就会不断升高。
大概可以推测内存泄露源于窗口打开时创建的资源在关闭时没有释放。于是我稍微花了些许时间在审计窗口管理相关的代码上,同时也借助了AI Agent来理清代码逻辑关系。虽然找到了许多内存泄露的可疑点,但是在逐个排查后发现并不是。
[!NOTE]
这中间值得吐槽的是:我受了不少LLM的误导,每次 AI 都自信满满地说找到了泄露点,但是我按照它的说法实际尝试处理后并没有任何效果。最后发现,让AI帮我找泄露点(甚至只是定位Bug),纯属是浪费时间。给 WezTerm 提的两个 PR,主要问题点都不是AI定位到的。
我最开始使用了 Valgrind DHAT 工具来分析内存。使用方法非常简单,只要设置 Rust 的 #[global_allocator] 为 dhat,然后照常运行程序复现Bug,在程序退出时会生成堆内存分配记录,最后扔到 dhat 的堆内存分析工具中就可以了。
但是由于是静态分析,里面的记录实在太多,几乎无法快速有效地定位问题点。总之就是对于这个场景,不太适用。
因此,需要一个动态的内存分析工具,在程序运行时(特别是我想关注的窗口打开和关闭的时间点)检查堆内存分配情况。这里省略我尝试过的乱七八糟工具(如 leak),直接请出终极的解决方案 Instruments。
Instruments 其实就是 Xcode 带的 Profiling 工具,功能十分强大,这里只讨论如何用于堆内存分析。
使用 Instruments Debug/Profile Rust 程序
虽然 Xcode 相关的组件基本都是为 C/C++, Swift, OC 服务的,不过我们还是可以直接把构建好的 Rust 二进制程序扔到里面分析的(可能归功于都使用 LLVM 的工具链)。
[!IMPORTANT]
对于 arm64 的 Mac,需要先对二进制进行 entitlements 签名后才能进行 profiling。
创建如下
entitle.xml
1
2
3
4
5
6
7
8
<plist version="1.0">
<dict>
<key>com.apple.security.get-task-allow</key>
<true/>
</dict>
</plist>再进行
codesign:
1 codesign -s - -v -f --entitlements=entitle.xml /path/to/bin注:对 Rust 的 debug 二进制签名可能会非常缓慢,要么耐心等待要么构建一个带 debug 信息的 release 二进制
WGPU 的 Wiki 中也有对于如何使用 Instruments 进行 Debug 的说明:https://github.com/gfx-rs/wgpu/wiki/Debugging-wgpu-Applications#metal
启动 Instruments 后,选择 Allocations 模板:
在 Target 中选择目标程序:

在 Allocations Section 中可以开启 Record reference counts 来记录 OC 的引用计数:
点击左上角红色 Record 按钮就可以开始动态分析了,效果如下:

通过多次打开关闭窗口后,可以注意到 VM: IOSurface 这个类别的内存占用不正常,只增加不减少(Persistent增加,而Transient没有同时增加)。

点击条目可以看到对应的 Malloc 调用栈信息,这样也就能方便定位出问题的模块。而右侧调用堆栈也可以双击查看源码:

根据这些记录,大概能推出问题可能与 WGPU 相关,之后就是需要定位是什么导致 IOSurface 没有被释放。
可能是经验不足或者智力堪忧,找了各处窗口控制中可能出现的循环引用以及跟踪 WGPU 对象是否释放,还是没定位到问题代码。
查看 OC 对象的依赖关系以及引用计数
好在 ChatGPT 指出了使用 Xcode 的 Memory Graph,可以查看 OC 对象的引用计数情况。而 Instruments 中我并没有找到对应的功能,于是只能用 Xcode 来调试了。
随便创建一个 Xcode 项目(使用最简单的项目模板即可),不用管任何项目结构和代码,直接如下图打开 Edit Scheme... :

Executable 选择 Other... :

在 Diagnostics 选项卡中勾选 Malloc Stack Logging,方便后续 Debug 查看调用栈:

这样配置后就可以对任意程序使用 Xcode 进行调试了。同样,点击左上角运行程序,如下图打开 Memory Graph View:


打开 Graph View 后程序会暂停,得到的是此时内存关系图的快照。在上图中可以清晰看到 IOSurface 被 CAMetalLayer 创建 -[CAMetalLayer nextDrawable] 和持有。因此转头看下 CAMetalLayer 又是怎么来的。
[!TIP]
其实在前面的 Instruments 中就可以通过 Backtrace 或者 Debug 找到 IOSurface 的来源,这里的 Xcode 内存关系图只是提供了一个辅助作用。
回到 Instruments,可以检查 CAMetalLayer 的引用计数变化日志:

CAMetalLayer 最终引用计数 (Retain Count) 为 1,代表有一个未释放的引用。根据上面的日志,由于头尾都有3,大概能认为中间部分都已经正确释放了。推测是 WezTerm 的哪里还持有一个引用。
在查到 CAMetalLayer 时,问题其实已经比较明了了,因为 WezTerm 的窗体创建逻辑中确实也有初始化 CAMetalLayer 的代码,那么检查 CAMetalLayer 相关的代码也就能大概率找到问题所在。
定位问题代码
1 | extern "C" fn make_backing_layer(view: &mut Object, _: Sel) -> id { |
CAMetalLayer 的创建于如上回调函数中,看起来好像没有太大的问题。 [CAMetalLayer new] 返回一个 Retain Count 为 1 、初始化后的对象,函数最后又会返回这个对象,在语义上是将对象的所有权交给了调用者。而调用者也就是 NSView,它会持有这个对象的 Strong 引用,同时使引用计数 ++。因此,在 NSView 销毁时,为了能同时销毁 CAMetalLayer,需要除了 NSView 以外没有其他的引用存在。那反推回来,也就是需要回调函数在返回时同时释放引用(返回引用计数为 0 的对象),才能使 CAMetalLayer 被正确销毁。OC 中有个 autorelease 方法可以妥善处理这种情况,autorelease 会给对象一个标记,在合适时候(如事件循环结束时)会让对象引用计数-1。
因此,解决方案也就很简单了,返回前调用对象的 autorelease 方法。不过,在 Apple 的文档中使用的是 [CAMetalLayer layer] 方法来给 NSView 设置 Layer 的,返回的对象自带 autorelease,就不需要在手动处理了,感觉更优雅一点、更不容易犯错:
1 | --- let layer: id = msg_send![class, new]; |
Unsafe Rust?
到此,问题确实是解决了,PR wezterm#7283 也是合并了。事后再复盘起来,Rust 的内存安全性确实还需要更谨慎考量。虽然看起来是在 Unsafe 块中出问题好像也并不奇怪,但是实际上 Rust 的基础设施基本都是建立在 Unsafe 上的,也不可能存在真正意义上没有 Unsafe 块的 Rust 程序。我们即便可以相信这些库都是由大牛、Rust 专家开发的,Unsafe 代码块都是经过充分审计的,我们也不能就盲目认为 Rust 就是完全内存安全的语言。
Rust 的坑感觉还有很多东西可以写。。。。(就比如我至今还没把生命周期注解完全搞清楚









