cover_image

如何排查 Rust 中的内存泄漏问题

货拉拉大前端 货拉拉技术
2025年03月04日 09:00

 

近年来,Rust 语言凭借其内存安全性和高性能优势,逐渐从前端工具链的底层渗透到前端开发的核心场景中。货拉拉前端的灰度服务即是采用 Rust 来实现。虽然 Rust 语言提供了内存安全的保障,但是由于使用不当或者一些 bug,仍然会造成内存泄漏问题。本文以一次真实的排查过程为例,讲解排查 Rust 内存泄漏问题中遇到的用到的工具以及排查思路。

问题描述

我们的前端灰度服务在每次发布之后,占用的内存会一直增长,最终耗尽所有内存从而导致 OOM。

图片

内存泄漏的场景

询问 GPT ,它告诉了我们以下几个典型的 Rust 内存泄漏的场景。

  • • 循环引用:如果多个对象互相引用,导致它们无法被释放。

  • • 不正确地使用BoxRc:这些类型可以用于管理内存,但如果使用不当,也会导致内存泄漏。

  • • 忘记释放资源:确保所有分配的内存,文件,网络连接等资源都得到了及时释放。

知道内存泄漏的场景,给了我们大致的排查方向,现在我们需要借助一些工具来定位问题所在。

排查工具

tokio-console

PageServer 使用了 tokio 这个异步编程库。tokio-console 是一个用于审查 tokio 运行时的工具,可以帮助我们查看异步任务的运行状态。参考文档[1]添加相关的代码来引入。开发者启动 tokio-console 命令便可以查看异步任务的状态了。

# 安装 & 引入相关初始化代码
cargo install --locked tokio-console
# 默认链接端口为 6669
tokio-console http://127.0.0.1:5555

结果展示如下,审查代码后,发现这里展示的绝大多数异步任务都是预期的。没有发现明显的异步任务泄漏。

图片

jemalloc

既然没有出现异步任务的泄漏,那么接下来,只能去观察内存是如何分配的,那就必须要要使用到 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

其实 k6[6] 通常被当做压测工具来使用。不过借助它可以很方便的模拟出线上的请求。它的一个优点是可以通过 JavaScript 编写测试脚本。简单调整配置就可以模拟出线上的场景, 前端 非常友好

在安装完成了 k6 cli 后,使用 k6 new 生成测试脚本,执行 k6 run script.js 来执行测试。

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  // 同时请求的数量
  vus10,
  // 此次测试持续时长
  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. 1. 导出线上 profile 文件来分析

  2. 2. 使用 k6 来模拟线上请求,找出最小复现 demo

  3. 3. 定位问题所在,修复 bug

问题分析与解决

通过对 profile 文件的分析,我们发现有两处地方存在较高的内存占用。

在 async 函数中使用正则

分析 profile

增长的内存来自于 52 行的正则匹配,并且经过测试发现,只要是 65 行是个异步操作,内存就会不断增长。

图片

查看函数的调用栈,看起来像是某个 future 对象没有销毁掉,还是持有内存。
图片

排除干扰项

给相应的对象实现 Drop 特性可以观察到在该对象确实被销毁。当然一个对象被销毁,不代表它某个异步方法创建的 future 也会被销毁。为了测试 future 在被取消时,其申请的内存是否会被释放,添加如下的代码来测试。

// 分配 10M 的内存空间
let mut bufVec<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]

缓存模块的 bug

初步定位与分析

除了正则使用不当导致的内存增长外,我们还要观察到内存集中在 rust-s3 第三方库的调用中。

图片

项目中用到 rust-s3 这个库的 get_object 方法。检索这个仓库的 issue 列表,没有发现有人提过内存泄漏问题。当我们进一步查看函数的调用栈时,发现分配的内存主要集中在 hyper::body::to_bytes 方法中

图片

继续查看 to_bytes 的方法,发现内存主要集中在 84 ~ 86 行间。

图片

难道说调用 body.data 方法时阻塞了,导致内存不能正确释放?通过在业务代码中添加日志,可以确信这 get_objectto_bytes 方法等都正常返回了。使用 rust-s3 编写简单的示例,也未发现存在的内存泄漏问题。

删减业务代码

既然依赖库 rust-s3 没有问题,那么只能说是业务代码使用上的问题。下方的截图是该过程的调用栈,这段逻辑会涉及到了多个模块。

把无关的代码去除,将涉及到的模块分批次加入到业务代码中,每次改动后都观察内存的变化。经过几次尝试,发现获取文件的模块的缓存逻辑存在问题。缓存模块没有及时清理文件的数据,从而导致内存一直在异常增长。

重新调整缓存模块的逻辑,推到线上验证。从监控上来看,内存使用量比较稳定,而非先前的一直在增长的趋势。

图片

Rust 所有权的转移

前文中提到内存主要集中在 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 文件得到内存占用图或者火焰图都是基于堆栈的,不能因为调用栈中出现某个函数,就简单认为函数阻塞了。

参考资料

引用链接

[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

 


修改于2025年03月07日
继续滑动看下一个
货拉拉技术
向上滑动看下一个