非同期(async)コマンドを定義する

async fn のコマンドは非同期ランタイムのスレッドで、同期コマンドはメインスレッドで動く。参照を含む引数で Result が必須になる制約、State と Mutex の扱い、spawn_blocking も示す。

Rust バックエンド 対象: Tauri 2.x 更新日: 読了目安: 約7分 rust-005
目次
  1. 前提条件
  2. 1. 同期コマンドと async コマンドの違い (Rust)
  3. 2. 借用した引数は Result とセットで使う (Rust)
  4. 3. async コマンドで State を使う (Rust)
  5. 4. 重い処理は spawn_blocking に逃がす (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

Tauri のコマンドは async fn でも書けます。async を付けるかどうかで実行されるスレッドが変わり、async でないコマンドはウィンドウの処理と同じメインスレッドで、async fn のコマンドは非同期ランタイム(tokio)のスレッドで動きます。通信やタイマーの待ち時間がある処理、数百ミリ秒以上かかる処理を async にしておくと、その間も画面が固まりません。このレシピではスレッドの違いと、async コマンド特有の制約(借用した引数、State と Mutex)を説明します。コマンドの定義と登録の基本は Tauri コマンド関数を定義する を参照してください。

前提条件

プラグインや権限の設定は不要で、async fn のコマンドは Tauri だけで書けます。Tauri が内部で tokio のランタイムを起動しているので、自分でランタイムを作る必要もありません。この記事の tokio::time::sleep のように tokio の機能を直接使うときだけ、src-tauri で追加します(tokio::sync::Mutex も使うなら --features time,sync)。

cd src-tauri
cargo add tokio --features time

1. 同期コマンドと async コマンドの違い (Rust)

書き方実行されるスレッド向いている処理
fnメインスレッド(ウィンドウの処理と同じ)すぐ終わる処理
#[tauri::command(async)] を付けた fn非同期ランタイムのスレッド同期の API しか無いが、少し時間のかかる処理
async fn非同期ランタイムのスレッド(.await の間はスレッドを手放す)通信、タイマー、非同期のファイル操作
async fn の中で spawn_blockingブロッキング処理用の別スレッド重い計算、同期のライブラリ呼び出し

同期コマンドはメインスレッドを占有します。3 秒かかる処理を書くと、3 秒間ウィンドウの移動やリサイズができず、ほかのコマンドの応答も待たされます。次のコマンドで違いを確かめられます。

use std::time::Duration;
use tauri::Manager;

fn thread_name() -> String {
    std::thread::current().name().unwrap_or("名前なし").to_string()
}

/// メインスレッドで動く。3 秒間ウィンドウが固まる
#[tauri::command]
fn wait_sync() -> String {
    std::thread::sleep(Duration::from_secs(3));
    format!("sync: {}", thread_name())
}

/// 非同期ランタイムのスレッドで動く。待っている間も画面は動く
#[tauri::command]
async fn wait_async() -> String {
    tokio::time::sleep(Duration::from_secs(3)).await;
    format!("async: {}", thread_name())
}

/// 中身は同期のままでも、async を指定するとメインスレッドから外れる
#[tauri::command(async)]
fn wait_pool() -> String {
    std::thread::sleep(Duration::from_secs(3));
    format!("pool: {}", thread_name())
}

async fn の中に std::thread::sleep や重いループを書くと、画面は固まりませんが、非同期ランタイムのスレッドを 1 本ふさぎます。ランタイムのスレッドは CPU のコア数ほどしか無いので、そうしたコマンドが同時にいくつも走ると、ほかの async コマンドまで待たされます。待つだけなら tokio::time::sleep(...).await、計算なら 4 節の spawn_blocking を使います。

2. 借用した引数は Result とセットで使う (Rust)

async コマンドの引数に &str のような参照や、tauri::State<'_, T> のようにライフタイムを持つ型を書くと、戻り値が Result でない限りコンパイルエラーになります。Tauri の現在の制約で、回避策は 2 つです。

/// 方法 1: 所有する型(String など)で受け取る
#[tauri::command]
async fn shout(text: String) -> String {
    text.to_uppercase()
}

/// 方法 2: 戻り値を Result にすれば &str も使える
#[tauri::command]
async fn count_chars(text: &str) -> Result<usize, String> {
    tokio::time::sleep(Duration::from_millis(100)).await;
    Ok(text.chars().count())
}

Result の別名(type CmdResult<T> = Result<T, AppError> など)でも条件を満たします。エラー型の作り方は Result 型を使ってエラーハンドリングする で扱います。

3. async コマンドで State を使う (Rust)

State<'_, T> もライフタイムを持つので、async コマンドで使うなら戻り値は Result にします。もう 1 つの落とし穴が std::sync::Mutex です。ロックのガードを持ったまま .await すると、コマンドの処理がスレッドをまたいで動かせない形になり、コンパイルエラーになります。ガードは { } の中で使い終えてから .await するか、.await をまたいでロックを持ちたいときは tokio::sync::Mutex を使います。

struct Counter(std::sync::Mutex<u64>);

#[tauri::command]
async fn bump(counter: tauri::State<'_, Counter>) -> Result<u64, String> {
    let value = {
        let mut n = counter.0.lock().map_err(|e| e.to_string())?;
        *n += 1;
        *n
    }; // ここでガードが外れる
    tokio::time::sleep(Duration::from_millis(200)).await; // ガードを持っていないので問題ない
    Ok(value)
}

/// 裏で動く処理を起動する。State は持ち出せないので AppHandle から取り直す
#[tauri::command]
fn start_ticker(app: tauri::AppHandle) {
    // 同期コマンドの中なので tokio::spawn ではなく tauri::async_runtime::spawn を使う
    tauri::async_runtime::spawn(async move {
        for _ in 0..5 {
            tokio::time::sleep(Duration::from_secs(1)).await;
            if let Ok(mut n) = app.state::<Counter>().0.lock() {
                *n += 1;
            }
        }
    });
}

State はコマンドの実行中だけ有効な借用なので、spawn した処理には渡せません。AppHandle は複製して持ち出せるので、使う時点で app.state::<T>() から取り直します。manage() での登録、std::sync::Mutex と tokio::sync::Mutex の選び方は State と Mutex でアプリの状態を管理する で扱います。

4. 重い処理は spawn_blocking に逃がす (Rust)

CPU を使い続ける計算や、同期のライブラリ呼び出しは tauri::async_runtime::spawn_blocking でブロッキング処理用のスレッドに移し、async コマンドは結果を待つだけにします。tauri::async_runtime は Tauri に含まれるので、tokio を追加しなくても使えます。

#[tauri::command]
async fn sum_primes(limit: u64) -> Result<u64, String> {
    tauri::async_runtime::spawn_blocking(move || {
        (2..=limit)
            .filter(|n| (2..).take_while(|d| d * d <= *n).all(|d| n % d != 0))
            .sum::<u64>()
    })
    .await
    .map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(Counter(std::sync::Mutex::new(0)))
        .invoke_handler(tauri::generate_handler![
            wait_sync, wait_async, wait_pool, shout, count_chars,
            bump, start_ticker, sum_primes
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

進み具合をフロントエンドに知らせながら長い処理を回す方法は 重い処理を別スレッドで実行する で扱います。

動作確認

npm run tauri dev で起動し、次の関数をボタンなどから呼びます。

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

export async function compare() {
  let t = performance.now();
  // async の 2 つと async 指定の 1 つを同時に呼ぶ。並行して進む
  const results = await Promise.all([
    invoke<string>('wait_async'),
    invoke<string>('wait_async'),
    invoke<string>('wait_pool'),
  ]);
  console.log(results, `${Math.round(performance.now() - t)}ms`);

  t = performance.now();
  // 同期コマンドはメインスレッドで 1 つずつ処理される
  const syncResults = await Promise.all([invoke<string>('wait_sync'), invoke<string>('wait_sync')]);
  console.log(syncResults, `${Math.round(performance.now() - t)}ms`);
}

コンソールには次のように出ます。async 側は 3 つで約 3 秒、同期側は 2 つで約 6 秒かかり、その 6 秒間はウィンドウをドラッグしても動きません。スレッド名は tokio のバージョンによって tokio-runtime-worker のこともあります。

['async: tokio-rt-worker', 'async: tokio-rt-worker', 'pool: tokio-rt-worker'] 3008ms
['sync: main', 'sync: main'] 6012ms

よくあるエラーと対処法

  • 「async commands that contain references as inputs must return a Result」: async コマンドの引数に &str や State<'_, T> を使い、戻り値が Result になっていません。2 節の方法で直します。
  • 「future cannot be sent between threads safely」: std::sync::Mutex のガードを持ったまま .await しています。ガードを { } の中で手放すか、tokio::sync::Mutex にします。
  • 「there is no reactor running, must be called from the context of a Tokio 1.x runtime」で panic する: 同期コマンドや setup の中で tokio::spawn を呼んでいます。メインスレッドにはランタイムの文脈が無いので、tauri::async_runtime::spawn を使います。
  • async にしたのに、ほかの async コマンドまで遅くなる: async fn の中で std::thread::sleep、重いループ、同期のファイル操作などをしています。ランタイムのスレッドがふさがるので、4 節の spawn_blocking に移します。

OS ごとの違いと注意点

  • Windows: 同期コマンドの中でウィンドウを作ったり Cookie を読んだりすると固まる(デッドロックする)既知の問題があります。こうした処理をするコマンドは async fn にします。
  • 共通: invoke には途中で取り消す仕組みがありません(第 3 引数で渡せるのはヘッダーだけ)。ページを再読み込みしても Rust 側の処理は最後まで走るので、止めたい長い処理は、中止のフラグを State に置いて処理の途中で確かめます。
  • 共通: async コマンドは並行して走るため、終わる順番は呼んだ順とは限りません。同じデータを書き換えるコマンドは Mutex で守ります。
  • async コマンドの中で panic すると、そのコマンドだけが止まり、JS の invoke には結果が返らないことがあります。扱い方は パニック(クラッシュ)時の処理を書く を参照してください。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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