在某次持續壓測過程中,我們發現 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]:
implRuntime{
/// Spawn a future and execute it in this thread pool
///
/// Similar to Tokio::runtime::Runtime::spawn
pubfnspawn<F>(&self, future: F) -> JoinHandle<F::Output>
where
F: Future + Send+ 'static,
F::Output: 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]
structA{
i: i32,
}
implDefaultforA{
fndefault-> Self{
println!("called A::default");
A { i: 42}
}
}
#[derive(Default)]
structB{
a: A,
i: i32,
}
implB{
fnnew(a: A) -> Self{
B {
a,
// A::default is called in B::default, even though "a" is provided here.
..Default::default
}
}
}
fnmAIn{
leta= A { i: 1};
letb= 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。
參考
[1] https://Github.com/jemalloc/jemalloc/wiki/Use-Case%3A-Heap-Profiling
[2] https://github.com/GreptimeTeam/greptimedb/blob/develop/src/common/mem-prof/README.md
[3] https://github.com/tokio-rs/console
[4] https://github.com/GreptimeTeam/greptimedb/blob/develop/src/common/runtime/src/runtime.rs#L63
[5] https://github.com/GreptimeTeam/greptimedb/pull/1512
[6] https://doc.rust-lang.org/book/ch05-01-defining-structs.html#creating-instances-from-other-instances-with-struct-update-syntax
[7] https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=c121ffd32d2ff0fa8e1241a62809bcef