画像の変換、ハッシュの計算、大量データの集計のように数秒かかる処理を普通のコマンドに書くと、その間ウィンドウが固まります。async fn にするだけでは足りず、計算そのものは spawn_blocking でブロッキング処理用のスレッドに移し、アプリの間ずっと動く処理は std::thread で専用のスレッドを立てるのが基本です。このレシピでは使い分けに加えて、進み具合を画面に送る方法と、途中で止める方法を説明します。async コマンドの基本(どのスレッドで動くか、引数の制約)は 非同期(async)コマンドを定義する を参照してください。
前提条件
プラグインや権限の追加は不要です。tauri::async_runtime::spawn_blocking と、進捗を送る tauri::ipc::Channel は Tauri に含まれるので、tokio を追加しなくても使えます。3 章でイベントを受け取る listen の権限は core:default に含まれます。
| 処理の性質 | 書き方 | 結果の受け取り方 |
|---|---|---|
| 数百ミリ秒以上の計算、同期のライブラリ呼び出し | async コマンド + spawn_blocking | invoke の戻り値 |
| 通信やタイマーなど待ち時間が主 | async コマンドで .await(rust-005) | invoke の戻り値 |
| アプリの間ずっと動く処理(キュー、監視) | std::thread の専用スレッド | イベント |
| 画面側のデータだけで済む計算 | Web Worker | postMessage |
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
jobson commandcount_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))。
