记一次Rust内存泄漏排查之旅

在某次持续压测过程中,我们发现 greptimedb 的 frontend 节点内存即使在请求量平稳的阶段也在持续上涨,直至被 oom kill。我们判断 frontend 应该是有内存泄漏了,于是开启了排查内存泄漏之旅。
heap profiling
大型项目几乎不可能只通过看代码就能找到内存泄漏的地方。所以我们首先要对程序的内存用量做统计分析。幸运的是,greptimedb 使用的 jemalloc 自带 heap profiling[1],我们也支持了导出 jemalloc 的 profile dump 文件[2]。于是我们在 greptimedb 的 frontend 节点内存达到 300mb 和 800mb 时,分别 dump 出了其内存 profile 文件,再用 jemalloc 自带的 jeprof 分析两者内存差异(--base 参数),最后用火焰图显示出来:
显然图片中间那一大长块就是不断增长的 500mb 内存占用了。仔细观察,居然有 thread 相关的 stack trace。难道是创建了太多线程?简单用 ps -t -p 命令看了几次 frontend 节点的进程,线程数稳定在 84 个,而且都是预知的会创建的线程。所以“线程太多”这个原因可以排除。
再继续往下看,我们发现了很多 tokio runtime 相关的 stack trace,而 tokio 的 task 泄漏也是常见的一种内存泄漏。这个时候我们就要祭出另一个神器:tokio-console[3]。
tokio console
tokio console 是 tokio 官方的诊断工具,输出结果如下:
我们看到居然有 5559 个正在运行的 task,且绝大多数都是 idle 状态!于是我们可以确定,内存泄漏发生在 tokio 的 task 上。现在问题就变成了:greptimedb 的代码里,哪里 spawn 了那么多的无法结束的 tokio task?
从上图的 location 列我们可以看到 task 被 spawn 的地方[4]:
impl runtime {    /// spawn a future and execute it in this thread pool    ///    /// similar to tokio::spawn()    pub fn spawn(&self, future: f) -> joinhandle    where        f: future + send + 'static,        f: send + 'static,    {        self.handle.spawn(future)    }}  
接下来的任务是找到 greptimedb 里所有调用这个方法的代码。
..default::default()
经过一番看代码的仔细排查,我们终于定位到了 tokio task 泄漏的地方,并在 pr #1512[5]中修复了这个泄漏。简单地说,就是我们在某个会被经常创建的 struct 的构造方法中,spawn 了一个可以在后台持续运行的 tokio task,却未能及时回收它。对于资源管理来说,在构造方法中创建 task 本身并不是问题,只要在 drop 中能够顺利终止这个 task 即可。而我们的内存泄漏就坏在忽视了这个约定。
这个构造方法同时在该 struct 的 default::default() 方法当中被调用了,更增加了我们找到根因的难度。
rust 有一个很方便的,可以用另一个 struct 来构造自己 struct 的方法,即 struct update syntax[6]。如果 struct 实现了 default,我们可以简单地在 struct 的 field 构造中使用 ..default::default()。
如果 default::default() 内部有 “side effect”(比如我们本次内存泄漏的原因——创建了一个后台运行的 tokio task),一定要特别注意:struct 构造完成后,default 创建出来的临时 struct 就被丢弃了,一定要做好资源回收。
例如下面这个小例子:rust playground[7]
struct a {    i: i32,}impl default for a {    fn default() -> self {        println!(called a::default());        a { i: 42 }    }}#[derive(default)]struct b {    a: a,    i: i32,}impl b {    fn new(a: a) -> self {        b {            a,            // a::default() is called in b::default(), even though a is provided here.            ..default::default()        }    }}fn main() {    let a = a { i: 1 };    let b = b::new(a);    println!({}, b.a.i);}  
struct a 的 default 方法是会被调用的,打印出 called a::default()。
总结
• 排查 rust 程序的内存泄漏,我们可以用 jemalloc 的 heap profiling 导出 dump 文件;再生成火焰图可直观展现内存使用情况。
• tokio-console 可以方便地显示出 tokio runtime 的 task 运行情况;要特别注意不断增长的 idle tasks。
• 尽量不要在常用 struct 的构造方法中留下有副作用的代码。
• default 只应该用于值类型 struct。


物理学家提出新型磁性火箭推进器概念
灵动微MCU产品MM32系列的特点及应用
JOIEEM要做E-bike领域的“特斯拉”,背后离不开涂鸦智能的这项能力
COM-335X V1.3
如何建立区块链系统来管理战略性公共资产
记一次Rust内存泄漏排查之旅
基于DSP和ADC技术实现高速缓存和海量缓存的方案研究
中科创达在苏州投资成立新公司,经营范围含AI业务等
法国开发出超低铂含量的燃料电池电极
RK3568开发板外接超声波传感器测距模块
怎么也想不到最便宜的5G手机竟然出自于vivo之手!
英特尔芯片领域面临四大威胁 10nm制程或增加竞争优势
Navigant Research新报告研究了智慧城市通信网络市场,并提供预测到2027年
高通收购NXP再次延期_创商务部审批时长之最
宁德时代出手“车电分离”模式,技术提升降成本
示波器探头能测多高电压
电机绕组端产生的电压脉冲波在电机绕组中的传输过程
越南MIC发布发证新要求:制造商没有分公司不再签发型式认可证书
连续3家明星机器人公司破产 给我们留下了什么生机
图解PLC应用开发的步骤
s