重い処理を別スレッド・非同期で実行する

重い計算は async コマンドから spawn_blocking に逃がし、常駐する処理は std::thread で専用スレッドにする。Channel での進捗の送り方と、フラグで途中で止める方法も示す。

Rust バックエンド 対象: Tauri 2.x 更新日: 読了目安: 約10分 rust-013
目次
  1. 前提条件
  2. 1. 計算を spawn_blocking に逃がし、進捗と中止を付ける (Rust)
  3. 2. フロントエンドから呼ぶ (TypeScript)
  4. 3. 常駐する処理は std::thread の専用スレッドで (Rust)
  5. 動作確認
  6. よくあるエラーと対処法
  7. 注意点
  8. 関連レシピ

画像の変換、ハッシュの計算、大量データの集計のように数秒かかる処理を普通のコマンドに書くと、その間ウィンドウが固まります。async fn にするだけでは足りず、計算そのものは spawn_blocking でブロッキング処理用のスレッドに移し、アプリの間ずっと動く処理は std::thread で専用のスレッドを立てるのが基本です。このレシピでは使い分けに加えて、進み具合を画面に送る方法と、途中で止める方法を説明します。async コマンドの基本(どのスレッドで動くか、引数の制約)は 非同期(async)コマンドを定義する を参照してください。

前提条件

プラグインや権限の追加は不要です。tauri::async_runtime::spawn_blocking と、進捗を送る tauri::ipc::Channel は Tauri に含まれるので、tokio を追加しなくても使えます。3 章でイベントを受け取る listen の権限は core:default に含まれます。

処理の性質書き方結果の受け取り方
数百ミリ秒以上の計算、同期のライブラリ呼び出しasync コマンド + spawn_blockinginvoke の戻り値
通信やタイマーなど待ち時間が主async コマンドで .await(rust-005)invoke の戻り値
アプリの間ずっと動く処理(キュー、監視)std::thread の専用スレッドイベント
画面側のデータだけで済む計算Web WorkerpostMessage

1. 計算を spawn_blocking に逃がし、進捗と中止を付ける (Rust)

async fn の中に重いループを直接書くと、画面は固まりませんが非同期ランタイムのスレッドを占有し、ほかの async コマンドまで待たされます。計算は spawn_blocking に渡し、async コマンドは結果を待つだけにします。

例では 300 万までの素数を数えます。進み具合は、引数で受け取った Channel に 1% 進むごとに送ります。Channel は呼び出した画面だけに順番どおり届くので、invoke 1 回ぶんの進捗に向いています。ループの 1 周ごとに送ると画面側の処理が追いつかなくなるので、送る回数は間引きます。

invoke には途中で取り消す仕組みがなく、spawn_blocking で動き始めた処理を外から止める方法もありません。そこで中止のフラグ(AtomicBool)をジョブ ID ごとに State に置き、処理の側が区切りごとに確かめて自分で抜けます。

use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use serde::Serialize;
use tauri::ipc::Channel;
use tauri::State;

/// 実行中のジョブの中止フラグ(ジョブ ID ごと)
#[derive(Default)]
struct Jobs(Mutex<HashMap<u32, Arc<AtomicBool>>>);

#[derive(Clone, Serialize)]
struct Progress {
    percent: u64,
}

fn is_prime(n: u64) -> bool {
    n >= 2 && (2..).take_while(|d| d * d <= n).all(|d| n % d != 0)
}

#[tauri::command]
async fn count_primes(
    job_id: u32,
    limit: u64,
    on_progress: Channel<Progress>,
    jobs: State<'_, Jobs>,
) -> Result<u64, String> {
    let cancel = Arc::new(AtomicBool::new(false));
    jobs.0.lock().map_err(|e| e.to_string())?.insert(job_id, cancel.clone());

    // クロージャには所有する値だけを move で渡す(State や参照は持ち込めない)
    let result = tauri::async_runtime::spawn_blocking(move || {
        let mut count = 0;
        let mut last = u64::MAX;
        for n in 0..=limit {
            let percent = n * 100 / limit.max(1);
            if percent != last {
                // 1% 進むごとに、中止の確認と進捗の送信をする
                last = percent;
                if cancel.load(Ordering::Relaxed) {
                    return Err("cancelled".to_string());
                }
                let _ = on_progress.send(Progress { percent });
            }
            if is_prime(n) {
                count += 1;
            }
        }
        Ok(count)
    })
    .await;

    // 完了・中止・panic のどれでもフラグを片付ける
    jobs.0.lock().map_err(|e| e.to_string())?.remove(&job_id);
    // 外側の Err は panic(JoinError)。文字列にして JS へ返す
    result.map_err(|e| e.to_string())?
}

#[tauri::command]
fn cancel_job(job_id: u32, jobs: State<'_, Jobs>) -> bool {
    let Ok(map) = jobs.0.lock() else { return false };
    match map.get(&job_id) {
        Some(flag) => {
            flag.store(true, Ordering::Relaxed);
            true
        }
        None => false, // もう終わっている
    }
}

中止はフラグを確かめた時点で効くので、確認の間隔が長いほど止まるまで遅れます。1 回の呼び出しが長い外部ライブラリの処理は、終わるまで止まりません。なお、tauri::async_runtime::spawn で起動した async の処理なら、戻り値の abort() で次の .await の時点で打ち切れますが、spawn_blocking や std::thread の中の処理には効きません。

2. フロントエンドから呼ぶ (TypeScript)

Channel を作って引数に入れると、Rust から送った値がコールバックに届きます。引数名 on_progress は JS では onProgress です。ページを再読み込みしても Rust 側の処理は最後まで走るので、ジョブ ID は再読み込みの前後で重なりにくい乱数にしています。

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

