异步命令与后台任务

下载、索引、批量处理这些耗时操作绝不能写进同步命令,否则整个应用都会卡住。Tauri 命令支持 async fn 形式,配合 tokio 运行时可以轻松写出高吞吐后台任务。本篇讲异步命令写法、任务取消与进度通知的完整模式。

一、异步命令基础

把命令声明为 async fn 即可,Tauri 会自动在异步运行时上执行它:

#[tauri::command]
async fn fetch_count() -> Result<u32, String> {
    let resp = reqwest::get("https://api.example.com/count")
        .await
        .map_err(|e| e.to_string())?;
    let n: u32 = resp.json().await.map_err(|e| e.to_string())?;
    Ok(n)
}

异步命令不阻塞主线程,前端 invoke 依然是异步等待。需要 HTTP 能力时引入 reqwest crate,并在 Capability 中声明网络权限,具体权限配置见「权限模型:Capability 与作用域」。

命令类型 阻塞主线程 适用场景
同步 fn 是 微秒级快速操作
async fn 否 网络、文件等 IO
spawn 后台任务 否 长时间运行、进度推送

注意同步命令内部也不要做长时间 sleep,会冻结窗口事件循环。

二、后台任务与 tokio

需要独立后台循环时,用 tokio::spawn 或标准库线程。用应用句柄在任务中发送事件:

use tauri::{AppHandle, Emitter};
use std::time::Duration;

#[tauri::command]
async fn start_sync(app: AppHandle) -> Result<(), String> {
    tokio::spawn(async move {
        for i in 1..=100 {
            tokio::time::sleep(Duration::from_millis(200)).await;
            let _ = app.emit("sync-progress", i);
        }
        let _ = app.emit("sync-done", ());
    });
    Ok(())
}

AppHandle 是克隆友好的,move 进任务后仍能发事件。后台任务持有句柄不会阻止应用退出;反过来如果任务必须随应用退出而终止,需要自行管理生命周期。

三、任务取消

未受控的后台任务是个隐患。实践上有两种取消方案。第一种用共享的取消标志:

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

#[tauri::command]
async fn start_and_monitor(app: AppHandle, state: tauri::State<'_, AppState>) {
    let cancel = state.cancel_flag.clone();
    tokio::spawn(async move {
        loop {
            if cancel.load(Ordering::SeqCst) { break; }
            tokio::time::sleep(Duration::from_secs(1)).await;
            let _ = app.emit("tick", ());
        }
    });
}

#[tauri::command]
fn cancel_sync(state: tauri::State<'_, AppState>) {
    state.cancel_flag.store(true, Ordering::SeqCst);
}

第二种更优雅:用 tokio_util::sync::CancellationToken 或 tokio::select! 监听取消信号,代码更清晰,具体 API 以 tokio 文档为准。无论哪种,核心思想是把「需要外部停下的任务」做成可观测、可中断的循环。

取消方案 优点
AtomicBool 标志 简单直观
CancellationToken 支持异步等待
select! 多路复用 可同时监听多个信号

state 中存放取消标志的写法见「全局状态:tauri::State 与共享数据」。

四、进度通知的完整模式

把命令、事件、后台任务串起来,就得到通用的「长任务」骨架:前端调命令启动任务,命令立即返回任务 id;任务在后台推进,不断 emit 带 id 的进度;前端按 id 匹配 UI。

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

const taskId = await invoke('start_sync')
await listen('sync-progress', (e) => {
  if (e.payload.id === taskId) {
    updateBar(e.payload.percent)
  }
})

多任务并行时 id 区分很重要,否则进度会串线。事件订阅的完整写法与注意事项见「事件系统:Rust 主动通知前端」。

五、异步陷阱清单

陷阱 表现 处理
跨 await 持锁 死锁或编译失败 先 clone 数据再 await
忘 await 返回 Future 而非值 检查返回类型
阻塞式 IO 在 async 中 拖慢调度 用 spawn_blocking 包裹
未取消的任务 事件持续触发 注册取消机制

异步是 Rust 里最有深度的话题之一,本篇只覆盖 Tauri 常用部分。想系统学习异步与并发模型,继续读「所有权与线程安全:Send、Sync 与 Mutex」;Rust 语法基础见 Rust 入门;用 AI 辅助排查异步死锁问题可参考 Claude Code 指南,日志批量分析的技巧见 正则 mastery。

六、异步调试工具箱

异步代码出问题时,光看代码很难定位,需要借助工具。常用组合:

工具 用途
tracing 结构化日志带任务上下文
console-subscriber tokio 任务实时面板
tokio-console 可视化观察任务调度
打印任务 id 区分并发请求的处理路径

console-subscriber 是 tokio 官方的调试利器,能在运行时看到每个任务的等待时间与唤醒次数,定位「为什么这个任务跑了十秒」这类问题非常直接,具体用法以 tokio 文档为准。

最后给一个尽快上手的心法:先用同步代码把逻辑写对,再逐块改造为 async。一开始就追求全异步,往往陷入借用与生命周期报错的泥潭。先正确再并发,是 Rust 学习曲线最平缓的走法。

异步是 Tauri 中高级开发的分水岭。先从单命令 async 化开始,再把后台任务与进度事件串起来,最后引入取消机制,循序渐进即可。并发安全的相关知识在下一章展开。

一个小结:async 让命令不阻塞,spawn 让任务脱离请求生命周期,事件让任务结果抵达前端,取消机制让任务可控收尾。四件套备齐,后台任务这一章就算真正落地。并发安全是下一章的内容。

异步三部曲收束成一句口诀:命令 async 化、任务 spawn 化、进度事件化。三者顺序固定依次引入即可,不需要一上来追求完美架构。

延伸阅读

  • 所有权与线程安全:Send、Sync 与 Mutex——锁与并发的原理
  • 事件系统:Rust 主动通知前端——进度推送的消费端
  • Rust 入门:async 与 await 的语法基础