近年来,Rust 语言凭借其内存安全性和高性能优势,逐渐从前端工具链的底层渗透到前端开发的核心场景中。货拉拉前端的灰度服务即是采用 Rust 来实现。虽然 Rust 语言提供了内存安全的保障,但是由于使用不当或者一些 bug,仍然会造成内存泄漏问题。本文以一次真实的排查过程为例,讲解排查 Rust 内存泄漏问题中遇到的用到的工具以及排查思路。
我们的前端灰度服务在每次发布之后,占用的内存会一直增长,最终耗尽所有内存从而导致 OOM。
询问 GPT ,它告诉了我们以下几个典型的 Rust 内存泄漏的场景。
• 循环引用:如果多个对象互相引用,导致它们无法被释放。
• 不正确地使用Box
或Rc
:这些类型可以用于管理内存,但如果使用不当,也会导致内存泄漏。
• 忘记释放资源:确保所有分配的内存,文件,网络连接等资源都得到了及时释放。
知道内存泄漏的场景,给了我们大致的排查方向,现在我们需要借助一些工具来定位问题所在。
PageServer 使用了 tokio
这个异步编程库。tokio-console
是一个用于审查 tokio
运行时的工具,可以帮助我们查看异步任务的运行状态。参考文档[1]添加相关的代码来引入。开发者启动 tokio-console
命令便可以查看异步任务的状态了。
# 安装 & 引入相关初始化代码
cargo install --locked tokio-console
# 默认链接端口为 6669
tokio-console http://127.0.0.1:5555
结果展示如下,审查代码后,发现这里展示的绝大多数异步任务都是预期的。没有发现明显的异步任务泄漏。
既然没有出现异步任务的泄漏,那么接下来,只能去观察内存是如何分配的,那就必须要要使用到 jemalloc
了。需要在项目中引入这两个依赖:
• tikv-jemallocator[2]: 替换默认的内存分配器
• jemalloc_pprof[3]: 提供了导出 profile 文件的工具函数
将导出 profile 文件的逻辑包装成一个 http 接口,那么就可以运行过程中导出 profile 文件。在启动程序前,设置 export _RJEM_MALLOC_CONF=prof:true,lg_prof_interval:28
用以开启导出 profile 相关的功能。
jeprof[4] 这款 cli 用来分析 profile 文件。当然也可以用 flamegraph.pl[5] 来生成火焰图。
# 导出 profile 文件,并使用 jeprof 来分析
dump-profile() {
curl http://127.0.0.1:3000/debug/profile/prof > "$1.prof"
jeprof --svg ./target/release/page-server "$1.prof" > "$1.svg"
# 导出成火焰图, flamegraph.pl 来自于 https://github.com/brendangregg/FlameGraph/blob/master/flamegraph.pl
jeprof ./target/release/page-server "$1.prof" --collapse | perl flamegraph.pl > "$1.flamegraph.svg"
}
dump-profile 1
其实 k6[6] 通常被当做压测工具来使用。不过借助它可以很方便的模拟出线上的请求。它的一个优点是可以通过 JavaScript 编写测试脚本。简单调整配置就可以模拟出线上的场景,对 前端 非常友好。
在安装完成了 k6
cli 后,使用 k6 new
生成测试脚本,执行 k6 run script.js
来执行测试。
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
// 同时请求的数量
vus: 10,
// 此次测试持续时长
duration: '30s',
};
export default function () {
const headers = {
host: `some-project.huolala.cn`,
timeout: '1s',
};
http.get(`http://127.0.0.1:3001/${path}`, { headers });
sleep(1);
}
1. 导出线上 profile 文件来分析
2. 使用 k6 来模拟线上请求,找出最小复现 demo
3. 定位问题所在,修复 bug
通过对 profile 文件的分析,我们发现有两处地方存在较高的内存占用。
增长的内存来自于 52
行的正则匹配,并且经过测试发现,只要是 65
行是个异步操作,内存就会不断增长。
给相应的对象实现 Drop
特性可以观察到在该对象确实被销毁。当然一个对象被销毁,不代表它某个异步方法创建的 future 也会被销毁。为了测试 future 在被取消时,其申请的内存是否会被释放,添加如下的代码来测试。
// 分配 10M 的内存空间
let mut buf: Vec<u8> = Vec::<u8>::with_capacity(1024 * 1024 * 10);
buf.fill(0);
for caps in HTML_TAG_REGEX.captures_iter(haystack: content.as_str()) {
// ...
}
测试后发现,最终的内存占用量为 8.1M
也就意味着 future 被取消时,其申请的内存会被释放。
到这里就可以怀疑额外的内存增长来自于正则对象。生成的火焰图中也可以印证我们的判断。
尝试对我们的业务代码进行合理的删减。几番测试下来,只要不将regex.captures_iter
与 await
语句混用就避免了内存的异常增长。
// 之前
for caps in HTML_TAG_REGEX.captures_iter(content) {
// ...
async_fn().await;
}
// 现在
for position in HTML_TAG_REGEX.captures_iter(content).map(extract_postion).collect::<Vec<_>>() {
// ...
async_fn().await;
}
我怀疑这是正则导致的内存泄漏,便给 regex 的作者提了一个 issue[7]。作者解释这种现象产生的原因:
在多线程中,使用同一个正则进行匹配时,因为存在
await
操作,导致当前正则占据的内存不能复用,必须申请新的内存空间来进行匹配。这个过程中申请的内存在正则匹配结束后,并不一定都会释放掉。参见链接[8]
对于我们来说,在使用正则时,最好将正则的匹配的操作放在一个同步的语句中,从而确保正则可能进行地被复用。
两种写法导致的内存使用差异,可以看这个例子[9]
除了正则使用不当导致的内存增长外,我们还要观察到内存集中在 rust-s3
第三方库的调用中。
项目中用到 rust-s3
这个库的 get_object
方法。检索这个仓库的 issue 列表,没有发现有人提过内存泄漏问题。当我们进一步查看函数的调用栈时,发现分配的内存主要集中在 hyper::body::to_bytes
方法中
继续查看 to_bytes
的方法,发现内存主要集中在 84 ~ 86 行间。
难道说调用 body.data
方法时阻塞了,导致内存不能正确释放?通过在业务代码中添加日志,可以确信这 get_object
,to_bytes
方法等都正常返回了。使用 rust-s3
编写简单的示例,也未发现存在的内存泄漏问题。
既然依赖库 rust-s3
没有问题,那么只能说是业务代码使用上的问题。下方的截图是该过程的调用栈,这段逻辑会涉及到了多个模块。
把无关的代码去除,将涉及到的模块分批次加入到业务代码中,每次改动后都观察内存的变化。经过几次尝试,发现获取文件的模块的缓存逻辑存在问题。缓存模块没有及时清理文件的数据,从而导致内存一直在异常增长。
重新调整缓存模块的逻辑,推到线上验证。从监控上来看,内存使用量比较稳定,而非先前的一直在增长的趋势。
前文中提到内存主要集中在 hyper::body::to_bytes
方法中。通常情况下,一个函数执行完成了,执行过程中的申请的内存都会释放。如果出现像下面的情况,是所有权转移了,数据被缓存了,而非这个异步函数阻塞了。
pub async fn to_bytes<T>(body: T) -> Result<Bytes, T::Error>
where
T: HttpBody,
{
// ...
while let Some(buf) = body.data().await {
vec.put(buf?); // <--- 从 profile 中文件看到内存集中在这一行
}
}
• 找到稳定的复现方式很重要:它可以帮助我们检验程序的改动是否正确。在排查问题前,要先分析线上的监控数据或者日志。
• 对业务代码进行合理的删减:一是业务代码的复杂性会给排查问题带来干扰,需要进行合理的抽象,只保留核心逻辑。二是排查过程中,不断地通过删减代码做对比,从而判断出哪里的问题。
• 内存 profile 文件很有用。由于分析 profile 文件得到内存占用图或者火焰图都是基于堆栈的,不能因为调用栈中出现某个函数,就简单认为函数阻塞了。
• Greptime 的内存泄漏排查:https://medium.com/@greptime_team/memory-leak-diagnosing-using-flame-graphs-760c9b05dba7
• Tikv 开发者有关排查内存泄漏的分享:https://mp.weixin.qq.com/s/S8aWbPLRgwtKuhT94j34Pw
[1]
文档: https://github.com/tokio-rs/console?tab=readme-ov-file#using-it[2]
tikv-jemallocator: https://crates.io/crates/tikv-jemallocator[3]
jemalloc_pprof: https://crates.io/crates/jemalloc_pprof[4]
jeprof: https://manpages.debian.org/unstable/libjemalloc-dev/jeprof.1.en.html[5]
flamegraph.pl: https://github.com/brendangregg/FlameGraph/blob/master/flamegraph.pl[6]
k6: https://grafana.com/docs/k6/latest/[7]
issue: https://github.com/rust-lang/regex/issues/1214[8]
链接: https://github.com/rust-lang/regex/issues/1214#issuecomment-2255793400[9]
例子: https://github.com/rust-lang/regex/issues/1214#issuecomment-2257635440