メモリリークしていないか検査する

増えているのが Rust 側か WebView 側かを見分け、DevTools のヒープスナップショットと Rust のヒープ計測で原因を探す。listen の解除忘れなど Tauri で起きやすい原因と直し方も示す。

パフォーマンス 対象: Tauri 2.x 更新日: 読了目安: 約9分 perf-006
目次
  1. 前提条件
  2. 1. WebView 側を調べる (TypeScript)
  3. ヒープスナップショットで残っているものを探す
  4. Tauri で起きやすい原因
  5. 2. Rust 側を調べる (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

長く使うと重くなる、画面を開閉するたびにメモリが増える。こうした症状は、不要になったものがどこかから参照されたまま解放されないのが原因です。Tauri アプリのメモリは、Rust のコードが動くプロセス(公式の用語ではコア・プロセス)と、ページの JS や DOM を持つ WebView のプロセスに分かれていて、調べる道具が違います。まず増えているのがどちらかを見分け、WebView 側は DevTools のヒープスナップショット、Rust 側はヒープの使用量の計測で原因を絞ります。listen の解除忘れなど、Tauri で起きやすい原因と直し方も示します。

前提条件

プラグインは不要です。DevTools は tauri dev などのデバッグビルドで使えます(リリースビルドで使う方法は 開発者ツールをプログラムから開く)。イベントの listen と解除の unlisten の権限は core:default に含まれます。

調べる手順は次のとおりです。

  1. 疑わしい操作(画面を開いて閉じる、ファイルを開くなど)を決め、10〜20 回繰り返す。
  2. 前後で、アプリ本体と WebView のプロセスの使用量を OS のタスクマネージャーなどで比べる。1 回目の増加はキャッシュのことが多いので、回数に比例して増え続けるかを見る。
  3. 増えている側に合わせて、1 章(WebView)か 2 章(Rust)へ進む。

1. WebView 側を調べる (TypeScript)

ヒープスナップショットで残っているものを探す

Windows の DevTools では、Memory タブで次のように調べます。

  1. 「Heap snapshot」でスナップショットを取る。
  2. 疑わしい操作を繰り返し、もう一度取る。
  3. 表示を「Comparison」にして増えたままのオブジェクトを探す。検索欄に Detached と入れると、画面から外したのに残っている DOM が見つかる。
  4. 選んだオブジェクトの「Retainers」をたどり、何が捕まえているかを確かめる。

Tauri で起きやすい原因

  • listen() の解除忘れ: listen() や onResized() などに渡した関数は、戻り値の関数で解除するまで保持され、その関数が参照している DOM も一緒に残ります。画面を開くたびに登録すると、閉じても増え続けます(Rust からのイベントを受信する (listen))。
  • 開発中だけ増える: Vite の HMR でモジュールが差し替えられると、モジュールの最上位にある listen() がもう一度実行され、古い登録が残ります。
  • ブラウザーと同じ原因: setInterval、window や document に付けた addEventListener、URL.createObjectURL() で作った URL の解放忘れ。
  • Web Worker の止め忘れ: Worker はそれぞれ別にメモリを持つので、使い終わったら terminate() します(perf-008)。

画面ごとに後片付けをまとめておくと解除忘れを防げます。片付けていない画面の数を数えれば、閉じた後に 0 に戻るかで漏れを確かめられます。

import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';

let liveScopes = 0;
/** 片付けていない画面の数(閉じた後に 0 に戻れば漏れていない) */
export const liveScopeCount = () => liveScopes;

/** 画面ごとの後片付けをまとめる。dispose() で全部外す */
export function createScope() {
  const cleanups: Array<() => void> = [];
  const controller = new AbortController();
  let disposed = false;
  liveScopes++;
  return {
    signal: controller.signal, // addEventListener に渡すと abort() でまとめて外れる
    add(fn: () => void) {
      cleanups.push(fn);
    },
    // listen() などの Promise を預かり、登録が終わってから解除する
    track(p: Promise<UnlistenFn>) {
      cleanups.push(() => void p.then((unlisten) => unlisten()));
    },
    dispose() {
      if (disposed) return;
      disposed = true;
      liveScopes--;
      controller.abort();
      cleanups.splice(0).forEach((fn) => fn());
    },
  };
}

/** 例: 開いている間だけ CPU 使用率とウィンドウの大きさを出すパネル。戻り値の関数で閉じる */
export function openStatusPanel(el: HTMLElement): () => void {
  const scope = createScope();
  scope.track(listen<{ total: number }>('cpu-usage', (e) => {
    el.textContent = `CPU ${e.payload.total.toFixed(0)}%`;
  }));
  scope.track(getCurrentWindow().onResized(({ payload }) => {
    el.title = `${payload.width} x ${payload.height}`;
  }));
  window.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') el.hidden = true;
  }, { signal: scope.signal });
  const timer = window.setInterval(() => el.classList.toggle('stale'), 5000);
  scope.add(() => window.clearInterval(timer));
  return () => scope.dispose();
}

listen() は登録が終わる前に画面が閉じられることがあるので、Promise のまま預かって、登録が終わってから解除しています。HMR で差し替わるモジュールでは、import.meta.hot の dispose で片付けます。本番のビルドでは import.meta.hot が無いので何もしません。

import { listen } from '@tauri-apps/api/event';

// モジュールの最上位で登録するなら、HMR で差し替わる前に解除する
const unlistenLog = listen<string>('log-line', (e) => console.log(e.payload));
import.meta.hot?.dispose(() => {
  void unlistenLog.then((unlisten) => unlisten());
});

2. Rust 側を調べる (Rust)

