练习与自测
本章练习共 11 题,答案折叠在每题下方。建议先自己写、编译通过后再展开答案;难度标记:★☆☆ 基础 / ★★☆ 综合 / ★★★ 挑战。
凡涉及 Tokio 的题目,答案里都标了依赖与 #[tokio::main];答案代码都可直接放进 src/main.rs 运行(多文件题给了目录结构)。
练习 1:不用运行时手写最小 Future
难度:★☆☆
要求:写一个 async fn 返回 u32,在 main 里不用任何运行时(只用 std) 拿到值。要求说明:为什么不能直接调用 .await,以及「不 await 就不执行」怎么被验证。 提示:future 需要一个 executor;先想想 futures::executor::block_on 是什么角色。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
// 依赖:无(纯 std)。目标:看清「async fn 返回值是 future」这件事
fn main() {
fn assert_future<F: std::future::Future<Output = u32>>(_f: F) {}
assert_future(answer());
println!("ok");
}
async fn answer() -> u32 {
println!("这一行只有被 poll 时才会执行");
42
}输出:只有
ok——println!那行没有打印,因为 future 从未被 poll。
要点解析:
main不是async fn,所以里面不能写.await(error[E0728]:awaitis only allowed inside ofasyncblocks and functions)。要拿到值必须有一个 executor 去 poll。- 三种合法做法:
#[tokio::main]、futures::executor::block_on(answer())、 自己写「最小block_on:自己写一个 executor」里那个。 - 「不 await 就不执行」的验证方法:在
async fn体里放println!,只构造不 await, 观察输出缺失;再把返回值赋给变量并drop,观察连警告都没有(见练习 10)。
练习 2:判断 async 代码能否编译
难度:★☆☆
要求:下面的代码能编译吗?如果编译失败,第一个错误是什么?请只靠阅读回答,再实际编译验证。
rust
// 依赖:无
use std::future::Future;
async fn get() -> String {
String::from("hi")
}
fn main() {
let fut: impl Future<Output = String> = get();
println!("{fut:?}");
}提示:impl Trait 能放在 let 的位置吗?Future 有 Debug 吗?
参考答案(先自己写再看)
参考答案(先自己写再看)
不能编译,有两个错误:
text
error[E0562]: `impl Trait` is not allowed in the type of variable bindings
--> src/main.rs:9:14
|
9 | let fut: impl Future<Output = String> = get();
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `impl Trait` is only allowed in arguments and return types of
functions and methods
error[E0277]: `impl Future<Output = String>` doesn't implement `Debug`
--> src/main.rs:10:20
|
10 | println!("{fut:?}");
| ^^^ `impl Future<Output = String>` cannot be formatted
using `{:?}` because it doesn't implement `Debug`修法:把 future 交给 executor,而不是试图打印它。
rust
// 依赖:tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
let fut = get(); // 类型由编译器推断,不写 impl Trait
println!("{}", fut.await);
}
async fn get() -> String {
String::from("hi")
}输出:
hi。
要点解析:
impl Trait在返回值位置是「存在类型」(opaque type),在let里没有意义, 编译器直接拒绝。想写具体类型就用Pin<Box<dyn Future<Output = String>>>。- 匿名 future 不实现
Debug(async生成的类型不派生任何 trait), 所以不能{:?}打印。要调试优先打印 future 的输出,而不是 future 本身。 - 这也解释了为什么「在结构体里存 future」必须指定
Pin<Box<dyn Future<...>>>: 匿名类型无法出现在字段声明里。
练习 3:用 sleep 对比串行与并发请求
难度:★★☆
要求:用 tokio::time::sleep 模拟三次「网络请求」(各 100ms),分别实现串行与并发版本, 打印总耗时,并解释差距来自哪里。 提示:并发版本用 tokio::join!,注意三次 sleep 要同时被轮询。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::time::{Duration, Instant};
async fn request(name: &str) -> String {
// 100ms 表示「等网络」,期间任务挂起,不占 CPU
tokio::time::sleep(Duration::from_millis(100)).await;
format!("{name}: 200 OK")
}
async fn serial() -> u64 {
let start = Instant::now();
let a = request("A").await;
let b = request("B").await;
let c = request("C").await;
println!("{a} / {b} / {c}");
start.elapsed().as_millis() as u64
}
async fn concurrent() -> u64 {
let start = Instant::now();
// join! 让三个 future 在同一个任务里被并发轮询:
// 三个 sleep 同时计时,所以总耗时 ≈ 单个耗时
let (a, b, c) = tokio::join!(request("A"), request("B"), request("C"));
println!("{a} / {b} / {c}");
start.elapsed().as_millis() as u64
}
#[tokio::main]
async fn main() {
println!("串行耗时: {}ms", serial().await);
println!("并发耗时: {}ms", concurrent().await);
}输出:
A: 200 OK / B: 200 OK / C: 200 OK两遍,串行耗时: 300ms(±10ms)、并发耗时: 100ms(±10ms)。
要点解析:
.await是等待点,不是「阻塞」:request("A").await让出执行权后,request("B")可以先跑到自己的sleep,于是三个计时器同时开始。- 串行写法里
.await一个接一个,第二个请求的sleep在第一个完成前根本不会被创建。 - 这是 async 最核心的价值:用一条线程把等待时间重叠起来。
- 想强调差异可以把 100ms 改成 1000ms;耗时应该是 3000ms vs 1000ms。
练习 4:用 tokio::spawn 并发抓取
难度:★★☆
要求:用 spawn 并发「抓取」5 个「URL」(用 sleep 模拟不同耗时),要求: 每个任务返回 (String, u64)(URL 与耗时),全部完成后打印总耗时与每个结果。 提示:JoinHandle 自己也是 future;Vec<JoinHandle<_>> 可以在循环里逐个 await。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::time::{Duration, Instant};
async fn fetch(url: &'static str, ms: u64) -> (String, u64) {
tokio::time::sleep(Duration::from_millis(ms)).await;
(format!("body of {url}"), ms)
}
#[tokio::main]
async fn main() {
let jobs = [
("https://a.example", 120u64),
("https://b.example", 40),
("https://c.example", 200),
("https://d.example", 80),
("https://e.example", 160),
];
let start = Instant::now();
// 每个 URL 一个任务:任务可以跨线程真并行,因此要求 move + 'static
let handles: Vec<_> = jobs
.iter()
.map(|&(url, ms)| tokio::spawn(fetch(url, ms)))
.collect();
for handle in handles {
let (body, ms) = handle.await.expect("task panicked");
println!("{body}({ms}ms)");
}
println!("总耗时 {}ms(最长任务 200ms)", start.elapsed().as_millis());
}输出:5 行
body of ...(Nms),最后总耗时 200ms(最长任务 200ms)(±10ms)。
要点解析:
tokio::spawn(fetch(url, ms))直接把返回的 future 交给运行时;jobs是&'static str数组,所以即使不加move也满足'static。JoinHandle<T>本身是 future,循环里.await是按顺序收集结果, 但任务早已并发跑完,收集顺序不影响总耗时。- 用
join!也能达到同样的总耗时,区别是spawn让每个请求成为独立任务 (可被单独取消、panic 被隔离到JoinError),代价是要求'static + Send。 - 若耗时之和明显大于 200ms,说明某个任务实际是串行的——常见原因是在循环里
spawn之后立刻.await,那样就退化成串行了。
练习 5:用 timeout 做超时控制
难度:★★☆
要求:给一个「可能很慢的」操作加 200ms 超时,超时打印 timeout,否则打印结果。 再补一个版本:超时后不丢弃结果,而是让后台任务继续跑完并在之后取回(提示:spawn + timeout)。 提示:tokio::time::timeout 返回 Result;超时时内层 future 被 drop。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::time::Duration;
use tokio::time::{timeout, Elapsed};
async fn slow(ms: u64) -> String {
tokio::time::sleep(Duration::from_millis(ms)).await;
format!("done in {ms}ms")
}
#[tokio::main]
async fn main() {
// 版本 1:真超时——超时后 future 被 drop,结果永久丢失
match timeout(Duration::from_millis(200), slow(500)).await {
Ok(v) => println!("版本1 完成: {v}"),
Err(Elapsed { .. }) => println!("版本1 timeout(slow 已被 drop)"),
}
// 版本 2:不丢结果——先把任务 spawn 出去,再对 JoinHandle 加超时
let handle = tokio::spawn(slow(500));
match timeout(Duration::from_millis(200), &mut handle).await {
Ok(Ok(v)) => println!("版本2 及时完成: {v}"),
Ok(Err(join_err)) => println!("版本2 任务失败: {join_err}"),
Err(_) => {
println!("版本2 超时,但后台任务还在跑");
// 注意:这里 handle 是 &mut,timeout 只取消"等待",不取消任务
let v = handle.await.expect("task panicked");
println!("版本2 稍后取回: {v}");
}
}
}输出:
版本1 timeout(slow 已被 drop),然后版本2 超时,但后台任务还在跑、 约 300ms 后版本2 稍后取回: done in 500ms。
要点解析:
timeout是「给 future 加时限」,超时后内层 future 被 drop——这就是取消。 已经从流里读走的数据、已经发出的请求都不会回退(取消安全,见「select!的语义与取消安全」)。- 版本 2 的关键是把「任务」和「等待」分开:
spawn出的任务的生命周期独立于handle,所以超时只取消handle.await这一层等待。 - 想取消后台任务要显式
handle.abort(),且被 abort 时任务可能在任意挂起点被 drop。 timeout(Duration::from_millis(200), &mut handle)能这样写是因为&mut JoinHandle也实现了Future(JoinHandle: Unpin),超时后还能继续用同一个 handle。
练习 6:用 Semaphore 限制并发数
难度:★★☆
要求:用 tokio::sync::Semaphore 限制并发数为 3,启动 10 个任务,每个任务打印 「开始/结束 + 自己的编号」,观察任意时刻最多 3 个任务在跑。 提示:Arc<Semaphore> + acquire_owned(),permit 要活到任务结束。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tokio::time::sleep;
async fn job(id: u32) {
println!("任务 {id} 开始");
sleep(Duration::from_millis(100)).await;
println!("任务 {id} 结束");
}
#[tokio::main]
async fn main() {
let start = Instant::now();
let sem = Arc::new(Semaphore::new(3)); // 最多 3 个并发
let mut handles = Vec::new();
for id in 0..10 {
let sem = Arc::clone(&sem);
handles.push(tokio::spawn(async move {
// acquire_owned 需要 Arc<Semaphore>,permit 因此是 'static 的,
// 可以安全地活在整个任务里
let permit = sem.acquire_owned().await.expect("semaphore closed");
job(id).await;
drop(permit); // 显式释放;其实函数结束也会 drop
}));
}
for h in handles {
h.await.expect("task panicked");
}
// 10 个任务、每个 100ms、每次最多 3 个 → 4 批 → 约 400ms
println!("总耗时 {}ms", start.elapsed().as_millis());
}输出:10 行「开始/结束」交错,任意时刻最多 3 对;
总耗时 400ms(±20ms)。
要点解析:
Semaphore::acquire_owned返回OwnedSemaphorePermit,它拥有一个Arc<Semaphore>的克隆,因此不需要生命周期参数,能直接跨线程移动进'static任务。 这也是为什么要把信号量放进Arc。- 不写
drop(permit)也可以:permit 在任务结束时随作用域自动释放。 - 一定要在拿到 permit 之后再 spawn 之外做重活;如果先做重活再等 permit, 限流就形同虚设。
- 替代方案:
futures::stream::iter(0..10).map(job).buffer_unordered(3).collect::<Vec<_>>().await, 一行搞定同样的事,见练习 11。
练习 7:实现 TCP 回声服务器
难度:★★★
要求:实现一个完整的 TCP 回声服务器(监听 127.0.0.1:8080,每个连接一个任务), 再写一个客户端连上去发 3 行并读回 3 行。要求给出目录结构与 Cargo.toml。 提示:TcpListener::accept + tokio::spawn;按行处理用 BufReader::lines。
参考答案(先自己写再看)
参考答案(先自己写再看)
text
echo/
├── Cargo.toml
└── src/
├── server.rs
└── client.rstoml
# Cargo.toml
[package]
name = "echo"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "server"
path = "src/server.rs"
[[bin]]
name = "client"
path = "src/client.rs"
[dependencies]
tokio = { version = "1", features = ["full"] }rust
// src/server.rs:每个连接一个任务,互不阻塞
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("listening on {}", listener.local_addr()?);
loop {
let (stream, peer) = listener.accept().await?;
println!("connected: {peer}");
tokio::spawn(async move {
if let Err(e) = echo(stream).await {
eprintln!("{peer} error: {e}");
}
println!("disconnected: {peer}");
});
}
}
async fn echo(stream: TcpStream) -> std::io::Result<()> {
let (read_half, mut write_half) = stream.into_split();
let mut lines = BufReader::new(read_half).lines();
// next_line 返回 None 表示对端关闭(EOF)
while let Some(line) = lines.next_line().await? {
write_half.write_all(line.as_bytes()).await?;
write_half.write_all(b"\n").await?;
write_half.flush().await?;
}
Ok(())
}rust
// src/client.rs:发 3 行,读回 3 行
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let stream = TcpStream::connect("127.0.0.1:8080").await?;
let (read_half, mut write_half) = stream.into_split();
for msg in ["ping", "rust", "bye"] {
write_half.write_all(msg.as_bytes()).await?;
write_half.write_all(b"\n").await?;
}
write_half.flush().await?;
// 关掉写半边,服务器才会收到 EOF 并退出循环
drop(write_half);
let mut lines = BufReader::new(read_half).lines();
while let Some(line) = lines.next_line().await? {
println!("echo: {line}");
}
Ok(())
}运行:终端 1
cargo run --bin server,终端 2cargo run --bin client。 客户端输出echo: ping、echo: rust、echo: bye;服务端打印连接/断开日志。
要点解析:
TcpListener::accept是异步的:没有新连接时任务挂起,线程可以去跑别的任务。 所以一个loop+spawn就撑住了上万个连接。into_split()把TcpStream拆成可独立移动的读写两半(内部是Arc), 这样「读任务」和「写任务」可以分开;也可以用tokio::io::split处理任意AsyncRead + AsyncWrite。- 忘记
flush常见于「数据卡在缓冲区」的 bug;write_all只保证写进缓冲。 - 客户端
drop(write_half)会发送 FIN,服务器next_line才会返回None。 不 drop 就会出现「双方都在等对方说话」的死锁。 - 出错时不要让整个服务器退出:把
handle的错误打印出来即可, 这正是tokio::spawn与「一连接一线程 + 全局 panic」相比更健壮的地方。
练习 8:tokio::spawn 与 Send 边界
难度:★★☆
要求:下面两个函数都能通过编译,但只有一个能安全地被 tokio::spawn。指出是哪一个, 说明为什么,并给出两种修法。
rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as AsyncMutex;
async fn a(lock: &Mutex<u32>) -> u32 {
let g = lock.lock().unwrap();
tokio::task::yield_now().await;
*g
}
async fn b(lock: &AsyncMutex<u32>) -> u32 {
let g = lock.lock().await;
tokio::task::yield_now().await;
*g
}
#[tokio::main]
async fn main() {
let m1 = Arc::new(Mutex::new(1));
let m2 = Arc::new(AsyncMutex::new(2));
// tokio::spawn(a(&m1));
// tokio::spawn(b(&m2));
}提示:看跨 .await 存活的类型是否 Send,以及 &Mutex<u32> 的 'static 问题。
参考答案(先自己写再看)
参考答案(先自己写再看)
两个都不能,而且失败原因不同:
text
error[E0277]: `std::sync::MutexGuard<'_, u32>` cannot be sent between threads safely
|
= help: within `impl Future<Output = u32>`, the trait `Send` is not implemented
for `std::sync::MutexGuard<'_, u32>`
note: future is not `Send` as this value is used across an await
| let g = lock.lock().unwrap();
| - has type `std::sync::MutexGuard<'_, u32>` which is not `Send`
| tokio::task::yield_now().await;
| ^^^^^ await occurs here, with `g` maybe used later
error[E0521]: borrowed data escapes outside of function
|
| tokio::spawn(b(&m2));
| ^^^^^^^ `m2` is borrowed here, but the function requires
| `'static` dataa:std::sync::MutexGuard不是Send(它绑定在特定线程上), 跨.await存活就让整个 future 变成!Send。而且lock: &Mutex<u32>是借用, 也不满足'static。b:tokio::sync::MutexGuard是Send,Send这一关能过, 但仍然因为&AsyncMutex<u32>是借用而不满足'static。
两种修法:
rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as AsyncMutex;
// 修法 1:缩小临界区,让守卫在任何 await 之前 drop —— 最佳实践
async fn a_fixed(lock: Arc<Mutex<u32>>) -> u32 {
let snapshot = {
let mut g = lock.lock().unwrap();
*g += 1;
*g
}; // 守卫在此 drop
tokio::task::yield_now().await; // 现在跨 await 的只有一个 u32
snapshot
}
// 修法 2:确实需要跨 await 持锁时,用 tokio 的锁 + Arc(满足 'static)
async fn b_fixed(lock: Arc<AsyncMutex<u32>>) -> u32 {
let mut g = lock.lock().await;
tokio::task::yield_now().await;
*g += 1;
*g
}
#[tokio::main]
async fn main() {
let m1 = Arc::new(Mutex::new(1));
let m2 = Arc::new(AsyncMutex::new(2));
let h1 = tokio::spawn(a_fixed(Arc::clone(&m1)));
let h2 = tokio::spawn(b_fixed(Arc::clone(&m2)));
println!("{} {}", h1.await.expect("join failed"), h2.await.expect("join failed"));
}输出:
2 3。
要点解析:
- 两条独立的规则要同时满足:
Send(跨线程)与'static(生命周期)。 只修一个仍会报错,这一点最容易漏。 - 为什么
std::sync::MutexGuard是!Send?因为它依赖「解锁发生在同一线程」, 而 Windows 的SRWLOCK等实现有线程亲和性;Send掉它可能导致未定义行为。 tokio::sync::MutexGuard是Send,但持锁跨 await 有死锁风险: 临界区里 await 的东西如果又需要同一把锁(直接或间接),任务会永久挂起。 优先级:缩小临界区 >tokio::sync::Mutex。- 若临界区不可能 await,直接继续用
std::sync::Mutex(更快),不要无脑换成 tokio 版本。
练习 9:递归 async fn 与 Box::pin
难度:★★☆
要求:写一个 async fn 递归函数 sum_to(n: u64) -> u64(返回 1+2+...+n), 要求它能在 tokio 里被 await。先用普通递归写一次并记录编译错误,再用正确写法实现。 提示:error[E0733];修法是引入间接(Box::pin)。
参考答案(先自己写再看)
参考答案(先自己写再看)
错误写法与真实诊断:
text
error[E0733]: recursion in an async fn requires boxing
--> src/main.rs:1:1
|
1 | async fn sum_to(n: u64) -> u64 {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
5 | n + sum_to(n - 1).await
| ------------------ recursive call here
|
= note: a recursive `async fn` call must introduce indirection such as
`Box::pin` to avoid an infinitely sized future正确写法(纯 std,不需要任何运行时就能通过类型检查):
rust
// 依赖:无
use std::future::Future;
use std::pin::Pin;
// 返回装箱的 future:大小固定为一个指针,打破无限递归的类型
fn sum_to(n: u64) -> Pin<Box<dyn Future<Output = u64> + Send>> {
Box::pin(async move {
if n == 0 {
0
} else {
n + sum_to(n - 1).await
}
})
}
fn main() {
// 这里只交出 future、不驱动它;真正 await 需要一个 executor(如
// #[tokio::main] 或 futures::executor::block_on)
fn assert_future<F: Future<Output = u64>>(_f: F) {}
assert_future(sum_to(100));
println!("future 构造完成,等待被 executor 驱动");
}输出:
future 构造完成,等待被 executor 驱动。放进#[tokio::main] async fn main() { println!("{}", sum_to(100).await); }会得到5050。
要点解析:
- 状态机的大小必须在编译期确定,而「状态机里嵌一个同类型状态机」意味着无穷大, 编译器用
E0733直接拦住。 Box::pin同时解决两件事:把值放到堆上(大小变成指针)、把地址固定(Pin, 因为 async 状态机是!Unpin)。- 工程上递归 async 要小心两件事:堆分配开销(每层一次)和 栈/堆深度(每层的
Poll都会往上展开,深递归可能栈溢出)。 目录遍历这类场景更适合显式Vec栈或ignore/walkdir的spawn_blocking版本。 - 如果只在内部分用,用
futures::future::BoxFuture<'static, u64>别名更易读, 并且.boxed()比手写Box::pin更简洁。
练习 10:排查 Future 不执行的原因
难度:★★☆
要求:下面代码「不会打印任何东西」,找出原因并修复:
rust
// 依赖:tokio = { version = "1", features = ["full"] }
async fn log(msg: &str) {
println!("{msg}");
}
#[tokio::main]
async fn main() {
log("hello");
let f = log("world");
drop(f);
}提示:Future 的 #[must_use] 只覆盖了一条路径。
参考答案(先自己写再看)
参考答案(先自己写再看)
原因:log("hello") 和 let f = log("world"); 都只构造了 future,没有 await, 所以两行 println! 都不会执行。let f 之后 drop(f) 让编译器认为变量被使用了, 因此没有警告,bug 完全静默。
修复:
rust
// 依赖:tokio = { version = "1", features = ["full"] }
async fn log(msg: &str) {
println!("{msg}");
}
#[tokio::main]
async fn main() {
log("hello").await; // 必须 await
let f = log("world");
f.await; // 或者显式 await 变量
// 想并发就用 join!,而不是"构造了再丢掉"
tokio::join!(log("a"), log("b"));
}输出:
hello、world、a、b(最后两行的顺序由调度决定)。
要点解析:
#[must_use]只作用在「表达式语句产生的值被立即丢弃」这一种情况; 赋值给变量后再 drop 就绕过了检查。- Clippy 的
let_underscore_future(let _ = async_fn();)能抓住部分漏写, 但仍无法覆盖「赋值给变量」的写法——所以「函数名叫xxx,返回值没被 await」 这件事只能靠 code review 与习惯。 - 记忆法:在 Rust 里,看到 async 函数调用就要想到「这里必须有一个
.await或显式交给 executor(spawn/join!/select!)」;spawn是例外, 它是「提交即开始」,不需要.await也能运行(但要保留JoinHandle才能拿结果)。
练习 11:并发上限与失败重试的组合
难度:★★★
要求:给定一个「一批 URL 需要抓取、并发上限 4、失败要重试 1 次」的场景, 用 futures::stream 的 buffer_unordered(或 tokio::sync::Semaphore)实现, 统计成功数与失败数。要求错误类型统一、代码可运行(用 sleep 模拟抓取)。 提示:buffer_unordered(n) 内部就是有界并发;重试用一个 for 循环包住单次尝试。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
// 依赖:tokio = { version = "1", features = ["full"] }, futures = "0.3"
use futures::stream::{self, StreamExt};
use std::time::{Duration, Instant};
#[derive(Debug)]
struct FetchError(String);
impl std::fmt::Display for FetchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for FetchError {}
/// 单次尝试:用 sleep 模拟网络;url 里带 "bad" 就失败
async fn try_once(url: &str) -> Result<String, FetchError> {
let ms = 30 + (url.len() as u64 % 5) * 10;
tokio::time::sleep(Duration::from_millis(ms)).await;
if url.contains("bad") {
Err(FetchError(format!("{url} -> 500")))
} else {
Ok(format!("{url} -> 200 ({ms}ms)"))
}
}
/// 带 1 次重试的抓取
async fn fetch_with_retry(url: &str) -> Result<String, FetchError> {
match try_once(url).await {
Ok(body) => Ok(body),
Err(first) => {
// 重试前稍微退避;真实项目用指数退避
tokio::time::sleep(Duration::from_millis(20)).await;
try_once(url).await.map_err(|_| first)
}
}
}
#[tokio::main]
async fn main() {
let urls: Vec<String> = (1..=12)
.map(|i| {
if i % 5 == 0 {
format!("https://bad{i}.example/api")
} else {
format!("https://host{i}.example/api")
}
})
.collect();
let start = Instant::now();
// 用 iter() 借出 &String,再用 as_str() 得到 &str:和 fetch_with_retry 的签名一致
let borrows = urls.iter().map(String::as_str);
// buffer_unordered(4):最多同时 4 个 future 在飞,谁先好谁先出
let results: Vec<Result<String, FetchError>> = stream::iter(borrows)
.map(fetch_with_retry)
.buffer_unordered(4)
.collect()
.await;
let mut ok = 0usize;
let mut failed = 0usize;
for r in results {
match r {
Ok(body) => {
ok += 1;
println!("OK {body}");
}
Err(e) => {
failed += 1;
println!("FAIL {e}");
}
}
}
println!("成功 {ok},失败 {failed},总耗时 {:?}", start.elapsed());
}输出:按完成顺序打印
OK .../FAIL ...(bad5、bad10会失败两次尝试后报错), 最后是成功 10,失败 2,总耗时 ...ms(约 200ms 上下,取决于耗时分布)。
要点解析:
buffer_unordered(n)= 「最多 n 个并发 + 完成即产出」,等价于一个内建的Semaphore, 但不需要手动管理 permit。需要结果保序时用buffered(n)。- 最常见的编译错误来源是元素类型与函数签名不匹配:
fetch_with_retry收&str, 而urls.iter()产出&String,所以要先.map(String::as_str)(或者把参数 改成url: &String/url: String)。这里的borrows借用urls, 而buffer_unordered(...).collect().await在同一个main里就结束,借用合法。 - 错误类型统一成
FetchError,所以buffer_unordered的元素类型是Result<String, FetchError>,无需Box<dyn Error>。 - 重试要用退避,否则失败的服务会被立刻再次打满;并且要注意整体重试次数上限, 避免重试风暴。真实项目里
tower::retry+tower::limit已经把这套逻辑做好了。 - 若任务需要独立取消或 panic 隔离,改用
JoinSet+Semaphore。