type Progress = { percent: number };

/** 開始して、結果の Promise と中止する関数を返す */
export function startCount(limit: number, onPercent: (percent: number) => void) {
  const jobId = crypto.getRandomValues(new Uint32Array(1))[0];
  const onProgress = new Channel<Progress>((p) => onPercent(p.percent));
  const result = invoke<number>('count_primes', { jobId, limit, onProgress });
  const cancel = () => invoke<boolean>('cancel_job', { jobId });
  return { result, cancel };
}

const bar = document.querySelector<HTMLProgressElement>('#bar');
const cancelButton = document.querySelector<HTMLButtonElement>('#cancel');

document.querySelector('#start')?.addEventListener('click', async () => {
  const job = startCount(3_000_000, (p) => {
    if (bar) bar.value = p; // <progress max="100">
  });
  if (cancelButton) cancelButton.onclick = () => void job.cancel();
  try {
    const count = await job.result;
    if (bar) bar.value = 100; // 最後の進捗は invoke の完了より後に届くことがある
    console.log(`素数は ${count} 個`);
  } catch (e) {
    console.log(e === 'cancelled' ? '中止しました' : `失敗: ${e}`);
  }
});

3. 常駐する処理は std::thread の専用スレッドで (Rust)

spawn_blocking のスレッドは、処理が終われば次の処理に使い回される前提のものです。アプリの間ずっと回り続けるループは、std::thread で専用のスレッドを立てます。仕事は std::sync::mpsc のチャネルで渡し、結果はイベントで知らせます。コマンドはキューに入れた時点で返るので、結果を別のウィンドウで表示することもできます。std::thread::Builder で名前を付けておくと、panic の表示にその名前が出て、どのスレッドで起きたか分かります。

use std::io::{BufRead, BufReader};
use std::sync::mpsc;
use serde::Serialize;
use tauri::State;
use tauri::{AppHandle, Emitter, Manager};

/// 常駐スレッドへの仕事の入口(Sender は複数のコマンドから同時に使える)
struct Worker(mpsc::Sender<String>);

#[derive(Clone, Serialize)]
struct LineCount {
    path: String,
    lines: Option<usize>, // 開けなかったら null
}

fn spawn_worker(app: AppHandle) -> std::io::Result<mpsc::Sender<String>> {
    let (tx, rx) = mpsc::channel::<String>();
    std::thread::Builder::new()
        .name("line-counter".into())
        .spawn(move || {
            // アプリの終了まで仕事を待ち続ける
            while let Ok(path) = rx.recv() {
                let lines = std::fs::File::open(&path)
                    .map(|f| BufReader::new(f).lines().count())
                    .ok();
                let _ = app.emit("line-count", LineCount { path, lines });
            }
        })?;
    Ok(tx)
}

#[tauri::command]
fn enqueue(path: String, worker: State<'_, Worker>) -> Result<(), String> {
    worker.0.send(path).map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(Jobs::default())
        .setup(|app| {
            let tx = spawn_worker(app.handle().clone())?;
            app.manage(Worker(tx));
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![count_primes, cancel_job, enqueue])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

type LineCount = { path: string; lines: number | null };

// 先にリスナーを登録してから仕事を入れる
await listen<LineCount>('line-count', ({ payload }) => {
  console.log(payload.path, payload.lines ?? '開けませんでした');
});
await invoke('enqueue', { path: 'C:/data/big.csv' }); // すぐ返る

動作確認

npm run tauri dev で起動して開始ボタンを押すと、プログレスバーが進み、開発ビルドで数秒後にコンソールへ次のように出ます。計算中もウィンドウを動かせ、ほかのコマンドも普段どおり応答します。途中で中止ボタンを押すと「中止しました」になります。

素数は 216816 個

enqueue を続けて何度か呼ぶと、入れた順に 1 件ずつ処理され、line-count イベントが届きます。

よくあるエラーと対処法

  • 「closure may outlive the current function, but it borrows limit, which is owned by the current function」: クロージャに move を付け忘れています。
  • 「borrowed data escapes outside of function」: &str の引数や State をクロージャに持ち込もうとしています。String で受け取るか、中身を clone() した値や Arc だけを渡します。
  • JS に「task 12 panicked with message ...」の形の文字列が届く: spawn_blocking の中で panic しています。アプリは動き続け、例のように Err として JS に返せます。記録の残し方は パニック(クラッシュ)時の処理を書く を参照してください。
  • 「state not managed for field jobs on command count_primes. You must call .manage() before using this command」: .manage(Jobs::default()) を忘れています。
  • enqueue が「sending on a closed channel」で失敗する: 常駐スレッドが panic で終わり、受け取る側がいなくなっています。ターミナルで line-counter の panic を探します。

注意点

  • 重い処理を同時にいくつも走らせると、それぞれ別のスレッドで CPU を取り合い、全体が遅くなります。実行中はボタンを無効にするなど、同時に走らせる数をアプリ側で抑えます。
  • アプリを終了すると、spawn_blocking の処理も専用スレッドも途中で打ち切られます。ファイルを書いている途中で切れると困る処理は、終了の前に待つ仕組みを入れます(アプリ起動・終了時の処理を書く (ライフサイクル))。
  • 別スレッドから State を触るときは、spawn に State を渡せないので、Arc で共有した値を持ち込むか、AppHandle から app.state::<T>() で取り直します(State と Mutex でアプリの状態を管理する)。
  • 進捗を全ウィンドウに知らせたい、invoke が終わった後にも知らせたい場合は、Channel ではなくイベントを使います(Rust からのイベントを受信する (listen))。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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