首页 / Tauri 2 入门教程 / 异步命令与并发

Tauri 2 入门教程

异步命令与并发

本教程共 48 篇 · 第 23 篇 · 更新于 2026-08-09 · 约 9 分钟阅读

Tauriasync并发tokio线程模型

本节目标:学完能写出不卡界面的异步命令,理解命令在 Tauri 里究竟跑在哪个线程,并知道什么时候该借助 tokio 或 std::thread 做并发。

命令一旦要做点耗时的事——读大文件、等网络、算一堆数据——界面就会卡住,按钮点不动、窗口拖不动。解决这个问题的钥匙,就是「异步命令(async command)」和背后的线程模型。

同步命令会卡住界面

先看一个反例:一个命令用 std::thread::sleep 假装干活 3 秒。

use std::thread::sleep;
use std::time::Duration;

#[tauri::command]
fn blocking_cmd() -> String {
    sleep(Duration::from_secs(3));
    "做完了".into()
}

因为它是同步命令,默认在「主线程」也就是运行事件循环的线程上执行。这 3 秒里,主线程被占着,界面完全无法响应。窗口像死了一样,等到命令返回才恢复。所以:任何可能超过一瞬的活,都别写在同步命令里。

把命令写成 async fn

fn 改成 async fn,问题基本解决:

use std::time::Duration;
use tauri::async_runtime;

#[tauri::command]
async fn non_blocking_cmd() -> String {
    // 注意:这里是异步运行时的 sleep,不会堵主线程
    async_runtime::sleep(Duration::from_secs(3)).await;
    "做完了".into()
}

前端调用方式完全不变,invoke 本来就返回 Promise。区别在 Rust 一侧:non_blocking_cmd 被 Tauri 丢到一个独立的异步任务里跑,主线程继续处理界面事件,窗口照常顺滑。

Tip

凡是「要等一会儿」的命令(网络请求、定时器、数据库查询),优先声明成 async fn。这是 Tauri 官方推荐的默认姿势。

Tauri 的线程模型:命令到底跑在哪

理解「跑在哪」能帮你避开很多坑。Tauri 在底层用了一个异步运行时(基于 tokio):

  • 没有 async 关键字的命令:默认在「主线程」执行,所以会阻塞界面,正如上面所说。
  • async 命令:被放进异步运行时,通过 async_runtime::spawn 在一个独立任务上执行,不占主线程。

换句话说,写成 async fn 几乎等于免费拿到了并发能力——多个异步命令可以同时推进,互不等待。这正是「并发」在 Tauri 命令里的含义:它们不是真的多线程同时算,而是在一个异步运行时里交替让出执行权,对外看起来是「同时在进行」。

举个例子:前端先后 invokefetch_userfetch_order 两个异步命令,它们不必排队等对方返回。运行时在 fetch_user 等待网络时,会把执行权切给 fetch_order,两边都在「进行中」。等各自 await 完成,结果才回到前端。这正是异步模型比「一个一个同步跑」流畅的根本原因——等待的空隙被充分利用了。

Note

你也可以显式给同步命令加 #[tauri::command(async)] 让它跑在异步运行时,而不是主线程。但通常直接写 async fn 更直观。

async 命令里借用类型的坑

async 有个语言层面的限制:异步函数里不能直接拿「借来的」参数,比如 &strtauri::State<'_, T>。因为异步函数未来某个时刻才执行,它没法保证那个引用到那时还有效。

Tauri 文档明确标注了这一点。解决办法有两条:

办法一:把借用类型换成拥有所有权的类型。 比如把 &str 换成 String

#[tauri::command]
async fn greet(value: String) -> String {
    some_async().await;
    value
}

办法二:给命令加一个 Result<T, E> 返回类型。 这条对 State 也适用,是最通用的解法:

#[tauri::command]
async fn greet(value: &str) -> Result<String, String> {
    some_async().await;
    Ok(format!("你好,{}", value))
}
Warning

如果你在 async fn 里写了 value: &str 又不返回 Result,编译会直接报错(社区里这是个高频踩坑点)。牢记:async 命令里要么用 String 这种所有权类型,要么返回 Result

何时用 tokio / std::thread::spawn

async fn 已经让命令跑在异步运行时了。但在两种情况下,你还需要再多想一步:

情况一:命令里要干「重体力」同步活。 比如解压、复杂计算,这些会霸占异步运行时的线程,拖累其他命令。此时应当把它挪到单独的线程:

#[tauri::command]
async fn heavy_compute() -> Result<i64, String> {
    let result = tauri::async_runtime::spawn_blocking(|| {
        // 这里是真正的同步重活
        (1..=1_000_000).fold(0i64, |a, b| a + b)
    })
    .await
    .map_err(|e| e.to_string())?;
    Ok(result)
}

spawn_blocking 把阻塞活丢到专用线程池,异步运行时得以继续调度别的任务。

情况二:要主动开新线程做后台循环。 比如后台定时检查、持续推送进度。这时用 tauri::async_runtime::spawn 起一个长期运行的异步任务:

use tauri::{AppHandle, Emitter};

#[tauri::command]
fn start_background(app: AppHandle) {
    tauri::async_runtime::spawn(async move {
        for i in 0..=100 {
            app.emit("progress", i).ok();
            async_runtime::sleep(Duration::from_millis(50)).await;
        }
    });
}
Tip

简单记:普通等待用 await;CPU 重活用 spawn_blocking;要开「一直跑」的后台任务用 async_runtime::spawnstd::thread::spawn 也能用,但在 Tauri 里优先用 async_runtime,因为它和命令跑的是同一套运行时,行为更可控。

一个实用的判断标准:命令里只要出现「等网络、等文件、等定时器」这类 await,就写成 async fn;如果还要在 await 之间插入「压缩、加解密、大量循环」这类纯算力活,就把它包进 spawn_blocking。照这个节奏走,界面基本不会卡。别一上来就把所有命令都异步化——纯计算瞬间完成的命令,同步写反而更简单,没有借用参数的额外约束。

小结

异步命令 = 不卡界面的命令。默认 async fn 就跑在 Tauri 的异步运行时上,天然支持并发;记得绕开借用参数的坑;遇到重活再考虑 spawn_blockingspawn。下一章我们看 Rust 怎么「主动」把消息推给前端——那又是另一套机制了。