如何在 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>。等待任务完成时,可能遇到两类错误:
JoinError,表示任务被取消或发生 panic。- 任务自身返回的业务错误。
所以代码中经常会连续使用两个 ?:
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。
备注:内容仅供参考。
