博客 / 詳情

返回

記一次 Rust 內存泄漏排查之旅 | 經驗總結篇

在某次持續壓測過程中,我們發現 GreptimeDB 的 Frontend 節點內存即使在請求量平穩的階段也在持續上漲,直至被 OOM kill。我們判斷 Frontend 應該是有內存泄漏了,於是開啓了排查內存泄漏之旅。

Heap Profiling

大型項目幾乎不可能只通過看代碼就能找到內存泄漏的地方。所以我們首先要對程序的內存用量做統計分析。幸運的是,GreptimeDB 使用的 jemalloc 自帶 heap profiling,我們也支持了導出 jemalloc 的 profile dump 文件。於是我們在 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。

Tokio Console

Tokio Console 是 Tokio 官方的診斷工具,輸出結果如下:

我們看到居然有 5559 個正在運行的 task,且絕大多數都是 Idle 狀態!於是我們可以確定,內存泄漏發生在 Tokio 的 task 上。
現在問題就變成了:GreptimeDB 的代碼裏,哪裏 spawn 了那麼多的無法結束的 Tokio task?

從上圖的 "Location" 列我們可以看到 task 被 spawn 的地方:

impl Runtime {
    /// Spawn a future and execute it in this thread pool
    ///
    /// Similar to Tokio::runtime::Runtime::spawn()
    pub fn spawn<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 中修復了這個泄漏。簡單地説,就是我們在某個會被經常創建的 struct 的構造方法中,spawn 了一個可以在後台持續運行的 Tokio task,卻未能及時回收它。對於資源管理來説,在構造方法中創建 task 本身並不是問題,只要在 Drop 中能夠順利終止這個 task 即可。而我們的內存泄漏就壞在忽視了這個約定。

這個構造方法同時在該 struct 的 Default::default() 方法當中被調用了,更增加了我們找到根因的難度。

Rust 有一個很方便的,可以用另一個 struct 來構造自己 struct 的方法,即 "Struct Update Syntax"。如果 struct 實現了 Default,我們可以簡單的在 struct 的 field 構造中使用 ..Default::default()。如果 Default::default() 內部有 “side effect”(比如我們本次內存泄漏的原因——創建了一個後台運行的 Tokio task),一定要特別注意:struct 構造完成後,Default 創建出來的臨時 struct 就被丟棄了,一定要做好資源回收。

例如下面這個小例子:(Rust Playground)

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。

關於 Greptime

Greptime 格睿科技於 2022 年創立,目前正在完善和打造時序數據庫GreptimeDB 和格睿雲 GreptimeCloud 這兩款產品。

GreptimeDB 是一款用 Rust 語言編寫的時序數據庫,具有分佈式、開源、雲原生、兼容性強等特點,幫助企業實時讀寫、處理和分析時序數據的同時,降低長期存儲的成本。

GreptimeCloud 基於開源的 GreptimeDB,為用户提供全託管的 DBaaS,以及與可觀測性、物聯網等領域結合的應用產品。利用雲提供軟件和服務,可以達到快速的自助開通和交付,標準化的運維支持,和更好的資源彈性。GreptimeCloud 已正式開放內測,歡迎關注公眾號或官網瞭解最新動態!

官網:https://greptime.com/

公眾號:GreptimeDB

GitHub: https://github.com/GreptimeTeam/greptimedb

文檔:https://docs.greptime.com/

Twitter: https://twitter.com/Greptime

Slack: https://greptime.com/slack

LinkedIn: https://www.linkedin.com/company/greptime/

user avatar prepared 頭像 yaoyaolx_wiki 頭像 u_16163442 頭像 tingtr 頭像 AllBigFish 頭像 yaha_5f66c6f02983e 頭像 pingcap 頭像 codingembedded 頭像 monkeynik 頭像 apocelipes 頭像 software_arch 頭像 dcsjava 頭像
12 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.