PAYATHON 2026

如何在 Rust async 任务边界间简洁传播错误

技术老齐

结论

通过 tokio::spawn 跨任务传播错误时,推荐使用:

Box<dyn std::error::Error + Send + Sync + 'static>

如果只添加 Send:

Box<dyn std::error::Error + Send>

一些常用的错误转换会无法使用。标准库为 Box<dyn Error + Send + Sync> 提供了相应的 From 实现,因此 std::io::Error、reqwest::Error、&str 等满足约束的类型通常仍可配合 ? 和 .into() 使用。

CLI 应用可以直接选择 anyhow::Result<T>。如果调用者需要根据错误类型分别处理,则更适合定义错误枚举,例如使用 thiserror。

为什么只加 Send 会导致转换失败

? 运算符会通过 From 把原始错误转换为函数声明的错误类型。Box<dyn Error + Send> 本身是合法类型,但标准库没有为它提供像 Box<dyn Error> 和 Box<dyn Error + Send + Sync> 那样完整的通用转换实现。

因此,下面的写法找不到对应的 From<&str>:

type BoxError = Box<dyn std::error::Error + Send>;

Err("example".into())

通常同时加上 Sync 即可:

type BoxError =
    Box<dyn std::error::Error + Send + Sync + 'static>;

这些约束的含义是:

  • Send:错误值可以在线程或任务执行器的工作线程之间移动。
  • Sync:错误值可以安全地通过共享引用访问。
  • 'static:错误中不包含生命周期较短的借用。

tokio::spawn 要求 Future 及其输出满足 Send + 'static。虽然 Sync 不是 spawn 对输出的直接要求,但加入这一约束后可以使用标准库已有的转换实现。Send + Sync + 'static 也是动态错误类型的常见组合。

使用动态错误类型

原示例可以写成:

use std::error::Error;

type BoxError = Box<dyn Error + Send + Sync + 'static>;

async fn foo() -> Result<(), BoxError> {
    let text = reqwest::get("https://example.com/")
        .await?
        .text()
        .await?;

    println!("{text}");

    Err("example".into())
}

具体错误只要实现了 Error + Send + Sync + 'static,? 就能把它转换为 BoxError。常见的 std::io::Error 和 reqwest::Error 都可以这样处理:

use std::error::Error;
use std::fs;

type BoxError = Box<dyn Error + Send + Sync + 'static>;

async fn load() -> Result<String, BoxError> {
    let config = fs::read_to_string("config.toml")?;
    let body = reqwest::get("https://example.com/")
        .await?
        .text()
        .await?;

    Ok(format!("{config}\n{body}"))
}

处理 tokio::spawn 的两层错误

tokio::spawn 返回 JoinHandle<T>。等待任务完成时,可能遇到两类错误:

  1. JoinError,表示任务被取消或发生 panic。
  2. 任务自身返回的业务错误。

所以代码中经常会连续使用两个 ?:

use std::error::Error;

type BoxError = Box<dyn Error + Send + Sync + 'static>;

async fn foo() -> Result<(), BoxError> {
    Err("task failed".into())
}

#[tokio::main]
async fn main() -> Result<(), BoxError> {
    let handle = tokio::spawn(foo());

    handle.await??;

    Ok(())
}

第一个 ? 处理 JoinError,第二个处理 foo() 返回的 BoxError。

非 Send 错误无法通过装箱变成 Send

PoisonError<std::sync::MutexGuard<'_, T>> 属于另一种情况。std::sync::MutexGuard 本身不能在线程间移动,包含它的 PoisonError 也不会因为装箱而获得 Send。

改成下面的类型仍然无法解决:

Box<dyn Error + Send + Sync>

装箱不会改变内部值的线程安全属性。可以在持有锁的位置处理 PoisonError,将它转换为不包含 MutexGuard 的错误:

use std::error::Error;
use std::io;
use std::sync::{Arc, Mutex};

type BoxError = Box<dyn Error + Send + Sync + 'static>;

async fn read_value(shared: Arc<Mutex<String>>) -> Result<String, BoxError> {
    let value = {
        let guard = shared.lock().map_err(|_| {
            io::Error::new(io::ErrorKind::Other, "mutex poisoned")
        })?;

        guard.clone()
    };

    Ok(value)
}

这里的代码作用域也有实际作用。退出该作用域后,guard 会在下一次 .await 之前释放,避免整个 Future 因持有非 Send 的 MutexGuard 而成为非 Send。

如果必须跨 .await 持有锁,可以考虑 tokio::sync::Mutex。临界区内没有异步等待时,继续使用 std::sync::Mutex 通常更简单。

其他非 Send 错误也需要先提取必要信息,再转换为自有且满足线程安全约束的错误。例如:

.map_err(|err| err.to_string())?

这种方式会丢失原始错误类型和错误链,适用于只需要显示错误文本的场景。需要保留结构化信息时,应定义新的错误类型。

CLI 应用可以使用 anyhow

如果错误只需向上传递、记录或展示,调用者也不需要匹配具体类型,使用 anyhow 会更简洁:

use anyhow::{bail, Result};

async fn foo() -> Result<()> {
    let text = reqwest::get("https://example.com/")
        .await?
        .text()
        .await?;

    println!("{text}");

    bail!("example");
}

#[tokio::main]
async fn main() -> Result<()> {
    tokio::spawn(foo()).await??;
    Ok(())
}

anyhow::Error 接收的底层错误需要满足 Send + Sync + 'static,适合应用程序,尤其适合处理 CLI 的顶层错误传播。它还支持为错误添加上下文:

use anyhow::{Context, Result};

async fn fetch() -> Result<String> {
    reqwest::get("https://example.com/")
        .await
        .context("request failed")?
        .text()
        .await
        .context("failed to read response body")
}

需要匹配错误类型时定义错误枚举

库代码或包含明确错误处理分支的业务代码,通常更适合使用具体错误类型:

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("HTTP request failed")]
    Http(#[from] reqwest::Error),

    #[error("I/O operation failed")]
    Io(#[from] std::io::Error),

    #[error("mutex poisoned")]
    MutexPoisoned,

    #[error("{0}")]
    Message(&'static str),
}

async fn foo() -> Result<(), AppError> {
    let text = reqwest::get("https://example.com/")
        .await?
        .text()
        .await?;

    println!("{text}");

    Err(AppError::Message("example"))
}

只要枚举中的所有字段都满足相应约束,AppError 就会自动成为 Send 和 Sync。具体错误类型也能得到保留,调用者可以使用 match 分别处理。

必须运行非 Send 任务时

如果任务必须持有非 Send 状态,可以在 LocalSet 中使用 spawn_local。它不要求 Future 实现 Send:

let local = tokio::task::LocalSet::new();

local
    .run_until(async {
        tokio::task::spawn_local(async {
            // 可以使用非 Send 状态
        })
        .await
        .unwrap();
    })
    .await;

这种方式会把任务限制在本地执行上下文中,因此不能替代需要在线程池之间调度的 tokio::spawn。

可以根据用途选择:

  • 简单 CLI:anyhow::Result<T>。
  • 希望少引入依赖:Box<dyn Error + Send + Sync + 'static>。
  • 库代码或需要匹配错误种类:自定义错误枚举,通常配合 thiserror。
  • 原始错误不是 Send:在任务边界前处理或转换,装箱无法绕过这一限制。
  • Future 本身必须是非 Send:使用 LocalSet 和 spawn_local。

备注:内容仅供参考。