async 中的错误处理
本教程共 78 篇 · 第 73 篇 · 更新于 2026-08-08 · 约 8 分钟阅读
本节目标:掌握异步函数如何返回
Result、在 async 里用?配合.await串起错误传播,理解tokio::spawn对错误类型的约束,并了解用 anyhow/thiserror 给异步代码减负。
Rust 的错误处理靠 Result<T, E>(第 9 章讲过)。到了异步世界,这套机制基本不变,只是多了一层 .await。这一章把异步错误处理讲清楚,免得你在 async fn 里被 ? 和 .await 的顺序搞晕。
1-1 异步函数照样返回 Result
一个异步函数完全可以像普通函数一样返回 Result。区别只是它前面多了 async,返回类型是 Future<Output = Result<T, E>>:
use std::io;
async fn read_config() -> Result<String, io::Error> {
// 假设这是一个异步读文件的操作
tokio::fs::read_to_string("config.toml").await
}
注意这里 .await 出现在 read_to_string(...) 后面:先 .await 拿到 Result,函数再把这个 Result 返回出去。调用方拿到的是 Result<String, io::Error>,该怎么处理还怎么处理。
Note
tokio::fs提供了和标准库std::fs对应的异步版本。凡是异步 IO,都要用 tokio 提供的异步 API,并在末尾.await,否则它不会真正执行。
1-2 ? 与 .await 的顺序
在 async fn 内部传播错误,你既要 .await 拿到 Future 的结果,又要对结果用 ? 提前返回错误。顺序是:先 .await,再 ?。
use std::io;
async fn read_config() -> Result<String, io::Error> {
tokio::fs::read_to_string("config.toml").await
}
async fn use_config() -> Result<(), io::Error> {
let content = read_config().await?; // 先 await 拿到 Result,再 ? 传播
println!("配置长度: {}", content.len());
Ok(())
}
read_config().await? 的含义是:等 read_config 这个 Future 完成,得到 Result;如果是 Err,就立刻从 use_config 返回这个错误;如果是 Ok,把里面的 String 赋给 content。和普通同步代码里 ? 的用法一模一样,只是中间插了个 .await。
一个常见的初学者错误是写成 read_config()?.await——这会先对”还没执行的 Future”用 ?,而 Future 本身不是 Result,直接编译报错。记住顺序:.await 在前,? 在后。
1-3 tokio::spawn 对错误类型的约束
tokio::spawn 派发的任务,其 Future 必须满足 'static,而且执行器在某些配置下对返回的错误类型也有期望。最直接的做法是:让 spawn 的任务自己处理错误,或者返回能被 JoinHandle 接住的 Result。
use std::io;
async fn may_fail() -> Result<(), io::Error> {
tokio::fs::read_to_string("missing.txt").await?;
Ok(())
}
#[tokio::main]
async fn main() {
let handle = tokio::spawn(may_fail());
match handle.await {
Ok(Ok(())) => println!("任务成功"),
Ok(Err(e)) => println!("任务内部出错: {}", e),
Err(e) => println!("任务 panic 了: {}", e), // JoinError
}
}
这里外层 Ok(Err(e)) 是”任务正常结束但返回了错误”,内层 Err(e) 是任务里 may_fail 的 io::Error;而最外层的 Err(e) 是 JoinError,表示任务自身 panic 了或被取消了。分清这两层,异步错误才好处理。
Warning
tokio::spawn里的闭包/函数如果借用外部变量,会因为不满足'static报错。需要传数据,用move把所有权搬进去,或Arc::clone共享。这个规则和第 65 章的线程闭包一致。
1-4 用 anyhow 给异步代码减负
写业务时,每个函数都去精确定义自己的错误类型会很累。社区里 anyhow 提供了一个”兜底”错误类型 anyhow::Error,能装下任何实现了 std::error::Error 的错误,用 ? 自动转换:
use anyhow::Result;
async fn read_config() -> Result<String> {
let s = tokio::fs::read_to_string("config.toml").await?; // io::Error 自动转成 anyhow::Error
Ok(s)
}
#[tokio::main]
async fn main() -> Result<()> {
let content = read_config().await?;
println!("读到 {} 字节", content.len());
Ok(())
}
在异步函数里用 anyhow 和同步里完全一样:函数返回 anyhow::Result<T>(即 Result<T, anyhow::Error>),内部 ? 自动把各种具体错误装箱成 anyhow::Error。它最适合应用层、main 函数这类”不想精细化定义错误”的地方。截至核对时,anyhow 的最新稳定版是 1.0.x 系列(约 1.0.104)。
1-5 用 thiserror 定义库的错误类型
如果你在写一个给别人用的库,应该定义自己的、精确的错误枚举,方便调用方匹配处理。这时用 thiserror 的 #[derive(Error)] 最省力:
use thiserror::Error;
#[derive(Error, Debug)]
enum MyError {
#[error("配置文件缺失: {0}")]
Missing(String),
#[error("IO 错误")]
Io(#[from] std::io::Error),
}
async fn read_config(path: &str) -> Result<String, MyError> {
let s = tokio::fs::read_to_string(path).await?; // io::Error 经 #[from] 转成 MyError::Io
if s.is_empty() {
return Err(MyError::Missing(path.to_string()));
}
Ok(s)
}
#[from] 让 ? 能自动把 io::Error 转成 MyError::Io,异步同步通用。截至核对时,thiserror 的最新稳定版是 2.0.x 系列(约 2.0.18),2.x 相比 1.x 主要是改进了衍生实现,对异步代码没有特殊差异。
Tip经验搭配:库用
thiserror定义精准错误,应用层用anyhow兜底。两者可以共存——库返回thiserror的错,应用层用anyhow的?自动接住。
1-6 异步与同步错误混用
实际项目里异步和同步代码经常交织。要点:同步函数返回 Result,在异步函数里照样能用 ?(但它不会 .await,因为本来就不是 Future);反过来,异步函数不能直接在同步函数里调用——你要么在异步上下文里 .await,要么用运行时的 block_on 强转(但 block_on 在异步任务里用会出问题,慎用)。
1-7 一个综合示例:从库错误到应用兜底
把前面几点串起来,看一个完整的小场景:一个库函数用 thiserror 定义精准错误,调用方在异步 main 里用 anyhow 兜底,中间用 ? 自动衔接。
use anyhow::Result;
use thiserror::Error;
#[derive(Error, Debug)]
enum ConfigError {
#[error("读取失败")]
Io(#[from] std::io::Error),
}
async fn load() -> Result<String, ConfigError> {
let s = tokio::fs::read_to_string("a.txt").await?;
Ok(s)
}
#[tokio::main]
async fn main() -> Result<()> {
let _ = load().await?;
Ok(())
}
这里 load 返回库自定义的 ConfigError,main 返回 anyhow::Result<()>。中间的 ? 能把 ConfigError 自动装箱进 anyhow::Error,全程不需要手写转换函数。这种”库精准、应用兜底”的分工,是 Rust 错误处理的事实标准,异步同步都适用。
如果某个错误你确实没法恢复,也可以直接 return Err(e.into()),或用 anyhow::bail!("出错啦: {}", x) 提前返回。错误处理没有唯一正确答案,关键是保持一致性:要么全程 thiserror 精确化,要么在边界用 anyhow 兜底,别在一个项目里换来换去让后人看不懂。
Note新手常纠结”我的错误类型到底该怎么设计”。建议:写库就
thiserror定义枚举,写应用就anyhow兜底,二者通过#[from]无缝衔接。这套组合能覆盖九成场景,等真遇到特殊需求再深入。
1-8 错误处理的性能与权衡
从性能角度,返回 Result 几乎没有运行时开销——错误就是个普通的枚举值,没有异常那种栈展开成本。这也解释了为什么 Rust 鼓励用 Result 而非 panic 来处理可预期的错误。anyhow 因为要把错误装进 trait 对象,会有一次小的堆分配;在极端热路径上,如果 profiling 显示它成了瓶颈,可以退回具体错误类型。但绝大多数业务代码,这点开销可以忽略不计。
Notepanic 用于”真的不该发生”的程序员错误(如数组越界),不是常规错误处理手段。异步任务里 panic 会被
JoinHandle捕获成JoinError,不会炸掉整个进程,但也不该依赖它来传递业务错误。业务错误,永远走Result。把错误处理想清楚,异步代码就能既安全又易读,不会因为一个未处理的错误类型而编译不过,也不会在运行时悄悄 panic。
1-9 小结
总之,异步错误处理 = 同步 Result/? 的那一套 + 牢记”.await 在前、? 在后”。工具上,anyhow 管应用、thiserror 管库,错误就不再是你写异步的负担。当你能把 Result、.await、? 三者在异步函数里流畅地串起来,错误处理就不再是心智负担,反而成了代码健壮性的保障。下一章我们换话题,聊 Rust 里另一个强大又容易被误解的特性——宏。