Rust 側のプロセスの使用量は Process::memory()(sys-004)で数値にできますが、これはプロセス全体の量で、Rust が解放したメモリも OS にすぐ返されるとは限らないため、直しても減らないことがあります。Rust のコードが今確保している量だけを見たいなら、アロケーター(メモリの確保と解放を受け持つ部分)を包んで、確保中のバイト数を数えます。デバッグビルドのときだけ差し替えるので、リリースビルドには影響しません。

use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tauri::Emitter;

/// 確保中のバイト数を数えるアロケーター(確保そのものは OS 標準のものに任せる)
struct CountingAlloc;

static HEAP_BYTES: AtomicUsize = AtomicUsize::new(0);

unsafe impl GlobalAlloc for CountingAlloc {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let ptr = unsafe { System.alloc(layout) };
        if !ptr.is_null() {
            HEAP_BYTES.fetch_add(layout.size(), Ordering::Relaxed);
        }
        ptr
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        unsafe { System.dealloc(ptr, layout) };
        HEAP_BYTES.fetch_sub(layout.size(), Ordering::Relaxed);
    }
}

// デバッグビルドのときだけ差し替える
#[cfg(debug_assertions)]
#[global_allocator]
static GLOBAL: CountingAlloc = CountingAlloc;

/// Rust のコードが確保中のバイト数(リリースビルドでは常に 0)
#[tauri::command]
fn rust_heap_bytes() -> usize {
    HEAP_BYTES.load(Ordering::Relaxed)
}

static MONITOR_STARTED: AtomicBool = AtomicBool::new(false);

/// 画面の読み込み時に呼ぶコマンド。再読み込みされても 2 本目のスレッドは作らない
#[tauri::command]
fn start_monitor(app: tauri::AppHandle) {
    if MONITOR_STARTED.swap(true, Ordering::SeqCst) {
        return; // この判定が無いと、呼ばれるたびに終わらないスレッドが増える
    }
    std::thread::spawn(move || loop {
        std::thread::sleep(std::time::Duration::from_secs(1));
        let _ = app.emit("tick", ());
    });
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![rust_heap_bytes, start_monitor])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

疑わしいコマンドを繰り返し呼び、前後の差を見ます。IPC などで数百バイトはぶれるので、回数を 100 と 1,000 で比べ、回数に比例して増えるかで判断します。

import { invoke } from '@tauri-apps/api/core';

/** 操作を n 回繰り返し、前後で Rust のヒープがどれだけ増えたかを出す */
export async function measureRustHeap(label: string, n: number, op: () => Promise<unknown>) {
  const before = await invoke<number>('rust_heap_bytes');
  for (let i = 0; i < n; i++) await op();
  const after = await invoke<number>('rust_heap_bytes');
  console.log(`${label}: ${n} 回で ${after - before} バイト増(1 回あたり ${((after - before) / n).toFixed(1)})`);
}

await measureRustHeap('start_monitor', 100, () => invoke('start_monitor'));

Rust 側で起きやすい原因は次のとおりです。

  • 呼ぶたびにスレッドを作る: 上の start_monitor の判定が無い形です。スレッドは setup で 1 本だけ作るか、フラグで 2 回目以降を断ります(CPU 使用率を常時監視する)。
  • State に貯め続ける: キャッシュやジョブの表など、足すだけで消さない Vec や HashMap。上限を決めて古いものから捨てます。
  • Channel を持ち続ける: JS 側の Channel のコールバックは、Rust 側で Channel が破棄されるまで解放されません。State に入れたままにしないようにします。
  • ウィンドウを作り続ける: ウィンドウごとに WebView が作られ、隠しただけのウィンドウもページを保ったままです。使い回すか、要らなくなったら close() します。

動作確認

npm run tauri dev で起動し、openStatusPanel() で開いたパネルを 20 回開閉してから liveScopeCount() をログに出すと 0 になり、スナップショットの Comparison にも Detached の DOM は増えません。measureRustHeap() は次のように出ます(数値は一例)。最初の 1 回でスレッドを作った分だけが増えています。

start_monitor: 100 回で 296 バイト増(1 回あたり 3.0)

start_monitor の判定を消して試すと、1 回あたり数百バイトずつ増え、回数を 10 倍にすると増加もほぼ 10 倍になります。

よくあるエラーと対処法

  • ログが 2 行ずつ出る・処理が二重に走る: listen() が二重に登録されています。画面を閉じるときに解除しているか、HMR の後ではないかを確かめます。
  • Comparison に Detached の DOM が残る: Retainers の先が、listen() に渡した関数や、モジュールの最上位にある配列・Map(キャッシュ)になっていることが多いです。
  • 「the #[global_allocator] in … conflicts with global allocator in: …」という趣旨のエラー: 別のクレートもアロケーターを差し替えています。1 つのアプリに 1 つだけなので、計測中はどちらかを外します。
  • Rust のヒープは増えないのにプロセスの使用量が増える: Rust のアロケーターを通らない確保(OS のライブラリなど)か、解放済みでも OS に返されていない分です。WebView 側の増加もあわせて確かめます。

OS ごとの違いと注意点

  • DevTools: インスペクターは OS ごとに異なり、Windows は Microsoft Edge DevTools、macOS は Safari のインスペクター、Linux は WebKitGTK のインスペクターです。1 章の手順は Windows のものです。ページのリークは OS に関係なく同じコードで起きるので、Windows で原因を探すのが近道です。
  • ネイティブの道具: Rust 側をさらに詳しく見るなら、heaptrack(Linux)や Instruments(macOS)、Visual Studio の診断ツール(Windows)も使えます。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する