异步 Rust:从 Future 到生产实践
作者介绍
- Microsoft SCHIE(芯片与云硬件基础设施工程)团队首席固件架构师
- 行业资深人士,擅长安全、系统编程(固件、操作系统、管理程序)、CPU 及平台架构以及 C++ 系统
- 2017 年开始在 AWS EC2 使用 Rust 编程,并从此爱上了这门语言
这是一份关于 Rust 异步编程的深度指南。与大多数从 tokio::main 开始并对内部机制含糊其辞的异步教程不同,本指南从基本原理开始构建理解 —— 包括 Future trait、轮询(polling)以及状态机 —— 然后逐步深入到现实世界的模式、运行时选择和生产环境中的陷阱。
目标读者
- 能够编写同步 Rust 但觉得异步令人困惑的 Rust 开发者
- 来自 C#、Go、Python 或 JavaScript,了解
async/await但不熟悉 Rust 模型的开发者 - 任何曾被
Future is not Send、Pin<Box<dyn Future>>困扰,或疑惑“为什么我的程序挂起了?”的人
预备知识
你应该熟悉以下内容:
- 所有权、借用和生命周期
- Trait 和泛型(包括
impl Trait) - 使用
Result<T, E>和?运算符 - 基础多线程(
std::thread::spawn、Arc、Mutex)
不需要先前的异步 Rust 经验。
如何阅读本书
初次阅读请按顺序阅读。 第一至第三部分环环相扣。每章都有:
| 符号 | 含义 |
|---|---|
| 🟢 | 初学者 —— 基础概念 |
| 🟡 | 中级 —— 需要先阅读之前的章节 |
| 🔴 | 高级 —— 深度内部机制或生产模式 |
每章包含:
- 顶部的 “你将学到” 区块
- 为视觉学习者准备的 Mermaid 图表
- 带有隐藏答案的 行内练习
- 总结核心思想的 关键要诀
- 相关章节的 交叉引用
进度指南
| 章节 | 主题 | 建议时间 | 检查点 |
|---|---|---|---|
| 1–5 | 异步如何工作 | 6–8 小时 | 你可以解释 Future、Poll、Pin 以及为什么 Rust 没有内置运行时 |
| 6–10 | 生态系统 | 6–8 小时 | 你可以手动构建 Future,选择运行时并使用 tokio 的 API |
| 11–13 | 生产级异步 | 6–8 小时 | 你可以使用流(streams)、正确的错误处理和优雅停机编写生产级异步代码 |
| 案例实践 | 聊天服务器 | 4–6 小时 | 你已经构建了一个集成所有概念的真实异步应用 |
总预估时间:22–30 小时
完成练习
每个内容章节都有行内练习。案例实践(第 16 章)将所有内容整合到一个项目中。为了获得最佳学习效果:
- 在展开答案前先尝试练习 —— 挣扎的过程正是学习发生的时候
- 动手输入代码,不要复制粘贴 —— 肌肉记忆对 Rust 的语法很重要
- 运行每个示例 ——
cargo new async-exercises并边做边测试
目录
第一部分:异步如何工作
- 1. 为什么异步在 Rust 中不同 🟢 —— 根本区别:Rust 没有内置运行时
- 2. Future Trait 🟡 ——
poll()、Waker以及使一切运转的契约 - 3. Poll 如何工作 🟡 —— 轮询状态机和最小执行器
- 4. Pin 与 Unpin 🔴 —— 为什么自引用结构体需要固定(Pinning)
- 5. 揭秘状态机 🟢 —— 编译器从
async fn实际生成了什么
第二部分:生态系统
- 6. 手动构建 Future 🟡 —— 从零开始实现 TimerFuture、Join、Select
- 7. 执行器与运行时 🟡 —— tokio、smol、async-std、embassy —— 如何选择
- 8. Tokio 深度探索 🟡 —— 运行时类型、spawn、通道(channels)、同步原语
- 9. 当 Tokio 不适用时 🟡 —— LocalSet、FuturesUnordered、运行时无关设计
- 10. 异步 Trait 🟡 —— RPITIT、动态分发、trait_variant、异步闭包
第三部分:生产级异步
- 11. 流与 AsyncIterator 🟡 —— 异步迭代、AsyncRead/Write、流组合器
- 12. 常见陷阱 🔴 —— 9 个生产环境中的漏洞以及如何避免它们
- 13. 生产模式 🔴 —— 优雅停机、背压(backpressure)、Tower 中间件
- 14. 异步是一种优化,而非架构 🔴 —— 同步核心 / 异步外壳,以及函数着色的成本
附录
- 总结与参考卡片 —— 快速查询表和决策树
- 案例实践项目:异步聊天服务器 —— 构建一个完整的异步应用
1. 为什么异步在 Rust 中不同 🟢
你将学到:
- 为什么 Rust 没有内置异步运行时(以及这对你意味着什么)
- 三大关键特性:惰性执行、无内置运行时、零成本抽象
- 何时异步是正确的工具(以及何时它反而更慢)
- Rust 模型与 C#、Go、Python 及 JavaScript 的对比
根本区别
大多数带有 async/await 的语言都隐藏了其后的运作机制。C# 有 CLR 线程池;JavaScript 有事件循环(event loop);Go 在运行时中内置了 goroutine 和调度器;Python 则有 asyncio。
而 Rust 什么都没有。
没有内置运行时,没有线程池,也没有事件循环。async 关键字是一种零成本编译策略 —— 它将你的函数转换为实现 Future trait 的状态机。必须由通过其他人(称为“执行器”,executor)来驱动该状态机向前运行。
Rust 异步的三大关键特性
graph LR
subgraph "C# / JS / Go"
EAGER["及早执行 (Eager)<br/>任务立即开始"]
BUILTIN["内置运行时<br/>自带线程池"]
GC["GC 管理<br/>无需担心生命周期"]
end
subgraph "Rust (及 Python*)"
LAZY["惰性执行 (Lazy)<br/>不轮询/等待则无事发生"]
BYOB["自备运行时 (BYOB)<br/>由你选择执行器"]
OWNED["所有权生效<br/>生命周期、Send、Sync 很关键"]
end
EAGER -. "相反" .-> LAZY
BUILTIN -. "相反" .-> BYOB
GC -. "相反" .-> OWNED
style LAZY fill:#e8f5e8,color:#000
style BYOB fill:#e8f5e8,color:#000
style OWNED fill:#e8f5e8,color:#000
style EAGER fill:#e3f2fd,color:#000
style BUILTIN fill:#e3f2fd,color:#000
style GC fill:#e3f2fd,color:#000
* Python 的协程(coroutine)像 Rust 的 future 一样是惰性的 —— 除非被 await 或调度,否则它们不会执行。然而,Python 仍使用 GC 且没有所有权/生命周期的概念。
无内置运行时
// 这段代码可以编译,但什么都不会做:
async fn fetch_data() -> String {
"hello".to_string()
}
fn main() {
let future = fetch_data(); // 创建了 Future,但并未执行
// future 只是一个处于栈上的结构体
// 没有输出,没有副作用,什么都没发生
drop(future); // 被静默丢弃 —— 任务从未启动
}
对比 C# 中 Task 的及早执行:
// C# —— 这会立即开始执行:
async Task<string> FetchData() => "hello";
var task = FetchData(); // 已经开始运行了!
var result = await task; // 仅仅是等待它完成
惰性 Future vs 及早 Task
这是最重要的一次思维转变:
| C# / JavaScript | Python | Go | Rust | |
|---|---|---|---|---|
| 创建时 | Task 立即开始执行 | 协程是惰性的 —— 返回一个对象,直到被 await 或调度才运行 | Goroutine 立即开始运行 | Future 在被轮询(poll)前无事发生 |
| 丢弃时 | 分离的任务继续运行 | 未被 await 的协程将被 GC 回收(带有警告) | Goroutine 持续运行直到返回 | 丢弃 Future 即意味着取消任务(立即生效) |
| 运行时 | 内置于语言/虚拟机 | asyncio 事件循环(必须显式启动) | 内置于二进制文件(M:N 调度器) | 你自己选(tokio, smol 等) |
| 调度 | 自动(线程池) | 事件循环 + await 或 create_task() | 自动(GMP 调度器) | 显式(spawn, block_on) |
| 取消 | CancellationToken (协作式) | Task.cancel() (协作式,抛出 CancelledError) | context.Context (协作式) | 丢弃 future (立即生效) |
// 要真正运行一个 future,你需要一个执行器:
#[tokio::main]
async fn main() {
let result = fetch_data().await; // 现在它执行了
println!("{result}");
}
何时使用异步(以及何时不用)
graph TD
START["什么类型的工作?"]
IO["I/O 密集型?<br/>(网络、文件、数据库)"]
CPU["CPU 密集型?<br/>(计算、解析)"]
MANY["大量并发连接?<br/>(100+)"]
FEW["少量并发任务?<br/>(<10)"]
USE_ASYNC["✅ 使用 async/await"]
USE_THREADS["✅ 使用 std::thread 或 rayon"]
USE_SPAWN_BLOCKING["✅ 使用 spawn_blocking()"]
MAYBE_SYNC["考虑同步代码<br/>(更简单,开销更小)"]
START -->|网络、文件、数据库| IO
START -->|计算任务| CPU
IO -->|是的,很多| MANY
IO -->|只有几个| FEW
MANY --> USE_ASYNC
FEW --> MAYBE_SYNC
CPU -->|并行处理| USE_THREADS
CPU -->|在异步上下文中| USE_SPAWN_BLOCKING
style USE_ASYNC fill:#c8e6c9,color:#000
style USE_THREADS fill:#c8e6c9,color:#000
style USE_SPAWN_BLOCKING fill:#c8e6c9,color:#000
style MAYBE_SYNC fill:#fff3e0,color:#000
经验法则:异步适用于 I/O 并发(在等待时同时做很多事),而非 CPU 并行(让一件事跑得更快)。如果你有 10,000 个网络连接,异步大放异彩;如果你在处理海量数字计算,请使用 rayon 或操作系统线程。
为什么异步有时反而更慢
异步并非毫无代价。对于低并发负载,同步代码的性能可能优于异步代码:
| 成本 | 原因 |
|---|---|
| 状态机开销 | 每个 .await 都会增加一个 enum 变体;深度嵌套的 future 会产生庞大且复杂的状态机 |
| 动态分发 | Box<dyn Future> 增加了间接寻址并破坏了内联(inlining) |
| 上下文切换 | 协作式调度仍有开销 —— 执行器必须管理任务队列、waker 和 I/O 注册 |
| 编译时间 | 异步代码会生成更复杂的类型,从而减慢编译速度 |
| 调试难度 | 穿过状态机的堆栈追踪更难阅读(见第 12 章) |
基准测试建议:如果并发 I/O 操作少于约 10 个,在决定使用异步前请先进行性能测试。在现代 Linux 上,每连接一个简单的 std::thread::spawn 可以轻松扩展到数百个线程。
练习:你会在什么时候使用异步?
🏋️ 练习(点击展开)
针对以下每个场景,决定使用异步是否合适,并说明原因:
- 一个处理 10,000 个并发 WebSocket 连接的 Web 服务器
- 一个压缩单个大文件的 CLI 工具
- 一个查询 5 个不同数据库并合并结果的服务
- 一个以 60 FPS 运行物理模拟的游戏引擎
🔑 答案
- 异步 —— I/O 密集型且具有极高并发。每个连接大部分时间都在等待数据。线程将需要 10K 个栈空间。
- 同步/线程 —— CPU 密集型,单一任务。异步只会增加开销而无收益。使用
rayon进行并行压缩。 - 异步 —— 五个并发的 I/O 等待。
tokio::join!可以同时运行这五个查询。 - 同步/线程 —— CPU 密集型,对延迟敏感。异步的协作式调度可能会引入帧抖动(jitter)。
关键要诀 —— 为什么异步在 Rust 中不同
- Rust 的 future 是惰性的 —— 除非被执行器轮询,否则它们什么都不做
- 没有内置运行时 —— 你可以根据需求选择(或构建)自己的运行时
- 异步是一种零成本编译策略,最终生成状态机
- 异步在 I/O 密集型并发 中表现卓越;对于 CPU 密集型工作,请使用线程或 rayon
另请参阅: 第 2 章 —— Future Trait 了解使这一切运转的核心 trait,第 7 章 —— 执行器与运行时 了解如何选择运行时
2. Future Trait 🟡
你将学到:
Futuretrait 详解:Output、poll()、Context、Waker- Waker 如何告知执行器“再次轮询我”
- 关键契约:永远不调用
wake()= 程序将静默挂起- 手动实现一个真实的 Future (
Delay)
Future 剖析
异步 Rust 中的所有内容最终都实现了这个 trait:
#![allow(unused)]
fn main() {
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T), // Future 已完成,返回值为 T
Pending, // Future 尚未就绪 —— 稍后再来找我
}
}
就这么简单。一个 Future 就是任何可以被 轮询(poll)—— 即被询问“你做完了吗?” —— 并响应“做完了,这是结果”或“还没呢,等我准备好了会叫醒你”的对象。
Output, poll(), Context, Waker
sequenceDiagram
participant E as 执行器 (Executor)
participant F as Future
participant R as 资源 (I/O)
E->>F: poll(cx)
F->>R: 检查:数据准备好了吗?
R-->>F: 还没
F->>R: 从 cx 中注册 waker
F-->>E: Poll::Pending
Note over R: ... 时间流逝,数据到达 ...
R->>E: waker.wake() —— “我准备好了!”
E->>F: poll(cx) —— 再试一次
F->>R: 检查:数据准备好了吗?
R-->>F: 准备好了!这是数据
F-->>E: Poll::Ready(数据)
让我们拆解每个部分:
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
// 一个立即返回 42 的 future
struct Ready42;
impl Future for Ready42 {
type Output = i32; // future 最终产出的类型
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<i32> {
Poll::Ready(42) // 总是就绪 —— 无需等待
}
}
}
组件解析:
Output—— future 完成时产生的值的类型。poll()—— 由执行器调用以检查进度;返回Ready(value)或Pending。Pin<&mut Self>—— 确保 future 不会在内存中被移动(我们将在第 4 章探讨原因)。Context—— 携带Waker,以便 future 在准备好取得进展时能信号通知执行器。
Waker 契约
Waker 是回调机制。当一个 future 返回 Pending 时,它 必须 安排在稍后调用 waker.wake() —— 否则执行器将永远不会再次轮询它,程序就会挂起。
#![allow(unused)]
fn main() {
use std::task::{Context, Poll, Waker};
use std::pin::Pin;
use std::future::Future;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
/// 一个在延迟后完成的 future(演示用实现)
struct Delay {
completed: Arc<Mutex<bool>>,
waker_stored: Arc<Mutex<Option<Waker>>>,
duration: Duration,
started: bool,
}
impl Delay {
fn new(duration: Duration) -> Self {
Delay {
completed: Arc::new(Mutex::new(false)),
waker_stored: Arc::new(Mutex::new(None)),
duration,
started: false,
}
}
}
impl Future for Delay {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
// 检查是否已经完成
if *self.completed.lock().unwrap() {
return Poll::Ready(());
}
// 存储 waker,以便后台线程能够唤醒我们
*self.waker_stored.lock().unwrap() = Some(cx.waker().clone());
// 在第一次轮询时启动后台计时器
if !self.started {
self.started = true;
let completed = Arc::clone(&self.completed);
let waker = Arc::clone(&self.waker_stored);
let duration = self.duration;
thread::spawn(move || {
thread::sleep(duration);
*completed.lock().unwrap() = true;
// 关键点:唤醒执行器以便它再次轮询我们
if let Some(w) = waker.lock().unwrap().take() {
w.wake(); // “嘿,执行器,我准备好了 —— 再次轮询我吧!”
}
});
}
Poll::Pending // 还没做完
}
}
}
核心见解:在 C# 中,TaskScheduler 会自动处理唤醒。而在 Rust 中,你(或你使用的 I/O 库)负责调用
waker.wake()。如果忘了这一步,你的程序就会静默挂起。
练习:实现一个倒计时 Future (CountdownFuture)
🏋️ 练习(点击展开)
挑战:实现一个 CountdownFuture,从 N 开始倒数到 0,并在每次被轮询时打印当前数值。当数到 0 时,它完成并返回 Ready("Liftoff!")。
提示:Future 需要存储当前计数值并在每次轮询时递减。记住一定要重新注册 waker!
🔑 答案
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct CountdownFuture {
count: u32,
}
impl CountdownFuture {
fn new(start: u32) -> Self {
CountdownFuture { count: start }
}
}
impl Future for CountdownFuture {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.count == 0 {
println!("Liftoff!");
Poll::Ready("Liftoff!")
} else {
println!("{}...", self.count);
self.count -= 1;
cx.waker().wake_by_ref(); // 立即安排再次轮询
Poll::Pending
}
}
}
}
关键总结:这个 future 倒数一次就会被轮询一次。每当它返回 Pending 时,它会立即唤醒自己以便再次被轮询。在生产环境中,你会使用计时器而不是这种繁忙轮询(busy-polling)。
关键要诀 —— Future Trait
Future::poll()返回Poll::Ready(value)或Poll::Pending- Future 在返回
Pending前必须注册一个Waker—— 执行器通过它知道何时重新轮询Pin<&mut Self>保证 future 不会在内存中被移动(自引用状态机所需 —— 见第 4 章)- 异步 Rust 中的一切 ——
async fn、.await、组合器 —— 都建立在这一 trait 之上
另请参阅: 第 3 章 —— Poll 如何工作 了解执行器循环,第 6 章 —— 手动构建 Future 了解更复杂的实现
3. Poll 如何工作 🟡
你将学到:
- 执行器的轮询循环:poll → pending → wake → poll again
- 如何从零开始构建一个极简执行器
- 虚假唤醒(Spurious wake)规则及其重要意义
- 实用辅助函数:
poll_fn()和yield_now()
轮询状态机
执行器运行一个循环:轮询一个 future,如果返回 Pending,则将其挂起直到其 waker 被触发,然后再次进行轮询。这与操作系统线程由内核处理调度有着本质的区别。
stateDiagram-v2
[*] --> 空闲 (Idle) : Future 已创建
空闲 (Idle) --> 轮询中 (Polling) : 执行器调用 poll()
轮询中 (Polling) --> 完成 (Complete) : Ready(value)
轮询中 (Polling) --> 等待中 (Waiting) : Pending
等待中 (Waiting) --> 轮询中 (Polling) : waker.wake() 被调用
完成 (Complete) --> [*] : 返回结果值
重要提示: 当处于 等待中 (Waiting) 状态时,future 必须已经向 I/O 源注册了 waker。如果没有注册 = 永远挂起。
一个极简执行器
为了揭开执行器的神秘面纱,让我们构建一个最简单的执行器:
use std::future::Future;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
use std::pin::Pin;
/// 最简单的执行器:忙碌循环轮询直到 Ready
fn block_on<F: Future>(mut future: F) -> F::Output {
// 将 future 固定在栈上
// 安全性:在此之后 `future` 绝不会被移动 —— 我们只通过固定引用
// 访问它,直到它完成。
let mut future = unsafe { Pin::new_unchecked(&mut future) };
// 创建一个无操作 (no-op) waker(只是持续轮询 —— 虽然低效但简单)
fn noop_raw_waker() -> RawWaker {
fn no_op(_: *const ()) {}
fn clone(_: *const ()) -> RawWaker { noop_raw_waker() }
let vtable = &RawWakerVTable::new(clone, no_op, no_op, no_op);
RawWaker::new(std::ptr::null(), vtable)
}
// 安全性:noop_raw_waker() 返回一个带有正确虚表 (vtable) 的有效 RawWaker。
let waker = unsafe { Waker::from_raw(noop_raw_waker()) };
let mut cx = Context::from_waker(&waker);
// 忙碌循环直到 future 完成
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(value) => return value,
Poll::Pending => {
// 真正的执行器会在这里挂起线程,并等待 waker.wake()
// 我们这里只是简单地自旋并让出 CPU 片刻
std::thread::yield_now();
}
}
}
}
// 使用示例:
fn main() {
let result = block_on(async {
println!("来自微型执行器的问候!");
42
});
println!("结果是: {result}");
}
切勿在生产环境中使用此代码! 它采用忙碌循环,会浪费 CPU。真正的执行器(如 tokio, smol)会使用
epoll/kqueue/io_uring来进入睡眠状态直到 I/O 就绪。但这展示了核心思想:执行器就是一个不断调用poll()的循环。
唤醒通知
真正的执行器是事件驱动的。当所有 future 都处于 Pending 状态时,执行器会进入休眠。Waker 则是一种中断机制:
#![allow(unused)]
fn main() {
// 真实执行器主循环的概念模型:
fn executor_loop(tasks: &mut TaskQueue) {
loop {
// 1. 轮询所有已被唤醒的任务
while let Some(task) = tasks.get_woken_task() {
match task.poll() {
Poll::Ready(result) => task.complete(result),
Poll::Pending => { /* 任务留在队列中,等待下一次唤醒 */ }
}
}
// 2. 睡眠直到有事件唤醒我们(epoll_wait, kevent 等)
// 这是 mio/polling 库发挥重要作用的地方
tasks.wait_for_events(); // 阻塞直到发生 I/O 事件或 waker 被触发
}
}
}
虚假唤醒 (Spurious Wakes)
即使 I/O 尚未准备好,future 也可能会被轮询。这被称为 虚假唤醒。Future 必须正确处理这种情况:
#![allow(unused)]
fn main() {
impl Future for MyFuture {
type Output = Data;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Data> {
// ✅ 正确做法:始终重新检查实际条件
if let Some(data) = self.try_read_data() {
Poll::Ready(data)
} else {
// 重新注册 waker(它可能已经发生了改变!)
self.register_waker(cx.waker());
Poll::Pending
}
// ❌ 错误做法:假设被轮询就意味着数据已就绪
// let data = self.read_data(); // 可能会阻塞或发生 panic
// Poll::Ready(data)
}
}
}
实现 poll() 的规则:
- 绝不阻塞 —— 如果未就绪,立即返回
Pending。 - 始终重新注册 waker —— waker 在两次轮询之间可能会发生改变。
- 处理虚假唤醒 —— 检查实际条件,不要假定已就绪。
- 不要在返回
Ready后再次轮询 —— 这种行为是 未定义规范 的(可能会 panic、返回Pending或重复返回Ready)。只有FusedFuture能保证完成后的轮询是安全的。
🏋️ 实践任务:重新检查并思考倒计时练习 (点击展开)
挑战:虽然上一章实现过类似的,但这里请注意其执行逻辑:实现一个 CountdownFuture,从 N 开始倒数到 0,将其作为副作用打印出来,当到 0 时,返回 Ready("Liftoff!")。
提示:它不需要真实的 I/O 源 —— 它可以在每次递减后使用 cx.waker().wake_by_ref() 立即唤醒自己。
🔑 参考方案
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct CountdownFuture {
count: u32,
}
impl CountdownFuture {
fn new(start: u32) -> Self {
CountdownFuture { count: start }
}
}
impl Future for CountdownFuture {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.count == 0 {
Poll::Ready("Liftoff!")
} else {
println!("{}...", self.count);
self.count -= 1;
// 立即唤醒 —— 我们总是准备好取得进展
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
}
核心总结:即使这个 future 总是准备好继续运行,它也会返回 Pending 以便在步骤之间让出控制权。它立即调用 wake_by_ref(),这样执行器就会立刻重新轮询它。这是协作式多任务的基础 —— 每个 future 都自愿让出执行权。
实用工具:poll_fn 和 yield_now
标准库和 tokio 提供了两个实用程序来避免编写完整的 Future 实现:
#![allow(unused)]
fn main() {
use std::future::poll_fn;
use std::task::Poll;
// poll_fn: 通过闭包创建一个一次性的 future
let value = poll_fn(|cx| {
// 使用 cx.waker() 做些工作,返回 Ready 或 Pending
Poll::Ready(42)
}).await;
// 现实场景:将基于回调的 API 桥接到异步环境中
async fn read_when_ready(source: &MySource) -> Data {
poll_fn(|cx| source.poll_read(cx)).await
}
}
#![allow(unused)]
fn main() {
// yield_now: 自愿将控制权交给执行器
// 在计算密集型的异步循环中非常有用,可以避免饿死其他任务
async fn cpu_heavy_work(items: &[Item]) {
for (i, item) in items.iter().enumerate() {
process(item); // 繁重的 CPU 计算
// 每处理 100 个条目,让出一次控制权以允许其他任务运行
if i % 100 == 0 {
tokio::task::yield_now().await;
}
}
}
}
何时使用
yield_now():如果你的异步函数在循环中进行 CPU 计算且没有任何.await点,它会独占执行器线程。定期插入yield_now().await以实现协作式多任务。
关键要诀 —— Poll 如何工作
- 执行器会反复对已被唤醒的 future 调用
poll()- Future 必须处理 虚假唤醒 —— 始终重新检查实际条件
poll_fn()允许你通过闭包创建临时 futureyield_now()是计算密集型异步代码实现协作式调度的“逃生舱”
另请参阅: 第 2 章 —— Future Trait 了解 trait 定义,第 5 章 —— 揭秘状态机 了解编译器生成的代码
4. Pin 与 Unpin 🔴
你将学到:
- 为什么自引用结构体在内存中被移动时会崩溃
Pin<P>保证了什么,以及它如何防止移动- 三种实用的固定(pinning)模式:
Box::pin()、tokio::pin!()、Pin::new()- 何时
Unpin提供了一个“逃生舱”
为什么需要 Pin
这是异步 Rust 中最令人困惑的概念。让我们循序渐进地建立直觉。
问题所在:自引用结构体 (Self-Referential Structs)
当编译器将 async fn 转换为状态机时,该状态机可能包含对其自身字段的引用。这创建了一个 自引用结构体 —— 如果在内存中移动它,就会使这些内部引用失效。
#![allow(unused)]
fn main() {
// 编译器为以下代码生成的简化版本:
// async fn example() {
// let data = vec![1, 2, 3];
// let reference = &data; // 指向其上方的 data
// use_ref(reference).await;
// }
// 变成类似这样的结构:
enum ExampleStateMachine {
State0 {
data: Vec<i32>,
// reference: &Vec<i32>, // 问题:指向了上方的 `data`
// // 如果此结构体移动了,指针就会悬空!
},
State1 {
data: Vec<i32>,
reference: *const Vec<i32>, // 指向 data 字段的内部指针
},
Complete,
}
}
graph LR
subgraph "移动前 (有效)"
A["data: [1,2,3]<br/>位于地址 0x1000"]
B["reference: 0x1000<br/>(指向 data)"]
B -->|"有效"| A
end
subgraph "移动后 (无效)"
C["data: [1,2,3]<br/>位于地址 0x2000"]
D["reference: 0x1000<br/>(仍指向旧位置!)"]
D -->|"悬空!"| E["💥 0x1000<br/>(已被释放/乱码)"]
end
style E fill:#ffcdd2,color:#000
style D fill:#ffcdd2,color:#000
style B fill:#c8e6c9,color:#000
实践中的自引用
这并非学术性的担忧。每一个跨越 .await 点持有引用的 async fn 都会创建一个自引用状态机:
#![allow(unused)]
fn main() {
async fn problematic() {
let data = String::from("hello");
let slice = &data[..]; // slice 借用了 data
some_io().await; // <-- .await 点:状态机同时存储了 data 和 slice
println!("{slice}"); // await 之后使用了该引用
}
// 生成的状态机拥有 `data: String` 和 `slice: &str`
// 其中 slice 指向 data 内部。移动状态机 = 悬空指针。
}
Pin 的实际应用
Pin<P> 是一个包装器,用于防止移动指针所指向的值:
#![allow(unused)]
fn main() {
use std::pin::Pin;
let mut data = String::from("hello");
// 固定它 —— 现在它不能被移动了
let pinned: Pin<&mut String> = Pin::new(&mut data);
// 仍可使用它:
println!("{}", pinned.as_ref().get_ref()); // "hello"
// 但我们无法取回 &mut String(那将允许使用 mem::swap 等操作):
// let mutable: &mut String = Pin::into_inner(pinned); // 仅当 String: Unpin 时才行
// String 是 Unpin 的,所以这对于 String 其实是可行的。
// 但对于自引用状态机(它们是 !Unpin 的),这会被阻止。
}
在实际代码中,你主要在三个地方遇到 Pin:
#![allow(unused)]
fn main() {
// 1. poll() 的签名 —— 所有的 future 都是通过 Pin 进行 poll 的
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Output>;
// 2. Box::pin() —— 在堆上分配并固定一个 future
let future: Pin<Box<dyn Future<Output = i32>>> = Box::pin(async { 42 });
// 3. tokio::pin!() —— 在栈上固定一个 future
tokio::pin!(my_future);
// 现在 my_future 的类型为: Pin<&mut impl Future>
}
Unpin 逃生舱
Rust 中的大多数类型都是 Unpin 的 —— 它们不包含自引用,因此固定对它们来说是无操作。只有编译器生成的(源自 async fn 的)状态机是 !Unpin 的。
#![allow(unused)]
fn main() {
// 这些都是 Unpin 的 —— 固定它们没有什么特别的:
// i32, String, Vec<T>, HashMap<K,V>, Box<T>, &T, &mut T
// 这些是 !Unpin 的 —— 必须在 poll 之前固定:
// 由 `async fn` 和 `async {}` 生成的状态机
// 实践建议:
// 如果你手动编写一个 Future 且它没有自引用,
// 请实现 Unpin 以使其更易于使用:
impl Unpin for MySimpleFuture {} // “我很安全,随便动,信我”
}
快速参考
| 对象 | 场景 | 方式 |
|---|---|---|
| 在堆上固定 Future | 存储在集合中,从函数返回 | Box::pin(future) |
| 在栈上固定 Future | 在 select! 中局部使用或手动轮询 | tokio::pin!(future) 或 pin-utils 中的 pin_mut! |
| 函数签名中的 Pin | 接收固定的 Future | future: Pin<&mut F> |
| 要求 Unpin | 在创建后需要移动 Future 时 | F: Future + Unpin |
🏋️ 实践任务:Pin 与移动 (点击展开)
挑战:以下代码片段中哪些可以编译?对于无法编译的,说明原因并修复它。
#![allow(unused)]
fn main() {
// 片段 A
let fut = async { 42 };
let pinned = Box::pin(fut);
let moved = pinned; // 移动 Box
let result = moved.await;
// 片段 B
let fut = async { 42 };
tokio::pin!(fut);
let moved = fut; // 移动固定的 future
let result = moved.await;
// 片段 C
use std::pin::Pin;
let mut fut = async { 42 };
let pinned = Pin::new(&mut fut);
}
🔑 参考方案
片段 A:✅ 可以编译。 Box::pin() 将 future 放在堆上。移动 Box 移动的是指针,而不是 future 本身。Future 仍固定在堆上的位置。
片段 B:✅ 可以编译。 tokio::pin! 将 future 固定到栈上并重新将 fut 绑定为 Pin<&mut ...>。let moved = fut 移动的是 Pin 包装器(一个指针),而不是底层的 future —— future 仍固定在栈上。这就像 Box::pin:移动 Box 不会移动堆分配。注意,fut 在移动后会被消费,之后只能使用 moved:
#![allow(unused)]
fn main() {
let fut = async { 42 };
tokio::pin!(fut);
let moved = fut; // 移动 Pin<&mut> 包装器 —— 没问题
// fut.await; // ❌ 错误:fut 已被移动
let result = moved.await; // ✅ 改用 moved
}
片段 C:❌ 无法编译。 Pin::new() 要求 T: Unpin。Async 代码块生成的是 !Unpin 类型。修复方法:使用 Box::pin() 或 unsafe Pin::new_unchecked():
#![allow(unused)]
fn main() {
let fut = async { 42 };
let pinned = Box::pin(fut); // 堆上固定 —— 对 !Unpin 有效
}
核心总结:Box::pin() 是固定 !Unpin future 的安全且简便的方法。tokio::pin!() 在栈上固定 —— 你可以移动 Pin<&mut> 包装器(它只是个指针),但底层的 future 保持不动。Pin::new() 仅适用于 Unpin 类型。
关键要诀 —— Pin 与 Unpin
Pin<P>是一个包装器,用于 防止被指向的对象被移动 —— 这对自引用状态机至关重要Box::pin()是在堆上固定 future 的安全且默认的首选方式tokio::pin!()在栈上固定 —— 你可以移动Pin<&mut>包装器,但底层的 future 保持不动Unpin是一个自动 trait:实现了Unpin的类型即使在被固定时也可以被移动(大多数类型都是Unpin的;async 块则不是)
另请参阅: 第 2 章 —— Future Trait 了解 poll 中的
Pin<&mut Self>,第 5 章 —— 揭秘状态机 了解为什么异步状态机是自引用的
5. 揭秘状态机 🟢
你将学到:
- 编译器如何将
async fn转换为基于枚举的状态机- 源码 vs 生成状态的对比展示
- 为什么
async fn中的巨量栈分配会使 future 体积爆炸- Drop 优化:变量在不再被需要时立即被释放
编译器实际生成了什么
当你编写 async fn 时,编译器会将你看起来是顺序执行的代码转换为基于枚举(enum)的状态机。理解这一转换过程是理解异步 Rust 性能特性及其许多奇特行为的关键。
对比展示:async fn vs 状态机
#![allow(unused)]
fn main() {
// 你编写的代码:
async fn fetch_two_pages() -> String {
let page1 = http_get("https://example.com/a").await;
let page2 = http_get("https://example.com/b").await;
format!("{page1}\n{page2}")
}
}
编译器生成的代码逻辑上类似于这样:
#![allow(unused)]
fn main() {
enum FetchTwoPagesStateMachine {
// 状态 0:准备调用 http_get 获取 page1
Start,
// 状态 1:等待 page1,持有其 future
WaitingPage1 {
fut1: HttpGetFuture,
},
// 状态 2:拿到 page1,等待 page2
WaitingPage2 {
page1: String,
fut2: HttpGetFuture,
},
// 终止状态
Complete,
}
impl Future for FetchTwoPagesStateMachine {
type Output = String;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<String> {
loop {
match self.as_mut().get_mut() {
Self::Start => {
let fut1 = http_get("https://example.com/a");
*self.as_mut().get_mut() = Self::WaitingPage1 { fut1 };
}
Self::WaitingPage1 { fut1 } => {
let page1 = match Pin::new(fut1).poll(cx) {
Poll::Ready(v) => v,
Poll::Pending => return Poll::Pending,
};
let fut2 = http_get("https://example.com/b");
*self.as_mut().get_mut() = Self::WaitingPage2 { page1, fut2 };
}
Self::WaitingPage2 { page1, fut2 } => {
let page2 = match Pin::new(fut2).poll(cx) {
Poll::Ready(v) => v,
Poll::Pending => return Poll::Pending,
};
let result = format!("{page1}\n{page2}");
*self.as_mut().get_mut() = Self::Complete;
return Poll::Ready(result);
}
Self::Complete => panic!("在完成后继续分发轮询"),
}
}
}
}
}
注意:上述脱糖过程是 概念性 的。真实的编译器输出使用
unsafe的固定投影(pin projections)—— 这里展示的get_mut()调用需要Unpin约束,但异步状态机是!Unpin的。本例旨在阐明状态转换过程,而非生成可直接编译的代码。
stateDiagram-v2
[*] --> Start
Start --> WaitingPage1: 创建 http_get future #1
WaitingPage1 --> WaitingPage1: poll() → Pending
WaitingPage1 --> WaitingPage2: poll() → Ready(page1)
WaitingPage2 --> WaitingPage2: poll() → Pending
WaitingPage2 --> Complete: poll() → Ready(page2)
Complete --> [*]: 返回 format!("{page1}\\n{page2}")
状态内容解析:
- WaitingPage1 —— 存储
fut1: HttpGetFuture(page2 尚未被分配空间)。- WaitingPage2 —— 存储
page1: String和fut2: HttpGetFuture(此时fut1已经被丢弃)。
为什么这对于性能至关重要
零成本:状态机是一个栈分配的枚举。每个 future 不需要堆分配,没有垃圾回收器,没有装箱(boxing)—— 除非你显式使用 Box::pin()。
体积(Size):枚举的大小是其所有变体中最大的那个。每个 .await 点都会创建一个新的枚举变体。这意味着:
#![allow(unused)]
fn main() {
async fn small() {
let a: u8 = 0;
yield_now().await;
let b: u8 = 0;
yield_now().await;
}
// 大小 ≈ max(size_of(u8), size_of(u8)) + 判别码 + 内部 future 大小
// ≈ 非常小!
async fn big() {
let buf: [u8; 1_000_000] = [0; 1_000_000]; // 栈上的 1MB 缓冲区!
some_io().await;
process(&buf);
}
// 大小 ≈ 1MB + 内部 future 的大小
// ⚠️ 绝不要在异步函数中在栈上分配巨大的缓冲区!
// 请改用 Vec<u8> 或 Box<[u8]>。
}
Drop 优化:当状态机发生转换时,它会丢弃不再需要的变量。在上面的例子中,当我们从 WaitingPage1 转换为 WaitingPage2 时,fut1 会被丢弃 —— 编译器会自动插入 drop 操作。
实践准则:
async fn中的大型栈分配会导致 future 的体积爆炸。如果你在异步代码中看到栈溢出(stack overflow),请检查是否有大型数组或深度嵌套的 future。必要时使用Box::pin()在堆上分配子 future。
练习:预测状态机
🏋️ 练习(点击展开)
挑战:给定以下异步函数,勾勒出编译器生成的动态状态机。它有多少个状态(枚举变体)?每个状态中存储了哪些值?
#![allow(unused)]
fn main() {
async fn pipeline(url: &str) -> Result<usize, Error> {
let response = fetch(url).await?;
let body = response.text().await?;
let parsed = parse(body).await?;
Ok(parsed.len())
}
}
🔑 答案
共有五个状态:
- Start —— 存储
url - WaitingFetch —— 存储
url和fetch返回的 future - WaitingText —— 存储
response和text()返回的 future - WaitingParse —— 存储
body和parse返回的 future - Done —— 返回
Ok(parsed.len())
每个 .await 都会创建一个让出点 (yield point) = 一个新的枚举变体。? 增加了早期退出路径,但并不会增加额外的状态 —— 它只是对 Poll::Ready 值的一个 match 操作。
关键要诀 —— 揭秘状态机
async fn编译为一个枚举,每个.await点对应一个变体- Future 的 体积 = 所有变体大小的最大值 —— 巨型栈变量会使其变大
- 编译器在状态转换时会自动插入 drop 操作
- 当 future 体积成为问题时,请使用
Box::pin()或改为堆分配
另请参阅: 第 4 章 —— Pin 与 Unpin 了解为什么生成的枚举需要固定,第 6 章 —— 手动构建 Future 尝试自己构建这些状态机
6. 手动构建 Future 🟡
你将学到:
- 实现一个基于线程唤醒的
TimerFuture- 构建
Join组合器:并发运行两个 future- 构建
Select组合器:竞速运行两个 future- 组合器如何进行复合 —— 万物皆可 Future
一个简单的计时器 Future
现在让我们从头开始构建一些真实且有用的 future。这将进一步巩固第 2-5 章中的理论知识。
TimerFuture:一个完整的例子
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use std::thread;
use std::time::{Duration, Instant};
pub struct TimerFuture {
shared_state: Arc<Mutex<SharedState>>,
}
struct SharedState {
completed: bool,
waker: Option<Waker>,
}
impl TimerFuture {
pub fn new(duration: Duration) -> Self {
let shared_state = Arc::new(Mutex::new(SharedState {
completed: false,
waker: None,
}));
// 派生一个线程,在持续时间结束后设置 completed=true
let thread_shared_state = Arc::clone(&shared_state);
thread::spawn(move || {
thread::sleep(duration);
let mut state = thread_shared_state.lock().unwrap();
state.completed = true;
if let Some(waker) = state.waker.take() {
waker.wake(); // 通知执行器
}
});
TimerFuture { shared_state }
}
}
impl Future for TimerFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let mut state = self.shared_state.lock().unwrap();
if state.completed {
Poll::Ready(())
} else {
// 存储 waker,以便计时器线程可以唤醒我们
// 重要:始终更新 waker —— 执行器可能会在两次轮询之间更改它
state.waker = Some(cx.waker().clone());
Poll::Pending
}
}
}
// 使用示例:
// async fn example() {
// println!("开始计时...");
// TimerFuture::new(Duration::from_secs(2)).await;
// println!("计时结束!");
// }
//
// ⚠️ 这种做法会为每个计时器派生一个操作系统线程 —— 仅用于学习,
// 在生产环境中请使用 `tokio::time::sleep`,它基于共享的
// 时间轮(timer wheel)实现,且不需要额外的线程。
}
Join:并发运行两个 Future
Join 组合器会轮询两个 future,并仅在 两者 都完成时才宣告完成。这也是 tokio::join! 宏内部的工作原理:
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
/// 并发轮询两个 future,以元组形式返回两者的结果
pub struct Join<A, B>
where
A: Future,
B: Future,
{
a: MaybeDone<A>,
b: MaybeDone<B>,
}
enum MaybeDone<F: Future> {
Pending(F),
Done(F::Output),
Taken, // 结果已被提取
}
// MaybeDone<F> 存储了 F::Output,编译器无法证明其即使在 F: Unpin 时也是 Unpin。
// 由于我们只针对 Unpin 的 future 使用 Join,且从不对字段进行固定投影 (pin-project),
// 手动实现 Unpin 是安全的,这使我们能在 poll() 中调用 self.get_mut()。
impl<A: Future + Unpin, B: Future + Unpin> Unpin for Join<A, B> {}
impl<A, B> Join<A, B>
where
A: Future,
B: Future,
{
pub fn new(a: A, b: B) -> Self {
Join {
a: MaybeDone::Pending(a),
b: MaybeDone::Pending(b),
}
}
}
impl<A, B> Future for Join<A, B>
where
A: Future + Unpin,
B: Future + Unpin,
{
type Output = (A::Output, B::Output);
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
// 如果 A 尚未完成,轮询 A
if let MaybeDone::Pending(ref mut fut) = this.a {
if let Poll::Ready(val) = Pin::new(fut).poll(cx) {
this.a = MaybeDone::Done(val);
}
}
// 如果 B 尚未完成,轮询 B
if let MaybeDone::Pending(ref mut fut) = this.b {
if let Poll::Ready(val) = Pin::new(fut).poll(cx) {
this.b = MaybeDone::Done(val);
}
}
// 两者都完成了吗?
match (&this.a, &this.b) {
(MaybeDone::Done(_), MaybeDone::Done(_)) => {
// 提取两者的结果
let a_val = match std::mem::replace(&mut this.a, MaybeDone::Taken) {
MaybeDone::Done(v) => v,
_ => unreachable!(),
};
let b_val = match std::mem::replace(&mut this.b, MaybeDone::Taken) {
MaybeDone::Done(v) => v,
_ => unreachable!(),
};
Poll::Ready((a_val, b_val))
}
_ => Poll::Pending, // 至少有一个仍在等待中
}
}
}
// 使用示例(async 代码块是 !Unpin 的,所以需要用 Box::pin 包装):
// let (page1, page2) = Join::new(
// Box::pin(http_get("https://example.com/a")),
// Box::pin(http_get("https://example.com/b")),
// ).await;
// 两个请求开始并发运行!
}
核心见解:这里的“并发”是指 在同一个线程上交替执行。
Join并没有派生线程 —— 它只是在同一次poll()调用中先后轮询两个 future。这是协作式并发,而非并行。
graph LR
subgraph "Future 组合器"
direction TB
TIMER["TimerFuture<br/>单个 future,延迟后唤醒"]
JOIN["Join<A, B><br/>等待两者全部完成"]
SELECT["Select<A, B><br/>等待最快的一个"]
RETRY["RetryFuture<br/>失败后重新尝试"]
end
TIMER --> JOIN
TIMER --> SELECT
SELECT --> RETRY
style TIMER fill:#d4efdf,stroke:#27ae60,color:#000
style JOIN fill:#e8f4f8,stroke:#2980b9,color:#000
style SELECT fill:#fef9e7,stroke:#f39c12,color:#000
style RETRY fill:#fadbd8,stroke:#e74c3c,color:#000
Select:竞速运行两个 Future
当 其中任意一个 future 先完成时,Select 就会宣告完成(另一个将被丢弃):
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub enum Either<A, B> {
Left(A),
Right(B),
}
/// 返回最先完成的那个 future 的结果;丢弃另一个
pub struct Select<A, B> {
a: A,
b: B,
}
impl<A, B> Select<A, B>
where
A: Future + Unpin,
B: Future + Unpin,
{
pub fn new(a: A, b: B) -> Self {
Select { a, b }
}
}
impl<A, B> Future for Select<A, B>
where
A: Future + Unpin,
B: Future + Unpin,
{
type Output = Either<A::Output, B::Output>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// 先轮询 A
if let Poll::Ready(val) = Pin::new(&mut self.a).poll(cx) {
return Poll::Ready(Either::Left(val));
}
// 再轮询 B
if let Poll::Ready(val) = Pin::new(&mut self.b).poll(cx) {
return Poll::Ready(Either::Right(val));
}
Poll::Pending
}
}
// 配合超时机制的使用示例:
// match Select::new(http_get(url), TimerFuture::new(timeout)).await {
// Either::Left(response) => println!("获得响应: {}", response),
// Either::Right(()) => println!("请求超时!"),
// }
}
关于公平性的说明:我们的
Select总是先轮询 A —— 如果两者同时就绪,A 总是会胜出。Tokio 的select!宏会随机化轮询顺序,以确保公平性。
🏋️ 实践任务:构建一个 RetryFuture (点击展开)
挑战:构建一个 RetryFuture<F, Fut>,它接收一个闭包 F: Fn() -> Fut。如果内部生成的 future 返回 Err,则重试最多 N 次。它应该返回第一个 Ok 结果或最后一次的 Err 结果。
提示:你需要为“当前正在运行的尝试”和“所有尝试已耗尽”建立状态。
🔑 参考方案
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub struct RetryFuture<F, Fut, T, E>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, E>> + Unpin,
{
factory: F,
current: Option<Fut>,
remaining: usize,
last_error: Option<E>,
}
impl<F, Fut, T, E> RetryFuture<F, Fut, T, E>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, E>> + Unpin,
{
pub fn new(max_attempts: usize, factory: F) -> Self {
let current = Some((factory)());
RetryFuture {
factory,
current,
remaining: max_attempts.saturating_sub(1),
last_error: None,
}
}
}
impl<F, Fut, T, E> Future for RetryFuture<F, Fut, T, E>
where
F: Fn() -> Fut + Unpin,
Fut: Future<Output = Result<T, E>> + Unpin,
T: Unpin,
E: Unpin,
{
type Output = Result<T, E>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
loop {
if let Some(ref mut fut) = self.current {
match Pin::new(fut).poll(cx) {
Poll::Ready(Ok(val)) => return Poll::Ready(Ok(val)),
Poll::Ready(Err(e)) => {
self.last_error = Some(e);
if self.remaining > 0 {
self.remaining -= 1;
self.current = Some((self.factory)());
// 立即进入大循环以轮询新创建的 future
} else {
return Poll::Ready(Err(self.last_error.take().unwrap()));
}
}
Poll::Pending => return Poll::Pending,
}
} else {
return Poll::Ready(Err(self.last_error.take().unwrap()));
}
}
}
}
// 使用示例:
// let result = RetryFuture::new(3, || async {
// http_get("https://flaky-server.com/api").await
// }).await;
}
核心总结:Retry future 本身也是一个状态机:它持有当前的尝试,并在失败时创建新的内部 future。这就是组合器的复合方式 —— “嵌套到底”。
关键要诀 —— 手动构建 Future
- 一个 Future 需要三样东西:状态、
poll()实现以及 waker 注册Join轮询两个子 future;Select返回先完成的那个的结果- 组合器本身也是包装了其他 future 的 future —— 万物皆可 Future
- 手动构建 Future 虽能提供深刻洞见,但在生产环境中请使用
tokio::join!/select!
另请参阅: 第 2 章 —— Future Trait 了解 trait 定义,第 8 章 —— Tokio 深度探索 了解生产级的替代方案
7. 执行器与运行时 🟡
你将学到:
- 执行器的职责:轮询 + 高效睡眠
- 六大主流运行时:mio, io_uring, tokio, async-std, smol, embassy
- 选择合适运行时的决策树
- 为什么运行时无关(runtime-agnostic)的库设计至关重要
执行器的职责
执行器有两个主要任务:
- 轮询 Future:当它们准备好取得进展时进行轮询。
- 高效睡眠:当没有 Future 就绪时,利用操作系统的 I/O 通知 API 进入休眠状态。
graph TB
subgraph Executor["执行器 (如 tokio)"]
QUEUE["任务队列"]
POLLER["I/O 轮询器<br/>(epoll/kqueue/io_uring)"]
THREADS["工作线程池"]
end
subgraph Tasks["任务"]
T1["任务 1<br/>(HTTP 请求)"]
T2["任务 2<br/>(数据库查询)"]
T3["任务 3<br/>(文件读取)"]
end
subgraph OS["操作系统"]
NET["网络栈"]
DISK["磁盘 I/O"]
end
T1 --> QUEUE
T2 --> QUEUE
T3 --> QUEUE
QUEUE --> THREADS
THREADS -->|"poll()"| T1
THREADS -->|"poll()"| T2
THREADS -->|"poll()"| T3
POLLER <-->|"注册/通知"| NET
POLLER <-->|"注册/通知"| DISK
POLLER -->|"唤醒任务"| QUEUE
style Executor fill:#e3f2fd,color:#000
style OS fill:#f3e5f5,color:#000
mio:基础层
mio (Metal I/O) 并不是一个执行器 —— 它是最底层的跨平台 I/O 通知库。它封装了 epoll (Linux)、kqueue (macOS/BSD) 和 IOCP (Windows)。
#![allow(unused)]
fn main() {
// mio 使用示意(简化版):
use mio::{Events, Interest, Poll, Token};
use mio::net::TcpListener;
let mut poll = Poll::new()?;
let mut events = Events::with_capacity(128);
let mut server = TcpListener::bind("0.0.0.0:8080")?;
poll.registry().register(&mut server, Token(0), Interest::READABLE)?;
// 事件循环 —— 阻塞直到有事发生
loop {
poll.poll(&mut events, None)?; // 进入睡眠直到发生 I/O 事件
for event in events.iter() {
match event.token() {
Token(0) => { /* 服务器有一个新连接 */ }
_ => { /* 其他 I/O 已就绪 */ }
}
}
}
}
大多数开发者从不直接接触 mio —— tokio 和 smol 都是在其之上构建的。
io_uring:基于“完成”通知的 Future
Linux 的 io_uring (内核 5.1+) 代表了从 mio/epoll 使用的基于“就绪”通知的 I/O 模型的一次根本性转变:
基于“就绪”模型 (epoll / mio / tokio):
1. 询问:“这个 socket 可读吗?” → epoll_wait()
2. 内核:“是的,它就绪了” → EPOLLIN 事件
3. 应用: read(fd, buf) → 仍可能由于各种原因发生短暂阻塞!
基于“完成”模型 (io_uring):
1. 提交:“从这个 socket 读取数据到此缓冲区” → SQE (提交队列条目)
2. 内核:后台异步执行读取操作
3. 应用:获取包含数据及其结果的完成通知 → CQE (完成队列条目)
graph LR
subgraph "基于就绪模型 (epoll)"
A1["应用:就绪了吗?"] --> K1["内核:是的"]
K1 --> A2["应用:现在开始 read()"]
A2 --> K2["内核:这是数据"]
end
subgraph "基于完成模型 (io_uring)"
B1["应用:帮我读一下这个"] --> K3["内核:处理中..."]
K3 --> B2["应用:拿到了结果和数据"]
end
style B1 fill:#c8e6c9,color:#000
style B2 fill:#c8e6c9,color:#000
所有权挑战:io_uring 要求内核在操作完成前拥有缓冲区的所有权。这与 Rust 标准的 AsyncRead trait 存在冲突,因为后者只是借用缓冲区。这就是为什么 tokio-uring 拥有不同的 I/O trait:
#![allow(unused)]
fn main() {
// 标准 tokio (基于就绪) —— 借用缓冲区:
let n = stream.read(&mut buf).await?; // buf 被借用
// tokio-uring (基于完成) —— 获取缓冲区所有权:
let (result, buf) = stream.read(buf).await; // buf 被 move 进去,随后返回
let n = result?;
}
// Cargo.toml: tokio-uring = "0.5"
// 注意:仅限 Linux 且要求内核 5.1+
fn main() {
tokio_uring::start(async {
let file = tokio_uring::fs::File::open("data.bin").await.unwrap();
let buf = vec![0u8; 4096];
let (result, buf) = file.read_at(buf, 0).await;
let bytes_read = result.unwrap();
println!("读取了 {} 字节: {:?}", bytes_read, &buf[..bytes_read]);
});
}
| 维度 | epoll (tokio) | io_uring (tokio-uring) |
|---|---|---|
| 模型 | 就绪通知 (Readiness) | 完成通知 (Completion) |
| 系统调用 | epoll_wait + read/write | 批处理 SQE/CQE 环 |
| 缓冲区所有权 | 应用保留所有权 (&mut buf) | 所有权转移 (move buf) |
| 支持平台 | Linux, macOS, Windows | 仅限较新版本的 Linux |
| 零拷贝 | 否(存在用户态拷贝) | 是(通过注册缓冲区实现) |
| 成熟度 | 生产级就绪 | 实验阶段 |
何时使用 io_uring:在系统调用开销成为瓶颈的高吞吐量文件 I/O 或网络场景(如数据库、存储引擎、承载 100k+ 连接的代理)。对于大多数应用,使用 epoll 的标准 tokio 依然是最佳选择。
tokio:功能完备的运行时
Rust 生态中占统治地位的异步运行时。Axum, Hyper, Tonic 以及大部分生产环境中的 Rust 服务器都在使用它。
// Cargo.toml:
// [dependencies]
// tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
// 派生一个带有任务窃取调度器的多线程运行时
let handle = tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
"完成"
});
let result = handle.await.unwrap();
println!("{result}");
}
tokio 特性:计时器、I/O (TCP/UDP/Unix 域套接字)、信号处理、同步原语 (Mutex, RwLock, Semaphore, 通道)、文件系统、进程管理、以及 tracing 监控集成。
async-std:标准库镜像
通过异步版本镜像了 std 的 API。虽然不如 tokio 流行,但对初学者来说更简单直观。
// Cargo.toml:
// [dependencies]
// async-std = { version = "1", features = ["attributes"] }
#[async_std::main]
async fn main() {
use async_std::fs;
let content = fs::read_to_string("hello.txt").await.unwrap();
println!("{content}");
}
smol:极简主义运行时
小型、零依赖的异步运行时。非常适合希望支持异步但又不想引入庞大 tokio 依赖的类库。
// Cargo.toml:
// [dependencies]
// smol = "2"
fn main() {
smol::block_on(async {
let result = smol::unblock(|| {
// 在线程池中运行阻塞代码
std::fs::read_to_string("hello.txt")
}).await.unwrap();
println!("{result}");
});
}
embassy:嵌入式异步 (no_std)
专为嵌入式系统设计的异步运行时。无需堆分配,无需 std 库支持。
// 在单片机上运行 (例如 STM32, nRF52, RP2040)
#[embassy_executor::main]
async fn main(spawner: embassy_executor::Spawner) {
// 使用 async/await 闪烁 LED —— 无需传统的 RTOS!
let mut led = Output::new(p.PA5, Level::Low, Speed::Low);
loop {
led.set_high();
Timer::after(Duration::from_millis(500)).await;
led.set_low();
Timer::after(Duration::from_millis(500)).await;
}
}
运行时决策树
graph TD
START["选择一个运行时"]
Q1{"正在构建<br/>网络服务器?"}
Q2{"需要 tokio 生态支持?<br/>(Axum, Tonic, Hyper)"}
Q3{"正在编写类库?"}
Q4{"嵌入式环境 /<br/>no_std?"}
Q5{"追求最小依赖?"}
TOKIO["🟢 tokio<br/>生态最强,最流行"]
SMOL["🔵 smol<br/>极简,无生态绑定"]
EMBASSY["🟠 embassy<br/>嵌入式优先,零分配"]
ASYNC_STD["🟣 async-std<br/>类 std API,适合学习"]
AGNOSTIC["🔵 运行时无关设计<br/>仅使用 futures crate"]
START --> Q1
Q1 -->|是的| Q2
Q1 -->|不| Q3
Q2 -->|是的| TOKIO
Q2 -->|不| Q5
Q3 -->|是的| AGNOSTIC
Q3 -->|不| Q4
Q4 -->|是的| EMBASSY
Q4 -->|不| Q5
Q5 -->|是的| SMOL
Q5 -->|不| ASYNC_STD
style TOKIO fill:#c8e6c9,color:#000
style SMOL fill:#bbdefb,color:#000
style EMBASSY fill:#ffe0b2,color:#000
style ASYNC_STD fill:#e1bee7,color:#000
style AGNOSTIC fill:#bbdefb,color:#000
运行时对比表
| 特性 | tokio | async-std | smol | embassy |
|---|---|---|---|---|
| 生态系统 | 统治级别 | 较小 | 极小 | 嵌入式 |
| 多线程支持 | ✅ 任务窃取调度 | ✅ | ✅ | ❌ (通常单核) |
| no_std 支持 | ❌ | ❌ | ❌ | ✅ |
| 计时器 | ✅ 内置 | ✅ 内置 | 通过 async-io | ✅ 基于 HAL |
| I/O | ✅ 自有抽象 | ✅ std 镜像 | 通过 async-io | ✅ HAL 驱动 |
| 通道 (Channels) | ✅ 类型丰富 | ✅ | 通过 async-channel | ✅ |
| 学习曲线 | 中等 | 低 | 低 | 较高 (涉及硬体) |
| 二进制体积 | 较大 | 中等 | 小 | 极小 |
🏋️ 实践任务:运行时对比实操 (点击展开)
挑战:使用三种不同的运行时(tokio, smol, 以及 async-std)编写同一个程序。程序要求:
- 获取一个 URL(通过 sleep 模拟)
- 读取一个文件(通过 sleep 模拟)
- 打印两个结果
该练习旨在证明:异步业务逻辑代码是完全相同的 —— 只有运行时的启动设置有所不同。
🔑 参考方案
// ----- tokio 版本 -----
// Cargo.toml: tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
let (url_result, file_result) = tokio::join!(
async {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
"来自 URL 的响应"
},
async {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
"文件内容"
},
);
println!("URL: {url_result}, 文件: {file_result}");
}
// ----- smol 版本 -----
// Cargo.toml: smol = "2", futures-lite = "2"
fn main() {
smol::block_on(async {
let (url_result, file_result) = futures_lite::future::zip(
async {
smol::Timer::after(std::time::Duration::from_millis(100)).await;
"来自 URL 的响应"
},
async {
smol::Timer::after(std::time::Duration::from_millis(50)).await;
"文件内容"
},
).await;
println!("URL: {url_result}, 文件: {file_result}");
});
}
// ----- async-std 版本 -----
// Cargo.toml: async-std = { version = "1", features = ["attributes"] }
#[async_std::main]
async fn main() {
let (url_result, file_result) = futures::future::join(
async {
async_std::task::sleep(std::time::Duration::from_millis(100)).await;
"来自 URL 的响应"
},
async {
async_std::task::sleep(std::time::Duration::from_millis(50)).await;
"文件内容"
},
).await;
println!("URL: {url_result}, 文件: {file_result}");
}
核心总结:跨运行时的异步业务逻辑几乎完全一致。只有入口点和计时器/IO API 存在差异。这就是为什么编写运行时无关(runtime-agnostic)的库(仅依赖 std::future::Future)具有重要价值。
关键要诀 —— 执行器与运行时
- 执行器的核心工作:在任务唤醒时进行轮询,并利用 OS I/O API 实现高效睡眠。
- tokio 是服务器端的默认选择;smol 适用于极致轻盈的需求;embassy 则是嵌入式的首选。
- 业务逻辑应当依赖
std::future::Future,而非特定的运行时环境。- io_uring 是 Linux 高性能 I/O 的未来,但目前生态系统仍在不断完善中。
另请参阅: 第 8 章 —— Tokio 深度探索 了解 tokio 细节,第 9 章 —— 当 Tokio 不适用时 了解替代方案。
8. Tokio 深度探索 🟡
你将学到:
- 运行时类型(Runtime Flavors):多线程 vs 单线程模式及其适用场景
tokio::spawn、'static约束以及JoinHandle- 任务取消语义(丢弃即取消吗?)
- 同步原语:Mutex、RwLock、Semaphore 以及四种通道(channel)类型
运行时类型:多线程 vs 单线程
Tokio 提供了两种运行时配置:
// 多线程模式(#[tokio::main] 的默认值)
// 使用任务窃取(work-stealing)线程池 —— 任务可以在线程间移动
#[tokio::main]
async fn main() {
// N 个工作线程(默认等于 CPU 核心数)
// 任务必须满足 Send + 'static
}
// 单线程模式(Current-thread) —— 所有内容都在一个线程上运行
#[tokio::main(flavor = "current_thread")]
async fn main() {
// 单线程运行 —— 任务不需要满足 Send 约束
// 更轻量,适合简单的工具或 WASM
}
// 手动构建运行时:
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.unwrap();
rt.block_on(async {
println!("在自定义运行时中运行");
});
graph TB
subgraph "多线程模式 (默认)"
MT_Q1["线程 1<br/>任务 A, 任务 D"]
MT_Q2["线程 2<br/>任务 B"]
MT_Q3["线程 3<br/>任务 C, 任务 E"]
STEAL["任务窃取:<br/>空闲线程从忙碌线程处“偷取”任务"]
MT_Q1 <--> STEAL
MT_Q2 <--> STEAL
MT_Q3 <--> STEAL
end
subgraph "单线程模式"
ST_Q["单线程<br/>任务 A → 任务 B → 任务 C → 任务 D"]
end
style MT_Q1 fill:#c8e6c9,color:#000
style MT_Q2 fill:#c8e6c9,color:#000
style MT_Q3 fill:#c8e6c9,color:#000
style ST_Q fill:#bbdefb,color:#000
tokio::spawn 与 ’static 约束
tokio::spawn 将一个 future 放入运行时的任务队列。由于它可能在 任何 时间运行在 任何 工作线程上,因此该 future 必须满足 Send + 'static:
#![allow(unused)]
fn main() {
use tokio::task;
async fn example() {
let data = String::from("hello");
// ✅ 可行:将所有权 move 进任务中
let handle = task::spawn(async move {
println!("{data}");
data.len()
});
let len = handle.await.unwrap();
println!("长度: {len}");
}
async fn problem() {
let data = String::from("hello");
// ❌ 失败:data 是被借用的,不是 'static
// task::spawn(async {
// println!("{data}"); // 借用了 `data` —— 非 'static
// });
// ❌ 失败:Rc 不是 Send 的
// let rc = std::rc::Rc::new(42);
// task::spawn(async move {
// println!("{rc}"); // Rc 是 !Send 的 —— 不能跨线程边界
// });
}
}
为什么需要 'static? 被派生(spawn)的任务是独立运行的 —— 它可能会比创建它的作用域活得更久。编译器无法证明引用会一直有效,因此要求拥有所有权的数据。
为什么需要 Send? 任务可能会在被挂起的线程之外的另一个线程上恢复执行。所有跨越 .await 点持有的数据都必须能安全地在线程间传递。
#![allow(unused)]
fn main() {
// 常见模式:克隆共享数据到任务中
let shared = Arc::new(config);
for i in 0..10 {
let shared = Arc::clone(&shared); // 克隆的是 Arc,不是内部数据
tokio::spawn(async move {
process_item(i, &shared).await;
});
}
}
JoinHandle 与任务取消
#![allow(unused)]
fn main() {
use tokio::task::JoinHandle;
use tokio::time::{sleep, Duration};
async fn cancellation_example() {
let handle: JoinHandle<String> = tokio::spawn(async {
sleep(Duration::from_secs(10)).await;
"已完成".to_string()
});
// 丢弃 handle 就能取消任务吗?不能 —— 任务会继续运行!
// drop(handle); // 任务在后台继续
// 要真正取消,请调用 abort():
handle.abort();
// 等待一个被中止的任务会返回 JoinError
match handle.await {
Ok(val) => println!("获得: {val}"),
Err(e) if e.is_cancelled() => println!("任务已取消"),
Err(e) => println!("任务发生 panic: {e}"),
}
}
}
重要提示:在 tokio 中,丢弃
JoinHandle并不会取消任务。任务会变为“分离”状态并在后台继续运行。你必须显式调用.abort()来取消它。这与直接丢弃一个Future不同(后者确实会取消/丢弃底层的计算)。
Tokio 同步原语
Tokio 提供了异步感知的同步原语。核心原则:不要在跨越 .await 点时使用 std::sync::Mutex。
#![allow(unused)]
fn main() {
use tokio::sync::{Mutex, RwLock, Semaphore, mpsc, oneshot, broadcast, watch};
// --- Mutex ---
// 异步 Mutex:lock() 方法是异步的,不会阻塞当前线程
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
{
let mut guard = data.lock().await; // 非阻塞锁
guard.push(4);
} // guard 被丢弃 —— 锁被释放
// --- 通道 (Channels) ---
// mpsc:多生产者,单消费者
let (tx, mut rx) = mpsc::channel::<String>(100); // 带缓冲队列
tokio::spawn(async move {
tx.send("hello".into()).await.unwrap();
});
let msg = rx.recv().await.unwrap();
// oneshot:单次发送,单消费者
let (tx, rx) = oneshot::channel::<i32>();
tx.send(42).unwrap(); // 无需 await —— 要么发送成功,要么报错
let val = rx.await.unwrap();
// broadcast:多生产者,多消费者(所有人都会收到每一条消息)
let (tx, _) = broadcast::channel::<String>(100);
let mut rx1 = tx.subscribe();
let mut rx2 = tx.subscribe();
// watch:单生产者,多消费者(仅保留最新值)
let (tx, rx) = watch::channel(0u64);
tx.send(42).unwrap();
println!("最新值: {}", *rx.borrow());
}
注意:为了简洁,这些通道示例中使用了
.unwrap()。在生产环境中,请优雅地处理发送/接收错误 ——.send()失败通常意味着接收方已被丢弃,.recv()失败则意味着通道已关闭。
graph LR
subgraph "通道类型"
direction TB
MPSC["mpsc<br/>N→1<br/>带缓冲队列"]
ONESHOT["oneshot<br/>1→1<br/>单次发送"]
BROADCAST["broadcast<br/>N→N<br/>所有人收到所有消息"]
WATCH["watch<br/>1→N<br/>仅保留最新值"]
end
P1["生产者 1"] --> MPSC
P2["生产者 2"] --> MPSC
MPSC --> C1["消费者"]
P3["生产者"] --> ONESHOT
ONESHOT --> C2["消费者"]
P4["生产者"] --> BROADCAST
BROADCAST --> C3["消费者 1"]
BROADCAST --> C4["消费者 2"]
P5["生产者"] --> WATCH
WATCH --> C5["消费者 1"]
WATCH --> C6["消费者 2"]
案例分析:为通知服务选择正确的通道
你正在构建一个通知服务,其中:
- 多个 API 处理器产生事件
- 单个后台任务进行批处理并发送
- 一个配置观察者在运行时更新速率限制
- 一个停机信号必须传达至所有组件
各场景应使用哪种通道?
| 需求 | 通道 | 原因 |
|---|---|---|
| API 处理器 → 批处理器 | mpsc (带缓冲) | N 个生产者,1 个消费者。带缓冲可提供背压(backpressure)—— 如果批处理器处理太慢,API 处理器会随之减速而不会内存溢出 |
| 配置观察者 → 速率限制器 | watch | 只有最新的配置才有意义。多个读取者(每个工作单元)只需要看到当前值 |
| 停机信号 → 所有组件 | broadcast | 每个组件都必须独立接收到停机通知 |
| 单次健康检查响应 | oneshot | 请求/响应模式 —— 一个值,发完即结束 |
graph LR
subgraph "通知服务"
direction TB
API1["API 处理器 1"] -->|mpsc| BATCH["批处理器"]
API2["API 处理器 2"] -->|mpsc| BATCH
CONFIG["配置观察者"] -->|watch| RATE["速率限制器"]
CTRL["Ctrl+C 信号"] -->|broadcast| API1
CTRL -->|broadcast| BATCH
CTRL -->|broadcast| RATE
end
style API1 fill:#d4efdf,stroke:#27ae60,color:#000
style API2 fill:#d4efdf,stroke:#27ae60,color:#000
style BATCH fill:#e8f4f8,stroke:#2980b9,color:#000
style CONFIG fill:#fef9e7,stroke:#f39c12,color:#000
style RATE fill:#fef9e7,stroke:#f39c12,color:#000
style CTRL fill:#fadbd8,stroke:#e74c3c,color:#000
🏋️ 实践任务:构建一个任务池 (点击展开)
挑战:编写一个函数 run_with_limit,它接收一组异步闭包和一个并发限制值,确保同时执行的任务不超过 N 个。使用 tokio::sync::Semaphore。
🔑 参考方案
#![allow(unused)]
fn main() {
use std::future::Future;
use std::sync::Arc;
use tokio::sync::Semaphore;
async fn run_with_limit<F, Fut, T>(tasks: Vec<F>, limit: usize) -> Vec<T>
where
F: FnOnce() -> Fut + Send + 'static,
Fut: Future<Output = T> + Send + 'static,
T: Send + 'static,
{
let semaphore = Arc::new(Semaphore::new(limit));
let mut handles = Vec::new();
for task in tasks {
let permit = Arc::clone(&semaphore);
let handle = tokio::spawn(async move {
let _permit = permit.acquire().await.unwrap();
// 任务运行时持有许可证,完成后自动释放
task().await
});
handles.push(handle);
}
let mut results = Vec::new();
for handle in handles {
results.push(handle.await.unwrap());
}
results
}
// 使用示例:
// let tasks: Vec<_> = urls.into_iter().map(|url| {
// move || async move { fetch(url).await }
// }).collect();
// let results = run_with_limit(tasks, 10).await; // 最多 10 个并发
}
核心总结:Semaphore 是 tokio 中限制并发的标准方式。每个任务在开始工作前获取一个许可证。当信号量满员时,新任务会异步等待(非阻塞)直到有空位放出。
关键要诀 —— Tokio 深度探索
- 服务器端使用
multi_thread(默认);CLI 工具、测试或处理!Send类型时使用current_thread。tokio::spawn要求'staticfuture —— 使用Arc或通道来共享数据。- 丢弃
JoinHandle不会取消任务 —— 请显式调用.abort()。- 根据需求选择同步原语:共享状态用
Mutex,限制并发用Semaphore,组件间通信则在mpsc/oneshot/broadcast/watch中按需挑选。
另请参阅: 第 9 章 —— 当 Tokio 不适用时 寻找 spawn 的替代方案,第 12 章 —— 常见陷阱 了解跨 await 持有 MutexGuard 的潜在漏洞。
9. 当 Tokio 不适用时 🟡
你将学到:
'static难题:为什么tokio::spawn总是逼你到处使用Arc- 适用于
!Send类型 future 的LocalSet- 借用友好型并发:无需使用 spawn 的
FuturesUnordered- 用于管理任务组的
JoinSet- 编写运行时无关(runtime-agnostic)的库
graph TD
START["需要并发运行 Future?"] --> STATIC{"Future 满足 'static 吗?"}
STATIC -->|是的| SEND{"Future 满足 Send 吗?"}
STATIC -->|不| FU["FuturesUnordered<br/>在当前任务上运行"]
SEND -->|是的| SPAWN["tokio::spawn<br/>多线程运行"]
SEND -->|不| LOCAL["LocalSet<br/>单线程运行"]
SPAWN --> MANAGE{"需要追踪/中止任务吗?"}
MANAGE -->|是的| JOINSET["JoinSet / TaskTracker"]
MANAGE -->|不| HANDLE["JoinHandle"]
style START fill:#f5f5f5,stroke:#333,color:#000
style FU fill:#d4efdf,stroke:#27ae60,color:#000
style SPAWN fill:#e8f4f8,stroke:#2980b9,color:#000
style LOCAL fill:#fef9e7,stroke:#f39c12,color:#000
style JOINSET fill:#e8daef,stroke:#8e44ad,color:#000
style HANDLE fill:#e8f4f8,stroke:#2980b9,color:#000
'static Future 难题
Tokio 的 spawn 要求传入的 future 必须满足 'static 约束。这意味着你无法在被派生(spawn)的任务中借用局部数据:
#![allow(unused)]
fn main() {
async fn process_items(items: &[String]) {
// ❌ 无法执行 —— items 是借用的,不是 'static
// for item in items {
// tokio::spawn(async {
// process(item).await;
// });
// }
// 😐 方案 1:克隆所有内容
for item in items {
let item = item.clone();
tokio::spawn(async move {
process(&item).await;
});
}
// 😐 方案 2:使用 Arc
let items = Arc::new(items.to_vec());
for i in 0..items.len() {
let items = Arc::clone(&items);
tokio::spawn(async move {
process(&items[i]).await;
});
}
}
}
这确实很烦人!在 Go 语言中,你可以直接使用闭包 go func() { use(item) }。而在 Rust 中,所有权系统强制你必须思考谁拥有什么、以及它能活多久。
tokio::spawn 的替代方案
并非所有问题都需要 spawn。这里有三个工具,它们分别解决了 不同 的约束:
#![allow(unused)]
fn main() {
// 1. FuturesUnordered —— 完全避开了 'static 约束(无需 spawn!)
use futures::stream::{FuturesUnordered, StreamExt};
async fn process_items(items: &[String]) {
let futures: FuturesUnordered<_> = items
.iter()
.map(|item| async move {
// ✅ 可以借用 item —— 无需 spawn,无需满足 'static!
process(item).await
})
.collect();
// 驱动所有 future 直至完成
futures.for_each(|result| async move {
println!("结果: {result:?}");
}).await;
}
// 2. tokio::task::LocalSet —— 在当前线程运行 !Send 的 future
// ⚠️ 仍需满足 'static —— 它解决了 Send 难题而非 'static 难题
use tokio::task::LocalSet;
let local_set = LocalSet::new();
local_set.run_until(async {
tokio::task::spawn_local(async {
// 这里可以使用 Rc, Cell 以及其他 !Send 类型
let rc = std::rc::Rc::new(42);
println!("{rc}");
}).await.unwrap();
}).await;
// 3. tokio JoinSet (tokio 1.21+) —— 被派生任务的管理集合
// ⚠️ 仍需满足 'static + Send —— 它解决了任务“管理”难题,
// 而非 'static 难题。在追踪、中止和汇聚动态任务组时非常有用。
use tokio::task::JoinSet;
async fn with_joinset() {
let mut set = JoinSet::new();
for i in 0..10 {
// i 是 Copy 的且已被 move 进闭包 —— 本身就是 'static 的。
// 对于借用的数据,你仍需使用 Arc 或克隆。
set.spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
i * 2
});
}
while let Some(result) = set.join_next().await {
println!("任务完成: {:?}", result.unwrap());
}
}
}
哪个工具解决哪个难题?
遇到的约束 工具 能避开 'static吗?能避开 Send吗?无法让 future 满足 'staticFuturesUnordered✅ 是 ✅ 是 满足 'static但不满足SendLocalSet❌ 否 ✅ 是 需要追踪/中止已派生的任务 JoinSet❌ 否 ❌ 否
为类库提供轻量级运行时支持
如果你正在编写类库 —— 请不要强迫用户使用 tokio:
#![allow(unused)]
fn main() {
// ❌ 错误做法:库强迫用户使用 tokio
pub async fn my_lib_function() {
tokio::time::sleep(Duration::from_secs(1)).await;
// 现在你的用户“必须”使用 tokio 才能运行
}
// ✅ 正确做法:库是运行时无关的
pub async fn my_lib_function() {
// 仅使用来自 std::future 和 futures crate 的类型
do_computation().await;
}
// ✅ 正确做法:为 I/O 操作接收泛型类型的 future
pub async fn fetch_with_retry<F, Fut, T, E>(
operation: F,
max_retries: usize,
) -> Result<T, E>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, E>>,
{
for attempt in 0..max_retries {
match operation().await {
Ok(val) => return Ok(val),
Err(e) if attempt == max_retries - 1 => return Err(e),
Err(_) => continue,
}
}
unreachable!()
}
}
经验法则:类库应当依赖
futurescrate,而非直接依赖tokio。二进制应用程序则应当依赖tokio(或自选的运行时)。这样能保持生态系统的可组合性。
🏋️ 实践任务:FuturesUnordered vs Spawn (click to expand)
挑战:用两种方式编写同一个函数 —— 一次使用 tokio::spawn(要求 'static),另一次使用 FuturesUnordered(支持借用)。该函数接收 &[String],在模拟异步查找后返回每个字符串的长度。
对比:哪种方法需要 .clone()?哪种可以直接借用输入的切片?
🔑 参考方案
#![allow(unused)]
fn main() {
use futures::stream::{FuturesUnordered, StreamExt};
use tokio::time::{sleep, Duration};
// 版本 1:tokio::spawn —— 要求 'static,必须克隆
async fn lengths_with_spawn(items: &[String]) -> Vec<usize> {
let mut handles = Vec::new();
for item in items {
let owned = item.clone(); // 必须克隆 —— spawn 要求满足 'static
handles.push(tokio::spawn(async move {
sleep(Duration::from_millis(10)).await;
owned.len()
}));
}
let mut results = Vec::new();
for handle in handles {
results.push(handle.await.unwrap());
}
results
}
// 版本 2:FuturesUnordered —— 支持借用,无需克隆
async fn lengths_without_spawn(items: &[String]) -> Vec<usize> {
let futures: FuturesUnordered<_> = items
.iter()
.map(|item| async move {
sleep(Duration::from_millis(10)).await;
item.len() // ✅ 直接借用 item —— 无需克隆!
})
.collect();
futures.collect().await
}
#[tokio::test]
async fn test_both_versions() {
let items = vec!["hello".into(), "world".into(), "rust".into()];
let v1 = lengths_with_spawn(&items).await;
// 注意:v1 保留了插入顺序(顺序 Join)
let mut v2 = lengths_without_spawn(&items).await;
v2.sort(); // FuturesUnordered 按完成顺序返回结果
assert_eq!(v1, vec![5, 5, 4]);
assert_eq!(v2, vec![4, 5, 5]);
}
}
核心总结:FuturesUnordered 通过在当前任务(而非线程迁移后的任务)上运行所有 future 来避开 'static 约束。权衡在于:所有 future 共享同一个任务 —— 如果其中一个发生阻塞,其余的都会停滞。对于应该运行在独立线程上的 CPU 密集型工作,仍请使用 spawn。
关键要诀 —— 当 Tokio 不适用时
FuturesUnordered在当前任务上并发运行多个 future —— 无需'static约束。LocalSet允许在单线程执行器上运行!Send类型的 future。JoinSet(tokio 1.21+) 提供了带有自动清理功能的任务管理组。- 类库开发者:请仅依赖
std::future::Future+futures包,不要直接绑定 tokio。
另请参阅: 第 8 章 —— Tokio 深度探索 了解何时 spawn 是正确之选,第 11 章 —— 流 (Streams) 了解
buffer_unordered()这一并发限制器。
10. 异步 Trait 🟡
你将学到:
- 为什么 Trait 中的异步方法花了数年才稳定下来
- RPITIT:原生异步 Trait 方法(Rust 1.75+)
- 动态分派 (dyn dispatch) 的挑战及
trait_variant解决方案- 异步闭包 (Rust 1.85+):
async Fn()与async FnOnce()
graph TD
subgraph "异步 Trait 实现方案"
direction TB
RPITIT["RPITIT (Rust 1.75+)<br/>Trait 中的原生 async fn<br/>仅支持静态分派"]
VARIANT["trait_variant<br/>自动生成满足 Send 的变体<br/>支持动态分派 (dyn dispatch)"]
BOXED["Box<dyn Future><br/>手动装箱 (Manual boxing)<br/>全版本通用"]
CLOSURE["异步闭包 (1.85+)<br/>async Fn() / async FnOnce()<br/>回调与中间件首选"]
end
RPITIT -->|"需要动态分派?"| VARIANT
RPITIT -->|"版本低于 1.75?"| BOXED
CLOSURE -->|"替代"| BOXED
style RPITIT fill:#d4efdf,stroke:#27ae60,color:#000
style VARIANT fill:#e8f4f8,stroke:#2980b9,color:#000
style BOXED fill:#fef9e7,stroke:#f39c12,color:#000
style CLOSURE fill:#e8daef,stroke:#8e44ad,color:#000
背景:为什么它花了这么久?
Trait 中的异步方法多年来一直是 Rust 用户最期待的特性。其难点在于:
#![allow(unused)]
fn main() {
// 在 Rust 1.75 (2023年12月) 之前,这段代码无法编译:
trait DataStore {
async fn get(&self, key: &str) -> Option<String>;
}
// 原因:async fn 返回的是 `impl Future<Output = T>`,
// 而当时 Trait 的返回位置并不支持 `impl Trait`。
}
根本挑战在于:当 Trait 方法返回 impl Future 时,每个实现者返回的其实都是 不同的具体类型。编译器需要知道返回类型的大小,但 Trait 方法往往涉及动态分派。
RPITIT: Return Position Impl Trait in Trait
自 Rust 1.75 起,原生异步 Trait 已支持静态分派:
#![allow(unused)]
fn main() {
trait DataStore {
async fn get(&self, key: &str) -> Option<String>;
// 脱糖后等价于:
// fn get(&self, key: &str) -> impl Future<Output = Option<String>>;
}
struct InMemoryStore {
data: std::collections::HashMap<String, String>,
}
impl DataStore for InMemoryStore {
async fn get(&self, key: &str) -> Option<String> {
self.data.get(key).cloned()
}
}
// ✅ 配合泛型使用(静态分派):
async fn lookup<S: DataStore>(store: &S, key: &str) {
if let Some(val) = store.get(key).await {
println!("{key} = {val}");
}
}
}
动态分派 (dyn dispatch) 与 Send 约束
局限性:你不能直接使用 dyn DataStore,因为编译器不知道返回的 future 的具体体积:
#![allow(unused)]
fn main() {
// ❌ 无法运行:
// async fn lookup_dyn(store: &dyn DataStore, key: &str) { ... }
// 错误信息:trait `DataStore` 不满足“dyn 兼容性”,因为其方法 `get` 是异步的
// ✅ 解决方案:返回一个装箱后的 future
trait DynDataStore {
fn get(&self, key: &str) -> Pin<Box<dyn Future<Output = Option<String>> + Send + '_>>;
}
// 或者使用 trait_variant 宏(见下文)
}
Send 难题:在多线程运行时中,派生的任务必须满足 Send。但异步 Trait 方法并不会自动添加 Send 约束:
#![allow(unused)]
fn main() {
trait Worker {
async fn run(self); // 该 future 可能是也可能不是 Send 的
}
struct MyWorker;
impl Worker for MyWorker {
async fn run(self) {
// 如果使用了 !Send 类型,整个 future 就是 !Send 的
let rc = std::rc::Rc::new(42);
some_work().await;
println!("{rc}");
}
}
// ❌ 报错,因为 future 内部包含 Rc,不满足 Send 约束:
// tokio::spawn(worker.run()); // 要求 Send + 'static
// 注意:这里我们使用 `self` (所有权) 是因为 tokio::spawn
// 还要求 'static 约束。
}
trait_variant Crate
trait_variant crate(由 Rust 异步工作小组发布)可以自动生成一个满足 Send 的变体:
#![allow(unused)]
fn main() {
// Cargo.toml: trait-variant = "0.1"
#[trait_variant::make(SendDataStore: Send)]
trait DataStore {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: String);
}
// 现在你拥有了两个 Trait:
// - DataStore:其 future 没有 Send 约束
// - SendDataStore:所有 future 都必须满足 Send 约束
// 两者拥有相同的方法,实现者通常只需实现 DataStore。
// 如果其 future 满足 Send,则会自动获得对 SendDataStore 的实现。
// 当你需要 spawn 任务时,使用 SendDataStore:
async fn spawn_lookup(store: Arc<dyn SendDataStore>) {
tokio::spawn(async move {
store.get("key").await;
});
}
}
快速参考:异步 Trait
| 方案 | 静态分派 | 动态分派 | Send 约束 | 语法负担 |
|---|---|---|---|---|
原生 async fn | ✅ | ❌ | 隐式 | 无 |
trait_variant | ✅ | ✅ | 显式 | #[trait_variant::make] |
手动 Box::pin | ✅ | ✅ | 显式 | 高 |
async-trait 包 | ✅ | ✅ | #[async_trait] | 中等(过程宏) |
建议:对于新代码(Rust 1.75+),优先使用原生异步 Trait。如果需要
dyn分派,配合trait_variant使用。虽然async-trait包仍被广泛使用,但它会对每个 future 进行装箱,而原生方案对于静态分派是零成本的。
异步闭包 (Async Closures, Rust 1.85+)
自 Rust 1.85 起,异步闭包 已稳定 —— 它们可以捕获环境变量并返回一个 future:
#![allow(unused)]
fn main() {
// 1.85 之前:繁琐的变通方案
let urls = vec!["https://a.com", "https://b.com"];
let fetchers: Vec<_> = urls.iter().map(|url| {
let url = url.to_string();
// 返回一个非异步闭包,内部返回一个异步块
move || async move { reqwest::get(&url).await }
}).collect();
// 1.85 之后:异步闭包直接可用
let fetchers: Vec<_> = urls.iter().map(|url| {
async move || { reqwest::get(url).await }
// ↑ 这是一个异步闭包 —— 捕获 url,返回一个 Future
}).collect();
}
异步闭包实现了新的 AsyncFn、AsyncFnMut 和 AsyncFnOnce trait,它们镜像了对应的 Fn 系列 trait:
#![allow(unused)]
fn main() {
// 接收异步闭包的泛型函数
async fn retry<F>(max: usize, f: F) -> Result<String, Error>
where
F: AsyncFn() -> Result<String, Error>,
{
for _ in 0..max {
if let Ok(val) = f().await {
return Ok(val);
}
}
f().await
}
}
迁移提示:如果你仍在使用
Fn() -> impl Future<Output = T>,可以考虑换成AsyncFn() -> T以获得更简洁的签名。
🏋️ 实践任务:设计一个异步缓存服务 Trait (点击展开)
挑战:设计一个带有异步 get 和 set 方法的 Cache trait。分别提供两个实现:一个基于 HashMap(内存存储),另一个模拟 Redis 后端(使用 tokio::time::sleep 模拟网络延迟)。编写一个能同时兼容两者的泛型函数。
🔑 参考方案
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration};
trait Cache {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: String);
}
// --- 内存缓存轴实现 ---
struct MemoryCache {
store: Mutex<HashMap<String, String>>,
}
impl MemoryCache {
fn new() -> Self {
MemoryCache {
store: Mutex::new(HashMap::new()),
}
}
}
impl Cache for MemoryCache {
async fn get(&self, key: &str) -> Option<String> {
self.store.lock().await.get(key).cloned()
}
async fn set(&self, key: &str, value: String) {
self.store.lock().await.insert(key.to_string(), value);
}
}
// --- 模拟 Redis 实现 ---
struct RedisCache {
store: Mutex<HashMap<String, String>>,
latency: Duration,
}
impl RedisCache {
fn new(latency_ms: u64) -> Self {
RedisCache {
store: Mutex::new(HashMap::new()),
latency: Duration::from_millis(latency_ms),
}
}
}
impl Cache for RedisCache {
async fn get(&self, key: &str) -> Option<String> {
sleep(self.latency).await; // 模拟网络往返
self.store.lock().await.get(key).cloned()
}
async fn set(&self, key: &str, value: String) {
sleep(self.latency).await;
self.store.lock().await.insert(key.to_string(), value);
}
}
// --- 兼容任意 Cache 的泛型函数 ---
async fn cache_demo<C: Cache>(cache: &C, label: &str) {
cache.set("greeting", "Hello, async!".into()).await;
let val = cache.get("greeting").await;
println!("[{label}] greeting = {val:?}");
}
#[tokio::main]
async fn main() {
let mem = MemoryCache::new();
cache_demo(&mem, "memory").await;
let redis = RedisCache::new(50);
cache_demo(&redis, "redis").await;
}
核心总结:同一个泛型函数通过静态分派完美支持两种不同的异步实现。没有装箱,没有额外的分配开销。
关键要诀 —— 异步 Trait
- 自 Rust 1.75 起,可以直接在 Trait 中编写
async fn。trait_variant::make宏在支持动态分派的同时能自动生成Send变体。- 异步闭包 (
async Fn()) 在 1.85 稳定 —— 它是回调和中间件的首选语法。- 在性能敏感的代码中,优先使用静态分派 (
<S: Service>) 而非dyn动态分派。
另请参阅: 第 13 章 —— 生产模式 了解 Tower 的
Servicetrait,第 6 章 —— 手动构建 Future 了解手动实现方案。
11. 流 (Streams) 与 AsyncIterator 🟡
你将学到:
Streamtrait:对多个值进行异步迭代- 创建流:
stream::iter、async_stream、unfold- 流组合器:
map、filter、buffer_unordered、fold- 异步 I/O trait:
AsyncRead、AsyncWrite、AsyncBufRead
Stream Trait 概览
如果说 Future 对应异步的单个值,那么 Stream 就对应异步的 Iterator —— 它异步地产生多个值:
#![allow(unused)]
fn main() {
// std::iter::Iterator (同步,多个值)
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
// futures::Stream (异步,多个值)
trait Stream {
type Item;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>>;
}
}
graph LR
subgraph "同步"
VAL["值<br/>(T)"]
ITER["迭代器<br/>(多个 T)"]
end
subgraph "异步"
FUT["Future<br/>(异步 T)"]
STREAM["流 (Stream)<br/>(异步多个 T)"]
end
VAL -->|"异步化"| FUT
ITER -->|"异步化"| STREAM
VAL -->|"数量增加"| ITER
FUT -->|"数量增加"| STREAM
style VAL fill:#e3f2fd,color:#000
style ITER fill:#e3f2fd,color:#000
style FUT fill:#c8e6c9,color:#000
style STREAM fill:#c8e6c9,color:#000
创建流 (Streams)
#![allow(unused)]
fn main() {
use futures::stream::{self, StreamExt};
use tokio::time::{interval, Duration};
use tokio_stream::wrappers::IntervalStream;
// 1. 从迭代器转换
let s = stream::iter(vec![1, 2, 3]);
// 2. 从异步生成器转换 (使用 async_stream crate)
// Cargo.toml: async-stream = "0.3"
use async_stream::stream;
fn countdown(from: u32) -> impl futures::Stream<Item = u32> {
stream! {
for i in (0..=from).rev() {
tokio::time::sleep(Duration::from_millis(500)).await;
yield i;
}
}
}
// 3. 从 tokio interval 转换
let tick_stream = IntervalStream::new(interval(Duration::from_secs(1)));
// 4. 从通道接收端转换 (tokio_stream::wrappers)
let (tx, rx) = tokio::sync::mpsc::channel::<String>(100);
let rx_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
// 5. 使用 unfold (从异步状态生成)
let s = stream::unfold(0u32, |state| async move {
if state >= 5 {
None // 流结束
} else {
let next = state + 1;
Some((state, next)) // 产出 `state`,新状态为 `next`
}
});
}
消费流 (Streams)
#![allow(unused)]
fn main() {
use futures::stream::{self, StreamExt};
async fn stream_examples() {
let s = stream::iter(vec![1, 2, 3, 4, 5]);
// for_each —— 处理每个条目
s.for_each(|x| async move {
println!("{x}");
}).await;
// map + collect
let doubled: Vec<i32> = stream::iter(vec![1, 2, 3])
.map(|x| x * 2)
.collect()
.await;
// filter
let evens: Vec<i32> = stream::iter(1..=10)
.filter(|x| futures::future::ready(x % 2 == 0))
.collect()
.await;
// buffer_unordered —— 并发处理 N 个条目
let results: Vec<_> = stream::iter(vec!["url1", "url2", "url3"])
.map(|url| async move {
// 模拟 HTTP 获取
tokio::time::sleep(Duration::from_millis(100)).await;
format!("来自 {url} 的响应")
})
.buffer_unordered(10) // 最多同时进行 10 个获取操作
.collect()
.await;
// take, skip, zip, chain —— 与 Iterator 的用法完全一致
let first_three: Vec<i32> = stream::iter(1..=100)
.take(3)
.collect()
.await;
}
}
与 C# IAsyncEnumerable 的对比
| 特性 | Rust Stream | C# IAsyncEnumerable<T> |
|---|---|---|
| 语法 | stream! { yield x; } | await foreach / yield return |
| 取消 | 丢弃该流即可 | 使用 CancellationToken |
| 背压 (Backpressure) | 消费者控制轮询速率 | 消费者控制 MoveNextAsync |
| 是否内置 | 否(需 futures 包) | 是(自 C# 8.0 起内置) |
| 组合器 | .map(), .filter(), .buffer_unordered() | LINQ + System.Linq.Async |
| 错误处理 | Stream<Item = Result<T, E>> | 在异步迭代器中抛出异常 |
#![allow(unused)]
fn main() {
// Rust:数据库行结果流
// 注意:如果在循环内使用 `?`,需要使用 try_stream! 而非 stream!。
// stream! 不会自动传播错误 —— try_stream! 则会产出 Err(e) 并结束。
fn get_users(db: &Database) -> impl Stream<Item = Result<User, DbError>> + '_ {
try_stream! {
let mut cursor = db.query("SELECT * FROM users").await?;
while let Some(row) = cursor.next().await {
yield User::from_row(row?);
}
}
}
// 消费流:
let mut users = pin!(get_users(&db));
while let Some(result) = users.next().await {
match result {
Ok(user) => println!("{}", user.name),
Err(e) => eprintln!("错误: {e}"),
}
}
}
// C# 等效代码:
async IAsyncEnumerable<User> GetUsers() {
await using var reader = await db.QueryAsync("SELECT * FROM users");
while (await reader.ReadAsync()) {
yield return User.FromRow(reader);
}
}
// 消费:
await foreach (var user in GetUsers()) {
Console.WriteLine(user.Name);
}
🏋️ 实践任务:构建一个异步统计聚合器 (点击展开)
挑战:给定一个传感器读数流 Stream<Item = f64>,编写一个异步函数消费该流并返回 (数量, 最小值, 最大值, 平均值)。请使用 StreamExt 组合器实现 —— 不要简单地将其全部 collect 到一个 Vec 中。
提示:使用 .fold() 在流的处理过程中不断累加状态。
🔑 参考方案
#![allow(unused)]
fn main() {
use futures::stream::{self, StreamExt};
#[derive(Debug)]
struct Stats {
count: usize,
min: f64,
max: f64,
sum: f64,
}
impl Stats {
fn average(&self) -> f64 {
if self.count == 0 { 0.0 } else { self.sum / self.count as f64 }
}
}
async fn compute_stats<S: futures::Stream<Item = f64> + Unpin>(stream: S) -> Stats {
stream
.fold(
Stats { count: 0, min: f64::INFINITY, max: f64::NEG_INFINITY, sum: 0.0 },
|mut acc, value| async move {
acc.count += 1;
acc.min = acc.min.min(value);
acc.max = acc.max.max(value);
acc.sum += value;
acc
},
)
.await
}
#[tokio::test]
async fn test_stats() {
let readings = stream::iter(vec![23.5, 24.1, 22.8, 25.0, 23.9]);
let stats = compute_stats(readings).await;
assert_eq!(stats.count, 5);
assert!((stats.min - 22.8).abs() < f64::EPSILON);
assert!((stats.max - 25.0).abs() < f64::EPSILON);
assert!((stats.average() - 23.86).abs() < 0.01);
}
}
核心总结:类似 .fold() 的流组合器会逐个处理条目而不需要将其全部载入内存 —— 这对于处理超大规模或无限的数据流至关重要。
异步 I/O Trait:AsyncRead, AsyncWrite, AsyncBufRead
正如 std::io::Read/Write 是同步 I/O 的基石,其对应的异步版本则是异步 I/O 的核心。这些 trait 由 tokio::io 提供(或在运行时无关代码中使用 futures::io):
#![allow(unused)]
fn main() {
// tokio::io —— std::io trait 的异步版本
/// 异步从源读取字节
pub trait AsyncRead {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>, // Tokio 提供的处理未初始化内存的安全包装
) -> Poll<io::Result<()>>;
}
/// 异步将字节写入目的地
pub trait AsyncWrite {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>>;
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>>;
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>>;
}
/// 具备行处理支持的缓冲读取
pub trait AsyncBufRead: AsyncRead {
fn poll_fill_buf(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<&[u8]>>;
fn consume(self: Pin<&mut Self>, amt: usize);
}
}
在实践中,你很少需要直接调用这些 poll_* 方法。相反,你应该使用对应的扩展 trait:AsyncReadExt、AsyncWriteExt 以及 AsyncBufReadExt,它们提供了支持 .await 的便捷方法:
#![allow(unused)]
fn main() {
use tokio::io::{AsyncReadExt, AsyncWriteExt, AsyncBufReadExt};
use tokio::net::TcpStream;
use tokio::io::BufReader;
async fn io_examples() -> tokio::io::Result<()> {
let mut stream = TcpStream::connect("127.0.0.1:8080").await?;
// AsyncWriteExt: write_all, write_u32, write_buf 等
stream.write_all(b"GET / HTTP/1.0\r\n\r\n").await?;
// AsyncReadExt: read, read_exact, read_to_end, read_to_string
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
// AsyncBufReadExt: read_line, lines(), split()
let file = tokio::fs::File::open("config.txt").await?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await? {
println!("{line}");
}
Ok(())
}
}
实现自定义异步 I/O —— 在原生 TCP 之上封装协议:
#![allow(unused)]
fn main() {
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use std::pin::Pin;
use std::task::{Context, Poll};
/// 长度前缀协议:[u32 长度][内容字节]
struct FramedStream<T> {
inner: T,
}
impl<T: AsyncRead + AsyncReadExt + Unpin> FramedStream<T> {
/// 读取一个完整的帧
async fn read_frame(&mut self) -> tokio::io::Result<Vec<u8>>
{
// 读取 4 字节的长度前缀
let len = self.inner.read_u32().await? as usize;
// 读取对应长度的字节
let mut payload = vec![0u8; len];
self.inner.read_exact(&mut payload).await?;
Ok(payload)
}
}
impl<T: AsyncWrite + AsyncWriteExt + Unpin> FramedStream<T> {
/// 写入一个完整的帧
async fn write_frame(&mut self, data: &[u8]) -> tokio::io::Result<()>
{
self.inner.write_u32(data.len() as u32).await?;
self.inner.write_all(data).await?;
self.inner.flush().await?;
Ok(())
}
}
}
| 同步 Trait | 异步 Trait (tokio) | 异步 Trait (futures/agnostic) | 扩展 Trait |
|---|---|---|---|
std::io::Read | tokio::io::AsyncRead | futures::io::AsyncRead | AsyncReadExt |
std::io::Write | tokio::io::AsyncWrite | futures::io::AsyncWrite | AsyncWriteExt |
std::io::BufRead | tokio::io::AsyncBufRead | futures::io::AsyncBufRead | AsyncBufReadExt |
std::io::Seek | tokio::io::AsyncSeek | futures::io::AsyncSeek | AsyncSeekExt |
tokio vs futures I/O trait: 两者非常相似但不完全一致 —— tokio 的
AsyncRead使用ReadBuf(能更安全地处理未初始化内存),而futures::AsyncRead使用&mut [u8]。可以使用tokio_util::compat在两者之间进行转换。
拷贝工具函数:
tokio::io::copy(&mut reader, &mut writer)是std::io::copy的异步版本 —— 在编写代理服务器或文件传输代码时非常有用。tokio::io::copy_bidirectional则可以同时在两个方向上进行并发拷贝。
🏋️ 实践任务:构建一个异步行计数器 (点击展开)
挑战:编写一个异步函数,接收任意 AsyncBufRead 数据源并返回非空行的数量。它应当能兼容文件、TCP 流或任何缓冲读取器。
提示:使用 AsyncBufReadExt::lines() 并过滤掉 line.is_empty() 的行。
🔑 参考方案
#![allow(unused)]
fn main() {
use tokio::io::AsyncBufReadExt;
async fn count_non_empty_lines<R: tokio::io::AsyncBufRead + Unpin>(
reader: R,
) -> tokio::io::Result<usize> {
let mut lines = reader.lines();
let mut count = 0;
while let Some(line) = lines.next_line().await? {
if !line.is_empty() {
count += 1;
}
}
Ok(count)
}
// 兼容所有 AsyncBufRead:
// let file = tokio::io::BufReader::new(tokio::fs::File::open("data.txt").await?);
// let count = count_non_empty_lines(file).await?;
//
// let tcp = tokio::io::BufReader::new(TcpStream::connect("...").await?);
// let count = count_non_empty_lines(tcp).await?;
}
核心总结:通过针对 AsyncBufRead trait 而非具体类型编程,你的 I/O 代码可以在文件、套接字、管道甚至内存缓冲区(std::io::Cursor)中复用。
关键要诀 —— 流 (Streams) 与 AsyncIterator
Stream是Iterator的异步等价形式 —— 产出Poll::Ready(Some(item))或Poll::Ready(None)。.buffer_unordered(N)是处理并发流的关键工具,它能并发处理 N 个条目。async_stream::stream!是创建自定义流最简单的方式(使用yield语法)。AsyncRead/AsyncBufRead使得 I/O 代码在文件、套接字与管道之间通用且可复用。
另请参阅: 第 9 章 —— 当 Tokio 不适用时 了解
FuturesUnordered(相关模式),第 13 章 —— 生产模式 了解如何通过有界通道处理背压。
12. 常见陷阱 🔴
你将学到:
- 9 种常见的异步 Rust Bug 及其修复方案
- 为什么“阻塞执行器”是头号错误(以及
spawn_blocking如何修复它)- 取消(Cancellation)带来的隐患:Future 在 await 中点被丢弃时会发生什么
- 调试利器:
tokio-console、tracing、#[instrument]- 测试技巧:
#[tokio::test]、time::pause()、基于 Trait 的 Mock 模拟
阻塞执行器 (Blocking the Executor)
异步 Rust 中的头号错误:在异步执行器线程上运行阻塞代码。这会导致其他任务被“饿死”。
#![allow(unused)]
fn main() {
// ❌ 错误做法:阻塞了整个执行器线程
async fn bad_handler() -> String {
let data = std::fs::read_to_string("big_file.txt").unwrap(); // 阻塞!
process(&data)
}
// ✅ 正确做法:将阻塞工作转移到专门的线程池
async fn good_handler() -> String {
let data = tokio::task::spawn_blocking(|| {
std::fs::read_to_string("big_file.txt").unwrap()
}).await.unwrap();
process(&data)
}
// ✅ 同样正确:使用 tokio 的异步文件 I/O
async fn also_good_handler() -> String {
let data = tokio::fs::read_to_string("big_file.txt").await.unwrap();
process(&data)
}
}
graph TB
subgraph "❌ 在执行器上进行阻塞调用"
T1_BAD["线程 1:std::fs::read()<br/>🔴 阻塞 500ms"]
T2_BAD["线程 2:处理请求<br/>🟢 孤军奋战"]
TASKS_BAD["100 个待处理任务<br/>⏳ 饿死中"]
T1_BAD -->|"无法轮询"| TASKS_BAD
end
subgraph "✅ 使用 spawn_blocking"
T1_GOOD["线程 1:轮询 Future<br/>🟢 可用"]
T2_GOOD["线程 2:轮询 Future<br/>🟢 可用"]
BT["阻塞池线程:<br/>std::fs::read()<br/>🔵 独立线程池"]
TASKS_GOOD["100 个任务<br/>✅ 都在稳步推进"]
T1_GOOD -->|"轮询"| TASKS_GOOD
T2_GOOD -->|"轮询"| TASKS_GOOD
end
std::thread::sleep vs tokio::time::sleep
#![allow(unused)]
fn main() {
// ❌ 错误做法:阻塞执行器线程 5 秒钟
async fn bad_delay() {
std::thread::sleep(Duration::from_secs(5)); // 线程无法轮询其他任何任务!
}
// ✅ 正确做法:让出执行权,其他任务可以继续运行
async fn good_delay() {
tokio::time::sleep(Duration::from_secs(5)).await; // 非阻塞!
}
}
跨 .await 持有 MutexGuard
#![allow(unused)]
fn main() {
use std::sync::Mutex; // std Mutex —— 非异步感知的
// ⚠️ 危险:跨 .await 持有 MutexGuard
async fn bad_mutex(data: &Mutex<Vec<String>>) {
let mut guard = data.lock().unwrap();
guard.push("条目".into());
some_io().await; // 锁在此处被持有 —— 阻止了其他线程获取锁!
guard.push("另一个".into());
}
// 注意:即便如此,这段代码仍能编译通过!std::sync::MutexGuard 虽然是 !Send 的,
// 但编译器只在当你将 Future 传递给要求满足 Send 约束的函数时(如 tokio::spawn)
// 才会进行强制检查。直接调用 bad_mutex(...).await 是没问题的。
// 然而,tokio::spawn(bad_mutex(data)) 将会报 Send 约束错误。
}
为什么这通常是个问题 —— 虽然并不绝对:
跨 .await 持有 std::sync::Mutex 会在 I/O 期间阻塞 操作系统线程,防止执行器在该线程上轮询其他任务。对于简短的临界区,这很浪费;对于长耗时的 I/O,这就是性能陷阱。
然而,有时你 必须 跨 .await 持有锁 —— 就像数据库事务在读取和提交之间必须持锁一样。单纯地丢弃并重新获取锁会引入 TOCTOU(检查时间到使用时间)竞态条件:另一个任务可能会在你的两个临界区之间修改数据。正确的修复方案取决于具体用例:
#![allow(unused)]
fn main() {
// 选项 1:收窄锁的作用域 —— 适用于操作相互独立的情况
async fn scoped_mutex(data: &Mutex<Vec<String>>) {
{
let mut guard = data.lock().unwrap();
guard.push("条目".into());
} // 锁在此处释放
some_io().await; // 锁已释放 —— 其他任务可以推进
{
let mut guard = data.lock().unwrap();
guard.push("另一个".into());
}
}
// ⚠️ 注意:另一个任务可能在两个段落之间锁定并修改 Vec。
// 如果两次 push 操作是独立的,这没问题;但如果“另一个”依赖于“条目”设置的状态,则会有问题。
// 选项 2:使用 tokio::sync::Mutex —— 跨 .await 持锁且不阻塞操作系统线程。
// 当你需要在跨 await 点时进行事务性的“读取-修改-写入”操作时,这是最佳选择。
use tokio::sync::Mutex as AsyncMutex;
async fn async_mutex(data: &AsyncMutex<Vec<String>>) {
let mut guard = data.lock().await; // 异步锁 —— 不阻塞线程
guard.push("条目".into());
some_io().await; // 没问题 —— tokio Mutex 的 guard 是满足 Send 约束的
guard.push("另一个".into());
// 锁在全程被持有 —— 无 TOCTOU 竞态,无线程阻塞。
}
}
Mutex 选型指南:
std::sync::Mutex:内部不含.await的简短临界区。tokio::sync::Mutex:需要跨越.await持有锁(如事务语义、避免 TOCTOU 竞态)。parking_lot::Mutex:std 的高性能替代品,更小更快,同样不建议跨.await使用。经验法则:不要为了避开
.await而盲目切割临界区。请思考这两个段落是否真的相互独立。如果第二部分依赖于第一部分的状态,请使用tokio::sync::Mutex或重新设计数据流。
取消隐患 (Cancellation Hazards)
丢弃一个 Future 意味着将其取消 —— 但这可能导致状态不一致:
#![allow(unused)]
fn main() {
// ❌ 危险:取消可能导致资源泄漏
async fn transfer(from: &Account, to: &Account, amount: u64) {
from.debit(amount).await; // 如果在此处被取消...
to.credit(amount).await; // ...钱就凭空消失了!
}
// ✅ 安全:使操作具备原子性或使用补偿机制
async fn safe_transfer(from: &Account, to: &Account, amount: u64) -> Result<(), Error> {
// 使用数据库事务(要么全部成功,要么全部失败)
let tx = db.begin_transaction().await?;
tx.debit(from, amount).await?;
tx.credit(to, amount).await?;
tx.commit().await?; // 只有所有工作都成功才会提交
Ok(())
}
// ✅ 同样安全:利用 tokio::select! 及其取消意识
tokio::select! {
result = transfer(from, to, amount) => {
// 转账完成
}
_ = shutdown_signal() => {
// 不要中途取消转账 —— 哪怕关机也让它跑完
// 或者:显式进行回滚
}
}
}
无异步 Drop (No Async Drop)
Rust 的 Drop trait 是同步的 —— 你 不能 在 drop() 内部使用 .await。这是新手的常见痛点:
#![allow(unused)]
fn main() {
struct DbConnection { /* ... */ }
impl Drop for DbConnection {
fn drop(&mut self) {
// ❌ 无法执行 —— drop() 是同步的!
// self.connection.shutdown().await;
// ✅ 方案 1:派生一个清理任务(发完即忘模式)
let conn = self.connection.take();
tokio::spawn(async move {
let _ = conn.shutdown().await;
});
// ✅ 方案 2:使用同步关闭方法
// self.connection.blocking_close();
}
}
}
最佳实践:提供一个显式的 async fn close(self) 方法,并指导调用者优先使用它。仅将 Drop 作为最后的安全防护网,而不是主要的清理路径。
select! 的公平性与饥饿问题
#![allow(unused)]
fn main() {
use tokio::sync::mpsc;
// ❌ 不公平:fast 总是胜出,slow 会被渴死
async fn unfair(mut fast: mpsc::Receiver<i32>, mut slow: mpsc::Receiver<i32>) {
loop {
tokio::select! {
Some(v) = fast.recv() => println!("来自快车道: {v}"),
Some(v) = slow.recv() => println!("来自慢车道: {v}"),
// 如果两者都就绪,tokio 会随机选一个。
// 但如果 fast 始终有数据,slow 被轮询到的概率极低。
}
}
}
// ✅ 公平:使用偏向性模式(biased)或按批次处理
async fn fair(mut fast: mpsc::Receiver<i32>, mut slow: mpsc::Receiver<i32>) {
loop {
tokio::select! {
biased; // 总是按顺序检查 —— 设置显式优先级
Some(v) = slow.recv() => println!("来自慢车道: {v}"), // 优先级!
Some(v) = fast.recv() => println!("来自快车道: {v}"),
}
}
}
}
意外的顺序执行
#![allow(unused)]
fn main() {
// ❌ 顺序执行:总耗时 2 秒
async fn slow() {
let a = fetch("url_a").await; // 耗时 1 秒
let b = fetch("url_b").await; // 又耗时 1 秒(必须等 a 完事!)
}
// ✅ 并发执行:总耗时 1 秒
async fn fast() {
let (a, b) = tokio::join!(
fetch("url_a"), // 两者立即开始
fetch("url_b"),
);
}
// ✅ 同样是并发:预先创建 Future
async fn also_fast() {
let fut_a = fetch("url_a"); // 创建 Future(惰性,尚未开始)
let fut_b = fetch("url_b"); // 创建 Future
let (a, b) = tokio::join!(fut_a, fut_b); // 现在两者并发运行
}
}
陷阱:
let a = fetch(url).await; let b = fetch(url).await;是顺序执行的!第二个.await在第一个完成前根本不会启动。追求并发请使用join!或spawn。
案例分析:调试一个挂起的生产服务
真实场景:一个服务运行 10 分钟后表现良好,随后停止响应。日志中无错误。CPU 占用率为 0%。
诊断步骤:
- 接入
tokio-console—— 发现 200 多个任务卡在Pending状态。 - 查看任务详情 —— 全都在等待同一个
Mutex::lock().await。 - 根因分析 —— 某个任务在持有一个
std::sync::MutexGuard期间发生了.await并随后 panic,导致 mutex 被毒化(poisoned)。所有其他任务现在在调用lock().unwrap()时全部失败。
修复:
| 修复前 (受损) | 修复后 (完好) |
|---|---|
std::sync::Mutex | tokio::sync::Mutex |
跨 .await 调用 .lock().unwrap() | 在 .await 前局部化锁的作用域 |
| 获取锁时无超时机制 | tokio::time::timeout(dur, mutex.lock()) |
| 无法从毒化锁中恢复 | tokio::sync::Mutex 不会发生毒化 |
防范核查清单:
- 如果锁引用跨越了
.await,请使用tokio::sync::Mutex。 - 为异步函数添加
#[tracing::instrument]以进行跨度追踪(span tracking)。 - 在预发布环境运行
tokio-console以尽早发现挂起的任务。 - 添加健康检查端点,定期核查任务的响应能力。
🏋️ 实践任务:找出 Bug 所在 (点击展开)
挑战:找出这段代码中所有的异步陷阱并修复它们。
#![allow(unused)]
fn main() {
use std::sync::Mutex;
async fn process_requests(urls: Vec<String>) -> Vec<String> {
let results = Mutex::new(Vec::new());
for url in &urls {
let response = reqwest::get(url).await.unwrap().text().await.unwrap();
std::thread::sleep(std::time::Duration::from_millis(100)); // 速率限制
let mut guard = results.lock().unwrap();
guard.push(response);
expensive_parse(&guard).await; // 解析目前所有的结果
}
results.into_inner().unwrap()
}
}
🔑 参考方案
发现的 Bug:
- 顺序获取 —— URL 是一条接一条获取的,没有并发。
- 使用了
std::thread::sleep—— 阻塞了执行器线程。 - 跨
.await持有 MutexGuard —— 在 awaitexpensive_parse时guard依然存活。 - 缺乏并发设计 —— 应使用
join!或FuturesUnordered。
修复方案:
#![allow(unused)]
fn main() {
use tokio::sync::Mutex;
use std::sync::Arc;
use futures::stream::{self, StreamExt};
async fn process_requests(urls: Vec<String>) -> Vec<String> {
// 修复 4:使用 buffer_unordered 并发处理 URL
let results: Vec<String> = stream::iter(urls)
.map(|url| async move {
let response = reqwest::get(&url).await.unwrap().text().await.unwrap();
// 修复 2:使用 tokio::time::sleep 代替 std::thread::sleep
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
response
})
.buffer_unordered(10) // 最多 10 个并发请求
.collect()
.await;
// 修复 3:收集完再解析 —— 完全不需要 mutex 了!
for result in &results {
expensive_parse(result).await;
}
results
}
}
核心总结:通常你可以通过重构异步代码来完全消除对 Mutex 的需求。利用流(Stream)或 join 来汇总结果,然后再统一处理。这样更简单、更快,且无死锁风险。
调试异步代码
异步堆栈追踪(Stack traces)因其难懂而闻名 —— 它们展现的是执行器的轮询循环,而非你的逻辑调用链。以下是必备的调试工具。
tokio-console:实时任务检查器
tokio-console 提供了类似 htop 的界面,展示每个被派生任务的状态、轮询时长、waker 活动以及资源占用。
# Cargo.toml
[dependencies]
console-subscriber = "0.4"
tokio = { version = "1", features = ["full", "tracing"] }
#[tokio::main]
async fn main() {
console_subscriber::init(); // 替换默认的 tracing 订阅器
// ... 应用其余部分
}
随后在另一个终端运行:
$ RUSTFLAGS="--cfg tokio_unstable" cargo run # 必需的编译标志
$ tokio-console # 连接至 127.0.0.1:6669
tracing + #[instrument]:异步结构化日志
tracing 包能理解 Future 的全生命周期。Span 会跨越 .await 点保持开启,即使操作系统线程已经切换,你依然能获得逻辑上的调用栈信息:
#![allow(unused)]
fn main() {
use tracing::{info, instrument};
#[instrument(skip(db_pool), fields(user_id = %user_id))]
async fn handle_request(user_id: u64, db_pool: &Pool) -> Result<Response> {
info!("查询用户中");
let user = db_pool.get_user(user_id).await?; // Span 在跨越 await 时保持有效
info!(email = %user.email, "找到用户");
let orders = fetch_orders(user_id).await?; // 依然在同一个 Span 内
Ok(build_response(user, orders))
}
}
调试核查清单
| 症状 | 可能原因 | 推荐工具 |
|---|---|---|
| 任务永久挂起 | 漏掉了 .await 或发生了 Mutex 死锁 | tokio-console 任务视图 |
| 吞吐量极低 | 在异步线程上进行了阻塞调用 | tokio-console 轮询耗时直方图 |
Future is not Send | 跨 .await 持有了非 Send 类型 | 编译器报错 + #[instrument] 定位 |
| 神秘的任务取消现象 | 父级 select! 丢弃了某个分支 | tracing Span 生命周期事件 |
测试异步代码
异步代码由于涉及到运行时、时间控制及并发行为,其测试具有独特挑战。
基础异步测试 使用 #[tokio::test]:
#![allow(unused)]
fn main() {
// Cargo.toml
// [dev-dependencies]
// tokio = { version = "1", features = ["full", "test-util"] }
#[tokio::test]
async fn test_basic_async() {
let result = fetch_data().await;
assert_eq!(result, "预期值");
}
}
时间操控 —— 无需真实等待即可测试超时:
#![allow(unused)]
fn main() {
use tokio::time::{self, Duration, Instant};
#[tokio::test]
async fn test_timeout_behavior() {
// 暂停时间 —— sleep() 会立即推进,无真实物理时间延迟
time::pause();
let start = Instant::now();
time::sleep(Duration::from_secs(3600)).await; // 模拟“运行”了 1 小时,实际耗时 0ms
assert!(start.elapsed() >= Duration::from_secs(3600));
}
}
模拟异步依赖 —— 使用 Trait 代码或泛型:
#![allow(unused)]
fn main() {
trait Storage {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: String);
}
// 编写针对 Storage 泛型的功能函数...
async fn cache_lookup<S: Storage>(store: &S, key: &str) -> String { ... }
#[tokio::test]
async fn test_cache_logic() {
let mock = MockStorage::new(); // 实现 Storage 的内存 mock
let val = cache_lookup(&mock, "key1").await;
assert_eq!(val, "预期数据");
}
}
关键要诀 —— 常见陷阱
- 绝不要阻塞执行器 —— 对计算/同步阻塞操作使用
spawn_blocking。- 绝不要跨 .await 持有 MutexGuard —— 应该收紧锁作用域或使用
tokio::sync::Mutex。- 取消会立即丢弃 Future —— 对部分操作使用“取消安全”的模式或事务。
- 使用 tokio-console 和 tracing 辅助调试。
- 使用 #[tokio::test] 和 time::pause() 实现确定性的时序测试。
另请参阅: 第 8 章 —— Tokio 深度探索 了解同步原语,第 13 章 —— 生产模式 了解优雅停机与结构化并发。
13. 生产模式 🔴
你将学到:
- 使用
watch通道与select!实现优雅停机(Graceful shutdown)- 背压(Backpressure):有界通道防止内存溢出(OOM)
- 结构化并发:
JoinSet与TaskTracker- 超时、重试与指数退避算法
- 错误处理:
thiserrorvsanyhow,以及双重?模式- Tower:axum、tonic 与 hyper 使用的中间件模式
优雅停机 (Graceful Shutdown)
生产级服务器必须能够干净地关闭 —— 完成正在进行的请求、冲刷缓冲区、关闭连接:
use tokio::signal;
use tokio::sync::watch;
async fn main_server() {
// 创建一个停机信号通道
let (shutdown_tx, shutdown_rx) = watch::channel(false);
// 派生服务器任务
let server_handle = tokio::spawn(run_server(shutdown_rx.clone()));
// 等待 Ctrl+C 信号
signal::ctrl_c().await.expect("监听 Ctrl+C 失败");
println!("接收到停机信号,正在完成剩余请求...");
// 通知所有任务进行停机
shutdown_tx.send(true).unwrap();
// 等待服务器完成(设置超时时间)
match tokio::time::timeout(
std::time::Duration::from_secs(30),
server_handle,
).await {
Ok(Ok(())) => println!("服务器已优雅停机"),
Ok(Err(e)) => eprintln!("服务器错误: {e}"),
Err(_) => eprintln!("服务器停机超时 —— 强制退出"),
}
}
async fn run_server(mut shutdown: watch::Receiver<bool>) {
loop {
tokio::select! {
// 接收新连接
conn = accept_connection() => {
let shutdown = shutdown.clone();
tokio::spawn(handle_connection(conn, shutdown));
}
// 停机信号
_ = shutdown.changed() => {
if *shutdown.borrow() {
println!("停止接收新连接");
break;
}
}
}
}
// 正在处理的连接将会自行完成
// 因为它们持有各自的 shutdown_rx 克隆
}
async fn handle_connection(conn: Connection, mut shutdown: watch::Receiver<bool>) {
loop {
tokio::select! {
request = conn.next_request() => {
// 完整处理请求 —— 不要中途抛弃
process_request(request).await;
}
_ = shutdown.changed() => {
if *shutdown.borrow() {
// 完成当前请求后退出
break;
}
}
}
}
}
sequenceDiagram
participant OS as 操作系统信号
participant Main as 主任务
participant WCH as watch 通道
participant W1 as 工作单元 1
participant W2 as 工作单元 2
OS->>Main: SIGINT (Ctrl+C)
Main->>WCH: send(true)
WCH-->>W1: 触发 changed()
WCH-->>W2: 触发 changed()
Note over W1: 完成当前请求
Note over W2: 完成当前请求
W1-->>Main: 任务完成
W2-->>Main: 任务完成
Main->>Main: 所有工作单元就绪 → 退出
有界通道提供的背压 (Backpressure)
如果生产者的速度快于消费者,无界通道会导致内存溢出(OOM)。在生产环境中请务必使用有界通道:
#![allow(unused)]
fn main() {
use tokio::sync::mpsc;
async fn backpressure_example() {
// 有界通道:最大缓冲 100 个条目
let (tx, mut rx) = mpsc::channel::<WorkItem>(100);
// 生产者:当缓冲区满时会自动减速
let producer = tokio::spawn(async move {
for i in 0..1_000_000 {
// send() 是异步的 —— 如果缓冲区满,它会等待
// 这自然地产生了“背压”!
tx.send(WorkItem { id: i }).await.unwrap();
}
});
// 消费者:按自己的节奏处理条目
let consumer = tokio::spawn(async move {
while let Some(item) = rx.recv().await {
process(item).await; // 处理慢一点也没关系 —— 生产者会等待
}
});
let _ = tokio::join!(producer, consumer);
}
// 对比无界通道 —— 这是危险的:
// let (tx, rx) = mpsc::unbounded_channel(); // 无背压!
// 生产者可能会无限填满内存
}
结构化并发:JoinSet 与 TaskTracker
JoinSet 用于将相关的任务分组,并确保它们全部完成:
#![allow(unused)]
fn main() {
use tokio::task::JoinSet;
async fn structured_concurrency() {
let mut set = JoinSet::new();
// 派生一批任务
for url in get_urls() {
set.spawn(async move {
fetch_and_process(url).await
});
}
// 收集所有结果(顺序不保证)
let mut results = Vec::new();
while let Some(result) = set.join_next().await {
match result {
Ok(Ok(data)) => results.push(data),
Ok(Err(e)) => eprintln!("任务错误: {e}"),
Err(e) => eprintln!("任务发生 panic: {e}"),
}
}
// 到这里所有任务都已完成 —— 不会有残留的后台工作
println!("已处理 {} 个条目", results.len());
}
}
超时、重试与指数退避
#![allow(unused)]
fn main() {
use tokio::time::{timeout, sleep, Duration};
// 指数退避重试
async fn retry_with_backoff<F, Fut, T, E>(
max_attempts: u32,
base_delay_ms: u64,
operation: F,
) -> Result<T, E>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
E: std::fmt::Display,
{
let mut delay = Duration::from_millis(base_delay_ms);
for attempt in 1..=max_attempts {
match operation().await {
Ok(result) => return Ok(result),
Err(e) => {
if attempt == max_attempts {
return Err(e);
}
sleep(delay).await;
delay *= 2; // 指数倍增延迟
}
}
}
unreachable!()
}
}
生产提示 —— 加入抖动 (Jitter):上面的函数是纯粹的指数退避,但在生产环境中,如果大量客户端同时失败并重试,会导致“惊群效应(thundering herd)”。请加入随机的 抖动 —— 比如
sleep(delay + rand_jitter),让重试请求在时间上分散开。
异步代码中的错误处理
异步引入了独特的错误传播挑战 —— 派生任务创建了错误边界,超时错误会包装内部错误,当 Future 跨越任务边界时,? 操作符会有不同的交互。
thiserror vs anyhow —— 选对工具:
#![allow(unused)]
fn main() {
// thiserror:为类库和公共 API 定义强类型错误
// 每个变体都是显式的 —— 调用者可以对特定错误进行 match
use thiserror::Error;
#[derive(Error, Debug)]
enum DiagError {
#[error("传感器 {sensor} 超出范围: {value}°C")]
OverTemp { sensor: String, value: f64 },
#[error("操作在 {0:?} 后超时")]
Timeout(std::time::Duration),
}
// anyhow:为应用程序和原型提供快速错误处理
// 包装任意错误 —— 无需为每种情况定义类型
use anyhow::{Context, Result};
async fn run_diagnostics() -> Result<()> {
let _ = load_config()
.await
.context("加载配置失败")?; // 增加上下文信息信息信息
Ok(())
}
}
| Crate | 适用场景 | 错误类型 | 是否支持匹配 |
|---|---|---|---|
thiserror | 类库代码、公共 API | enum MyError | 支持 match err |
anyhow | 应用程序、CLI 工具、脚本 | anyhow::Error | 需使用 downcast |
双重 ? 模式:
#![allow(unused)]
fn main() {
async fn spawn_with_errors() -> Result<String, AppError> {
let handle = tokio::spawn(async {
let resp = reqwest::get("https://example.com").await?;
Ok::<_, reqwest::Error>(resp.text().await?)
});
// 双重 ?: 第一个 ? 解出 JoinError (任务 panic),第二个 ? 解出内部的 Result
let result = handle.await??;
Ok(result)
}
}
Tower:中间件模式
Tower 定义了一个可组合的 Service trait —— 它是 Rust 异步中间件的骨架(被 axum, tonic, hyper 所采用):
#![allow(unused)]
fn main() {
// Tower 核心 trait (简化版):
pub trait Service<Request> {
type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
fn call(&mut self, req: Request) -> Self::Future;
}
}
中间件通过包装一个 Service 来添加跨切面行为(日志、超时、限流),而无需修改内部逻辑:
#![allow(unused)]
fn main() {
let service = ServiceBuilder::new()
.layer(TimeoutLayer::new(Duration::from_secs(10))) // 最外层:超时
.layer(RateLimitLayer::new(100, Duration::from_secs(1))) // 随后:限流
.service(my_handler); // 最内层:你的业务逻辑
}
🏋️ 实践任务:带工作池的优雅停机 (点击展开)
挑战:构建一个基于通道的工作队列,包含 N 个工作任务,并在按下 Ctrl+C 时实现优雅停机。工作任务应在退出前完成当前正在处理的工作。
🔑 参考方案
use tokio::sync::{mpsc, watch};
use tokio::time::{sleep, Duration};
struct WorkItem { id: u64 }
#[tokio::main]
async fn main() {
let (work_tx, work_rx) = mpsc::channel::<WorkItem>(100);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let work_rx = std::sync::Arc::new(tokio::sync::Mutex::new(work_rx));
let mut handles = Vec::new();
for id in 0..4 {
let rx = work_rx.clone();
let mut shutdown = shutdown_rx.clone();
handles.push(tokio::spawn(async move {
loop {
let item = {
let mut rx = rx.lock().await;
tokio::select! {
item = rx.recv() => item,
_ = shutdown.changed() => {
if *shutdown.borrow() { None } else { continue }
}
}
};
match item {
Some(work) => {
println!("工作单元 {id}: 正在处理 {}", work.id);
sleep(Duration::from_millis(200)).await;
}
None => break,
}
}
}));
}
// 提交一些任务
for i in 0..20 {
let _ = work_tx.send(WorkItem { id: i }).await;
}
// 处理停机
tokio::signal::ctrl_c().await.unwrap();
shutdown_tx.send(true).unwrap();
for h in handles { let _ = h.await; }
println!("已干净地停机。");
}
关键要诀 —— 生产模式
- 使用
watch通道 +select!实现多组件协调的优雅停机。- 有界通道 (
mpsc::channel(N)) 提供了 背压 机制 —— 当缓冲区满时发送者会挂起等待。JoinSet与TaskTracker提供 结构化并发:以便追踪、中止和等待任务组。- 为所有网络操作添加超时处理 ——
tokio::time::timeout(dur, fut)。- Tower 的
Servicetrait 是 Rust 异步中间件事实上的标准。
另请参阅: 第 8 章 —— Tokio 深度探索 了解通道与同步原语,第 12 章 —— 常见陷阱 了解停机过程中的取消陷阱。
14. 异步是手段而非目的 🔴
你将学到:
- 为什么异步倾向于污染整个代码库 —— 以及为什么这其实是设计上的失策,而非特性
- “同步核心,异步外壳”模式:让绝大部分代码保持可测试与易调试性
- 如何处理棘手情况:那些 同时 需要进行 I/O 的逻辑
spawn_blocking究竟是救急良药还是架构缺陷的征兆- 什么时候异步才真正属于你的核心逻辑
- 为什么“同步优先”的库比“异步优先”的库更具组合性
你已经补完了 13 个章节来学习异步 Rust。现在,我要告诉你全书最重要的一点:你的大部分代码都不应该是异步的。
函数着色问题 (The Function Coloring Problem)
Bob Nystrom 的名篇 “你的函数是什么颜色?” 指出了核心矛盾:异步函数可以调用同步函数,但同步函数无法直接调用异步函数。一旦某个函数变成了异步,其调用链上方的所有函数都必须随之改变。
在 Rust 中,这比 C# 或 JavaScript 还要 严重,因为异步不仅会感染函数签名,还会感染类型系统:
| 同步代码 | 异步等效代码 | 差异所在 |
|---|---|---|
fn process(&self) | async fn process(&self) | 调用方也必须是异步的 |
&mut T | Arc<Mutex<T>> | 派生任务需要满足 'static + Send |
std::sync::Mutex | tokio::sync::Mutex | 若跨越 .await 持有,则类型不同 |
impl Trait 返回值 | impl Future<Output = T> + Send | 虽有 RPITIT (1.75+) 简化,但仍带“颜色” |
#[test] | #[tokio::test] | 测试需要运行时环境 |
| 栈追踪:5 帧 | 栈追踪:25 帧 | 其中一半是运行时内部逻辑 |
每一行差异都代表着开发者必须做出的决策和维护成本 —— 而这些都与业务逻辑本身无关。整个行业正在试图 摆脱 这种现状:Java 的 Project Loom (虚拟线程) 和 Go 的 goroutine 都能让你编写同步风格的代码,而运行时在高并发下仍能高效复用线程。Rust 选择显式异步是为了实现零成本控制,但这种控制带来的复杂性成本应当由开发者有意识地去“支付”,而非作为默认设置。
“但是线程很贵”
一个常见的本能反驳是:“我们需要异步,因为线程太贵了。” 在大多数团队所面对的规模下,这个观点基本是错误的。
- 栈内存:每个 OS 线程会在虚拟内存中预留 8MB 空间(Linux 默认),但 OS 只有在线程真正触及时才会分配物理页 —— 一个基本空闲的线程实际仅占用 20-80KB 物理内存。
- 上下文切换:在现代硬件上约为 1-5µs。在 50 个并发请求的规模下,这完全是杂音。只有当每秒发生 10 万次切换时,它才会有明显影响。
- 创建成本:Linux 上每线程约 10-30µs。通过线程池(rayon 或
std::thread::scope)可以将其摊销为零。
异步真正能够抵消其复杂性成本的门槛,大约在 1,000 到 10,000 个并发连接 左右 —— 这是 epoll/io_uring 的核心战场。在此规模之下,用线程池更简单、更易调试且速度足够快。在此规模之上,异步才是赢家。而绝大多数服务都处于这一门槛之下。
案例分析:同步核心,异步外壳
一个简单的纯函数 —— fn add(a: i32, b: i32) —— 显然不需要异步。但有趣的情况是:当业务规则看起来 必须 在中途执行 I/O 时 —— 比如校验逻辑需要检查库存、定价逻辑需要查询汇率、订单流水线需要查询客户信息。
考虑一个订单处理服务。全异步版本看起来很“自然”:
方案 A:全异步核心(Async all the way down)
#![allow(unused)]
fn main() {
// orders.rs —— 异步逻辑贯穿始终
pub async fn process_order(order: Order) -> Result<Receipt, OrderError> {
// 步骤 1: 校验 —— 纯逻辑,无 I/O
validate_items(&order)?;
validate_quantities(&order)?;
// 步骤 2: 检查库存 —— 需要数据库调用
let stock = inventory_client.check(&order.items).await?;
if !stock.all_available() {
return Err(OrderError::OutOfStock(stock.missing()));
}
// 步骤 3: 计算定价 —— 纯数学运算,但因为环境是异步的,所以也是异步环境
let pricing = calculate_pricing(&order, &stock);
// 步骤 4: 应用折扣 —— 需要外部服务调用
let discount = discount_service.lookup(order.customer_id).await?;
let final_price = pricing.apply_discount(discount);
// 步骤 5: 生成收据 —— 纯逻辑
Ok(Receipt::new(order, final_price))
}
}
这是一段 合理 的异步代码。没有滥用 Arc<Mutex>,只有顺序调用。大多数开发者会直接这样写。但请看发生了什么:validate_items、calculate_pricing 和 Receipt::new 这些原本纯粹的函数,仅仅因为步骤 2 和 4 需要 I/O,便全都被卷进了异步语境。整个函数变成了异步,测试需要运行时,调用链上方也全被染色了。
方案 B:同步核心,异步外壳 (Sync Core, Async Shell)
替代方案:将 如何决策 与 如何获取数据 分离开来:
#![allow(unused)]
fn main() {
// core.rs —— 纯业务逻辑,零异步,零 tokio 依赖
pub fn validate_order(order: &Order) -> Result<ValidatedOrder, OrderError> {
validate_items(order)?;
validate_quantities(order)?;
Ok(ValidatedOrder::from(order))
}
pub fn check_stock(
order: &ValidatedOrder,
stock: &StockResult,
) -> Result<StockedOrder, OrderError> {
if !stock.all_available() {
return Err(OrderError::OutOfStock(stock.missing()));
}
Ok(StockedOrder::from(order, stock))
}
pub fn finalize(
order: &StockedOrder,
discount: Discount,
) -> Receipt {
let pricing = calculate_pricing(order);
let final_price = pricing.apply_discount(discount);
Receipt::new(order, final_price)
}
}
#![allow(unused)]
fn main() {
// shell.rs —— 薄薄的异步编排层
use crate::core;
pub async fn process_order(order: Order) -> Result<Receipt, OrderError> {
// 同步:执行校验
let validated = core::validate_order(&order)?;
// 异步:获取库存(这是“外壳”的工作)
let stock = inventory_client.check(&validated.items).await?;
// 同步:将业务规则应用到获取的数据上
let stocked = core::check_stock(&validated, &stock)?;
// 异步:获取折扣
let discount = discount_service.lookup(order.customer_id).await?;
// 同步:完成结算
Ok(core::finalize(&stocked, discount))
}
}
异步外壳就是一个“获取数据 → 决策 → 获取数据 → 决策”的管道。 每一个“决策(decide)”步骤都是一个同步函数,它将 I/O 结果作为输入参数,而不是自己伸手去库里拿。
测试差异对比
同步核心不需要运行时或 mock 即可测试每一项业务规则:
#![allow(unused)]
fn main() {
#[test]
fn out_of_stock_rejects_order() {
let order = validated_order(vec![item("零件", 10)]);
let stock = stock_result(vec![("零件", 3)]); // 仅存 3 个
let result = core::check_stock(&order, &stock);
assert_eq!(result.unwrap_err(), OrderError::OutOfStock(vec!["零件"]));
}
#[test]
fn discount_applied_correctly() {
let order = stocked_order(100_00); // 单位为分
let receipt = core::finalize(&order, Discount::Percent(15));
assert_eq!(receipt.final_price, 85_00);
}
}
异步外壳只需要一个薄薄的 集成测试 来验证线路是否接通,而不需要验证业务逻辑的正误:
#![allow(unused)]
fn main() {
#[tokio::test]
async fn process_order_integration() {
let mock_inventory = mock_service(/* 返回库存 */);
let mock_discounts = mock_service(/* 返回 10% 折扣 */);
let receipt = process_order(sample_order()).await.unwrap();
assert!(receipt.final_price > 0);
// 逻辑的正确性已由上方的核心代码测试证明
}
}
为什么这很重要
| 关注点 | 异步逻辑贯穿核心 | 同步核心 + 异步外壳 |
|---|---|---|
| 业务规则无需运行时即可测试 | 否 | 是 |
需要 #[tokio::test] 的单元测试数量 | 全部 | 仅限集成测试 |
| I/O 故障与逻辑错误耦合 | 是 —— 两者共用一个 Result | 否 —— 同步代码返回逻辑错误,外壳专门处理 I/O 错误 |
| 业务逻辑可在 CLI / WASM / 批处理中复用 | 难 —— 会传递性地引入 tokio | 易 —— 纯函数 |
| 业务逻辑中的栈追踪 | 夹杂大量运行时帧 | 非常整洁 |
| 未来将 HTTP 客户端改为 gRPC | 需要修改核心函数 | 仅需修改外壳 |
核心洞察:步骤 2 和 4 中的 I/O 调用不需要出现在业务逻辑内部,它们应该是业务逻辑的输入。 同步核心接收 StockResult 和 Discount 作为参数。至于这些值是从 HTTP、gRPC、缓存还是测试桩中来的,那是“外壳”该关心的事。
spawn_blocking 的坏味道
之前章节介绍了 spawn_blocking 用于修复意外阻塞执行器的问题。当你面对一次性的阻塞调用时 —— 如 std::fs::read、压缩库、遗留的 FFI 函数 —— 它是正确的修复手段。
但如果你发现自己将大段代码包装在 spawn_blocking 中:
#![allow(unused)]
fn main() {
async fn handler(req: Request) -> Response {
// 如果你的代码库到处都是这种结构,说明架构边界划错地方了
tokio::task::spawn_blocking(move || {
let validated = validate(&req); // 同步
let enriched = enrich(validated); // 同步
let result = process(enriched); // 同步
let output = format_response(result); // 同步
output
}).await.unwrap()
}
}
这说明:这段逻辑从一开始就不需要异步。 你不需要 spawn_blocking —— 你需要的是一个能被异步处理器直接调用的同步模块。
请将 spawn_blocking 留给真正的重型 CPU 工作(如大文件解析、图像处理、数据压缩),因为这类任务的时间开销确实会饿死执行器。对于那些运行时间仅为几微秒的普通业务逻辑,直接进行同步调用更简单、更正确。
库作者:同步优先,异步可选
代码边界问题对库作者而言影响更为深远。一个同步的库可以被同步和异步调用方灵活使用:
// 同步库 —— 处处可用
let report = my_lib::analyze(&data);
// 调用方 A: 同步 CLI 工具
fn main() {
let report = my_lib::analyze(&data);
println!("{report}");
}
// 调用方 B: 异步处理器 —— 完美兼容
async fn handler() -> Json<Report> {
let report = my_lib::analyze(&data); // 异步语境下的同步调用 —— 没问题
Json(report)
}
// 调用方 C: 繁重分析 —— 由调用方决定是否转移至独立线程
async fn handler_heavy() -> Json<Report> {
let data = data.clone();
let report = tokio::task::spawn_blocking(move || {
my_lib::analyze(&data) // 调用方自主控制异步边界
}).await.unwrap();
Json(report)
}
而一个异步库则强迫 所有 调用方都必须载入一个运行时:
#![allow(unused)]
fn main() {
// 异步库 —— 只能在异步语境下使用
let report = my_lib::analyze(&data).await; // 调用方必须是异步的
// 同步调用方?现在你得使用 block_on —— 还要祈祷没有嵌套运行时的冲突
let report = tokio::runtime::Runtime::new().unwrap().block_on(
my_lib::analyze(&data)
); // 脆弱且在嵌套环境下极易引发 panic
}
默认提供同步 API。 如果你的库执行的是纯粹的计算、数据转换或解析,完全没有理由写成异步。如果涉及 I/O,考虑提供一个同步核心,并在功能标志(feature flag)后提供一个可选的异步便捷层 —— 让调用方来决定是否跨越异步边界。
何时异步才真正属于核心?
并不是所有东西都能被简单分离。在以下情况,异步理应出现在核心逻辑中:
- 并发“扇出/扇入(Fan-out/Fan-in)”本身就是逻辑所在:如果业务规则是“同时向 5 个定价服务发起查询并返回最低价”,那么这种并发本身就是核心逻辑,而非繁事。
- 流式处理(Streaming)逻辑:带有背压(backpressure)控制的持续事件流处理。
- 长连接与有状态协议:WebSocket 处理器、gRPC 双向流以及协议状态机。第 17 章中的聊天服务器正是此类。
测试准则:如果从一个函数中移除 async 关键字会导致你必须用线程、通道或手动轮询来替换它,那么异步就是值得的。如果移除 async 只是删掉了一个关键字,逻辑本身毫无变化,那它本就不该是异步。
决策规则
graph TD
START["这个函数应该是异步的吗?"] --> IO{"它执行 I/O 吗?"}
IO -->|不执行| SYNC["同步函数 (始终如此)"]
IO -->|执行| BOUNDARY{"它在导出边界上吗?<br/>(处理器、主循环、accept 循环)"}
BOUNDARY -->|是| ASYNC_SHELL["异步函数 (这是外壳)"]
BOUNDARY -->|否| CORE_IO{"I/O 逻辑本身是核心功能吗?<br/>(如 扇出、流、长连接)"}
CORE_IO -->|是| ASYNC_CORE["异步函数 (合理)"]
CORE_IO -->|否| EXTRACT["提取逻辑为同步函数。<br/>将 I/O 结果作为参数传入。"]
style SYNC fill:#d4efdf,stroke:#27ae60,color:#000
style ASYNC_SHELL fill:#e8f4f8,stroke:#2980b9,color:#000
style ASYNC_CORE fill:#e8f4f8,stroke:#2980b9,color:#000
style EXTRACT fill:#d4efdf,stroke:#27ae60,color:#000
经验法则:从同步开始。仅在最外层的 I/O 边界处添加异步。仅当你能明确说出 哪些并发 I/O 操作 抵消了由于函数着色带来的复杂性成本时,才将其向内层推进。
🏋️ 实践任务:提取同步核心 (点击展开)
以下是一个 axum 处理器,由于业务逻辑与 I/O 混杂,导致了严重的异步污染。请将其重构为一个“同步核心模块”和一个“薄异步外壳”。
#![allow(unused)]
fn main() {
use axum::{Json, extract::Path};
async fn get_device_report(Path(device_id): Path<String>) -> Result<Json<Report>, AppError> {
// 通过 HTTP 从设备获取原始遥测数据
let raw = reqwest::get(format!("http://bmc-{device_id}/telemetry"))
.await?
.json::<RawTelemetry>()
.await?;
// 业务逻辑:将原始传感器读数转换为校准值
let mut readings = Vec::new();
for sensor in &raw.sensors {
let calibrated = (sensor.raw_value as f64) * sensor.scale + sensor.offset;
if calibrated < sensor.min_valid || calibrated > sensor.max_valid {
return Err(AppError::SensorOutOfRange {
name: sensor.name.clone(),
value: calibrated,
});
}
readings.push(CalibratedReading {
name: sensor.name.clone(),
value: calibrated,
unit: sensor.unit.clone(),
});
}
// 业务逻辑:评估设备健康状况
let critical_count = readings.iter()
.filter(|r| r.value > 90.0)
.count();
let health = if critical_count > 2 { Health::Critical }
else if critical_count > 0 { Health::Warning }
else { Health::Ok };
// 从库存服务获取设备元数据
let meta = reqwest::get(format!("http://inventory/devices/{device_id}"))
.await?
.json::<DeviceMetadata>()
.await?;
Ok(Json(Report {
device_id,
device_name: meta.name,
health,
readings,
timestamp: chrono::Utc::now(),
}))
}
}
你的目标:
- 创建
core.rs,包含同步函数:calibrate_sensors、classify_health和build_report。 - 创建
shell.rs,包含轻量级异步处理器,负责数据获取并调用同步核心。 - 编写
#[test](而非#[tokio::test])来验证:传感器超限、健康分类阈值以及生成报告的正确性。
🔑 参考方案
#![allow(unused)]
fn main() {
// core.rs —— 零异步依赖
pub fn calibrate_sensors(raw: &RawTelemetry) -> Result<Vec<CalibratedReading>, AppError> {
raw.sensors.iter().map(|sensor| {
let calibrated = (sensor.raw_value as f64) * sensor.scale + sensor.offset;
if calibrated < sensor.min_valid || calibrated > sensor.max_valid {
return Err(AppError::SensorOutOfRange {
name: sensor.name.clone(),
value: calibrated,
});
}
Ok(CalibratedReading {
name: sensor.name.clone(),
value: calibrated,
unit: sensor.unit.clone(),
})
}).collect()
}
pub fn classify_health(readings: &[CalibratedReading]) -> Health {
let critical_count = readings.iter().filter(|r| r.value > 90.0).count();
if critical_count > 2 { Health::Critical }
else if critical_count > 0 { Health::Warning }
else { Health::Ok }
}
pub fn build_report(
device_id: String,
readings: Vec<CalibratedReading>,
meta: &DeviceMetadata,
) -> Report {
Report {
device_id,
device_name: meta.name.clone(),
health: classify_health(&readings),
readings,
timestamp: chrono::Utc::now(),
}
}
}
#![allow(unused)]
fn main() {
// shell.rs —— 仅作为异步边界
pub async fn get_device_report(
Path(device_id): Path<String>,
) -> Result<Json<Report>, AppError> {
let raw = reqwest::get(format!("http://bmc-{device_id}/telemetry"))
.await?.json::<RawTelemetry>().await?;
let readings = core::calibrate_sensors(&raw)?;
let meta = reqwest::get(format!("http://inventory/devices/{device_id}"))
.await?.json::<DeviceMetadata>().await?;
Ok(Json(core::build_report(device_id, readings, &meta)))
}
}
变化点: 异步处理器从 30 行逻辑与 I/O 混杂的代码变成了 8 行纯粹的流程编排。所有业务规则(校准、范围校验、健康阈值)现在都通过 #[test] 进行测试,在毫秒内即可跑完,且完全不依赖 tokio、reqwest 或任何 HTTP mock 服务器。
关键要诀:
- 异步是一种 I/O 复用优化,而非应用架构模式。绝大部分业务逻辑应当是同步的。
- 同步核心,异步外壳: 将业务规则保存在纯函数中,并将 I/O 结果作为参数传入。异步外壳负责编排数据获取并调用核心逻辑。
- 如果你发现自己在用
spawn_blocking包裹大段逻辑,说明 边界划分错了 —— 请将其重构为同步模块。- 库设计应首选同步 API。 异步库会强迫所有调用方绑定运行时;同步库则将选择权交由调用方。
- 异步在 扇出/扇入、并发流和有状态长连接 场景下实至名归 —— 在这些场景下,并发本身就是业务。
另请参阅: 第 12 章 —— 常见陷阱(spawn_blocking 的战术用法)· 第 13 章 —— 生产模式(背压、结构化并发)· 第 17 章 —— 实战项目:异步聊天服务器(异步架构的正确应用实例)
15. 练习题 🟡
通过实践巩固所学:
- 构建异步 Echo 服务器
- 带速率限制的并发 URL 获取器
- 带工作池(Worker Pool)的优雅停机
- 从零实现简单的异步 Mutex
- 流(Stream)处理管道
- 实现带超时的 select
练习 1:异步 Echo 服务器
构建一个能并发处理多个客户端的 TCP echo 服务器。
要求:
- 监听
127.0.0.1:8080 - 接收连接并回显每一行内容
- 优雅地处理客户端断开连接
- 在客户端连接/断开时打印日志
🔑 参考方案
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Echo 服务器已在 8080 端口监听");
loop {
let (socket, addr) = listener.accept().await?;
println!("[{addr}] 已连接");
tokio::spawn(async move {
let (reader, mut writer) = socket.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => {
println!("[{addr}] 已断开连接");
break;
}
Ok(_) => {
print!("[{addr}] 回显: {line}");
if writer.write_all(line.as_bytes()).await.is_err() {
break;
}
}
Err(e) => {
eprintln!("[{addr}] 读取错误: {e}");
break;
}
}
}
});
}
}
练习 2:带限速的并发 URL 获取器
并发获取一组 URL,确保同时进行的请求不超过 5 个。
🔑 参考方案
#![allow(unused)]
fn main() {
use futures::stream::{self, StreamExt};
async fn fetch_urls(urls: Vec<String>) -> Vec<Result<String, String>> {
// buffer_unordered(5) 确保最多同时轮询 5 个 future ——
// 在这里不需要额外的 Semaphore(信号量)。
let results: Vec<_> = stream::iter(urls)
.map(|url| {
async move {
println!("正在获取: {url}");
match reqwest::get(&url).await {
Ok(resp) => match resp.text().await {
Ok(body) => Ok(body),
Err(e) => Err(format!("{url}: {e}")),
},
Err(e) => Err(format!("{url}: {e}")),
}
}
})
.buffer_unordered(5) // ← 仅此一行即可限制并发数为 5
.collect()
.await;
results
}
// 注意:当你需要限制跨多个独立派生任务(tokio::spawn)的并发时,请使用 Semaphore。
// 在处理流(Stream)时,请直接使用 buffer_unordered。不要为了同一个限制目标混用两者。
}
练习 3:带工作池的优雅停机
构建一个包含以下功能的任务处理器:
- 基于通道(channel)的任务队列
- N 个从队列中消费任务的工作任务(worker tasks)
- 在按下 Ctrl+C 时实现优雅停机:停止接收新任务,完成已在进行的任务
🔑 参考方案
use tokio::sync::{mpsc, watch};
use tokio::time::{sleep, Duration};
struct WorkItem { id: u64, payload: String }
#[tokio::main]
async fn main() {
let (work_tx, work_rx) = mpsc::channel::<WorkItem>(100);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
// 派生 4 个工作单元
let mut worker_handles = Vec::new();
let work_rx = std::sync::Arc::new(tokio::sync::Mutex::new(work_rx));
for id in 0..4 {
let rx = work_rx.clone();
let mut shutdown = shutdown_rx.clone();
let handle = tokio::spawn(async move {
loop {
let item = {
let mut rx = rx.lock().await;
tokio::select! {
item = rx.recv() => item,
_ = shutdown.changed() => {
if *shutdown.borrow() { None } else { continue }
}
}
};
match item {
Some(work) => {
println!("工作单元 {id}: 处理中 {}", work.id);
sleep(Duration::from_millis(200)).await; // 模拟耗时操作
}
None => break,
}
}
});
worker_handles.push(handle);
}
// 生产者:提交一些任务
let producer = tokio::spawn(async move {
for i in 0..20 {
let _ = work_tx.send(WorkItem { id: i, payload: "...".into() }).await;
sleep(Duration::from_millis(50)).await;
}
});
// 等待 Ctrl+C
tokio::signal::ctrl_c().await.unwrap();
println!("\n接收到停机信号!");
shutdown_tx.send(true).unwrap();
producer.abort(); // 取消生产者任务
// 等待所有工作单元完成
for handle in worker_handles { let _ = handle.await; }
println!("所有工作单元均已停止。再见!");
}
练习 4:从零实现简单的异步 Mutex
利用通道实现一个异步感知的 Mutex(不直接使用 tokio::sync::Mutex)。
提示:使用容量为 1 的 tokio::sync::mpsc 通道作为信号量。
🔑 参考方案
#![allow(unused)]
fn main() {
use std::cell::UnsafeCell;
use std::sync::Arc;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
pub struct SimpleAsyncMutex<T> {
data: Arc<UnsafeCell<T>>,
semaphore: Arc<Semaphore>,
}
pub struct SimpleGuard<T> {
data: Arc<UnsafeCell<T>>,
_permit: OwnedSemaphorePermit, // 丢弃 guard 时会释放锁
}
impl<T> SimpleAsyncMutex<T> {
pub fn new(value: T) -> Self {
SimpleAsyncMutex {
data: Arc::new(UnsafeCell::new(value)),
semaphore: Arc::new(Semaphore::new(1)),
}
}
pub async fn lock(&self) -> SimpleGuard<T> {
let permit = self.semaphore.clone().acquire_owned().await.unwrap();
SimpleGuard {
data: self.data.clone(),
_permit: permit,
}
}
}
// 还要实现 Deref 和 DerefMut...
}
核心总结:异步 Mutex 通常构建在信号量之上。信号量提供了异步等待机制 —— 当锁定时,acquire() 会挂起任务直到有空出的许可证。这正是 tokio::sync::Mutex 的内部工作原理。
练习 5:流处理管道
使用流(Stream)构建数据处理管道:
- 生成数字 1 到 100
- 过滤出偶数
- 对每个数字求平方
- 并发处理:一次处理 10 个(使用 sleep 模拟耗时异步操作)
- 收集结果
🔑 参考方案
use futures::stream::{self, StreamExt};
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let results: Vec<u64> = stream::iter(1u64..=100)
.filter(|x| futures::future::ready(x % 2 == 0))
.map(|x| x * x)
.map(|x| async move {
sleep(Duration::from_millis(50)).await;
println!("已处理: {x}");
x
})
.buffer_unordered(10) // 10 路并发
.collect()
.await;
println!("得到 {} 个结果,总和为 {}", results.len(), results.iter().sum::<u64>());
}
练习 6:实现带超时的 Select
在不直接使用 tokio::select! 或 tokio::time::timeout 的前提下,实现一个函数让 Future 与某个截止时间竞速,并在超时后返回 Either::Right(())。
提示:基于第 6 章的 Select 组合器和 TimerFuture 实现。
🔑 参考方案
#![allow(unused)]
fn main() {
pub enum Either<A, B> { Left(A), Right(B) }
pub struct Timeout<F> {
future: F,
timer: TimerFuture, // 来自第 6 章
}
impl<F: Future + Unpin> Future for Timeout<F> {
type Output = Either<F::Output, ()>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if let Poll::Ready(val) = Pin::new(&mut self.future).poll(cx) {
return Poll::Ready(Either::Left(val));
}
if let Poll::Ready(()) = Pin::new(&mut self.timer).poll(cx) {
return Poll::Ready(Either::Right(()));
}
Poll::Pending
}
}
}
总结与备忘卡 (Reference Card) 🟡
快速参考卡
异步思维模型
┌─────────────────────────────────────────────────────┐
│ async fn → 状态机 (enum) → 实现 Future Trait │
│ .await → 调用内部 future 的 poll() 方法 │
│ 执行器 → 循环 { poll(); 睡眠直至被唤醒; } │
│ Waker → “嘿,执行器,再次轮询我” │
│ Pin → “我承诺不会在内存中移动位置” │
└─────────────────────────────────────────────────────┘
常见模式速查表
| 目标 | 方法 |
|---|---|
| 并发运行两个 future | tokio::join!(a, b) |
| 竞速运行两个 future | tokio::select! { ... } |
| 派生一个后台任务 | tokio::spawn(async { ... }) |
| 在异步中运行阻塞代码 | `tokio::task::spawn_blocking( |
| 限制并发数量 | Semaphore::new(N) |
| 收集多个任务的结果 | JoinSet |
| 跨任务共享状态 | Arc<Mutex<T>> 或使用通道 |
| 优雅停机 | watch::channel + select! |
| 每次并发处理 N 个流条目 | .buffer_unordered(N) |
| 为 future 设置超时 | tokio::time::timeout(dur, fut) |
| 带退避算法的重试 | 自定义组合器 (见第 13 章) |
固定 (Pinning) 快速参考
| 场景 | 方法 |
|---|---|
| 在堆上固定 future | Box::pin(fut) |
| 在栈上固定 future | tokio::pin!(fut) |
固定一个 Unpin 类型 | Pin::new(&mut val) —— 安全且零开销 |
| 返回固定的 trait 对象 | -> Pin<Box<dyn Future<Output = T> + Send>> |
通道 (Channel) 选择指南
| 通道 | 生产者 | 消费者 | 传输内容 | 适用场景 |
|---|---|---|---|---|
mpsc | 多个 (N) | 1 | 流 | 工作队列、事件总线 |
oneshot | 1 | 1 | 单个值 | 请求/响应模式、完成通知 |
broadcast | 多个 (N) | 多个 (N) | 所有人收到所有 | 扇出通知、停机广播 |
watch | 1 | 多个 (N) | 仅最新值 | 配置更新、健康状态检查 |
Mutex 选择指南
| Mutex | 适用场景 |
|---|---|
std::sync::Mutex | 持锁时间极短,决不跨越 .await |
tokio::sync::Mutex | 必须跨越 .await 点持有锁 |
parking_lot::Mutex | 高并发竞争,无 .await,极致性能 |
tokio::sync::RwLock | 多读少写,且需要跨越 .await |
决策快速参考
需要并发?
├── I/O 密集型 → 使用 async/await
├── CPU 密集型 → 使用 rayon / std::thread
└── 混合型 → 为 CPU 部分使用 spawn_blocking
选择运行时?
├── 服务器应用 → 使用 tokio
├── 类库项目 → 运行时无关设计 (使用 futures 包)
├── 嵌入式项目 → 使用 embassy
└── 极简项目 → 使用 smol
需要并发运行 Future?
├── 满足 'static + Send → 使用 tokio::spawn
├── 满足 'static + !Send → 使用 LocalSet
├── 不满足 'static → 使用 FuturesUnordered
└── 需要追踪/中止任务 → 使用 JoinSet
常见错误信息及修复方案
| 错误信息 | 原因 | 修复方法 |
|---|---|---|
future is not Send | 跨 .await 持有了 !Send 类型 | 收窄锁作用域或使用 current_thread |
borrowed value does not live long enough (在 spawn 中) | tokio::spawn 要求 'static 生命周期 | 使用 Arc、克隆或 FuturesUnordered |
the trait Future is not implemented for () | 遗漏了 .await | 在异步调用后补上 .await |
cannot borrow as mutable (在 poll 中) | 自引用借用问题 | 正确使用 Pin<&mut Self> (见第 4 章) |
| 程序发生静默挂起 | 忘记调用 waker.wake() | 确保每个 Pending 路径都注册了 waker |
延伸阅读
| 资源 | 推荐理由 |
|---|---|
| Tokio 官方教程 | 官方出品 —— 入门首选指南 |
| Async Book (官方文档) | 从语言层面涵盖 Future, Pin, Stream |
| Jon Gjengset —— Crust of Rust: async/await | 2 小时带源码实测深度解析 |
| Alice Ryhl —— 使用 Tokio 构建 Actor | 生产级有状态服务的架构模式 |
| Without Boats —— Pin, Unpin, 及其设计初衷 | 核心设计者的原始设计逻辑 |
| Tokio mini-Redis | 完整的异步项目 —— 极具参考价值的代码库 |
| Tower 官方文档 | axum/tonic 使用的中间件/服务模式 |
终极实战项目:异步聊天服务器
这个项目将书中各章节的模式整合到一个单体、生产级的应用程序中。你将构建一个多聊天室异步聊天服务器,综合运用 tokio、通道(channels)、流(streams)、优雅停机(graceful shutdown)以及完善的错误处理。
预计耗时:4–6 小时 | 难度:★★★
你将实践的内容:
tokio::spawn及其'static约束 (第 8 章)- 通道:
mpsc处理消息,broadcast处理房间,watch处理停机 (第 8 章)- 流:从 TCP 连接中读取行内容 (第 11 章)
- 常见陷阱:取消安全性、跨
.await持有 MutexGuard (第 12 章)- 生产模式:优雅停机、背压控制 (第 13 章)
- 异步 Trait 用于插件化后端 (第 10 章)
问题描述
构建一个 TCP 聊天服务器,具备以下功能:
- 客户端通过 TCP 连接并加入指定的命名聊天室。
- 消息会广播给同一房间内的所有客户端。
- 命令支持:
/join <room>,/nick <name>,/rooms,/quit。 - 服务器停机:在按下 Ctrl+C 时实现优雅停机 —— 完成剩余消息的发送。
graph LR
C1["客户端 1<br/>(Alice)"] -->|TCP| SERVER["聊天服务器"]
C2["客户端 2<br/>(Bob)"] -->|TCP| SERVER
C3["客户端 3<br/>(Carol)"] -->|TCP| SERVER
SERVER --> R1["#general<br/>广播通道"]
SERVER --> R2["#rust<br/>广播通道"]
R1 -->|msg| C1
R1 -->|msg| C2
R2 -->|msg| C3
CTRL["Ctrl+C"] -->|watch| SERVER
style SERVER fill:#e8f4f8,stroke:#2980b9,color:#000
style R1 fill:#d4efdf,stroke:#27ae60,color:#000
style R2 fill:#d4efdf,stroke:#27ae60,color:#000
style CTRL fill:#fadbd8,stroke:#e74c3c,color:#000
第 1 步:基础 TCP 接收循环
从一个能接收连接并回显内容的服务器开始:
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("聊天服务器已在 8080 端口启动");
loop {
let (socket, addr) = listener.accept().await?;
println!("[{addr}] 已连接");
tokio::spawn(async move {
let (reader, mut writer) = socket.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => {
let _ = writer.write_all(line.as_bytes()).await;
}
}
}
println!("[{addr}] 已断开连接");
});
}
}
任务:验证此段代码可编译,并能通过 telnet localhost 8080 正常工作。
第 2 步:使用广播通道管理房间状态
每个房间对应一个 broadcast::Sender。房间内的所有客户端都通过订阅来接收消息。
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
type RoomMap = Arc<RwLock<HashMap<String, broadcast::Sender<String>>>>;
fn get_or_create_room(rooms: &mut HashMap<String, broadcast::Sender<String>>, name: &str) -> broadcast::Sender<String> {
rooms.entry(name.to_string())
.or_insert_with(|| {
let (tx, _) = broadcast::channel(100); // 100 条消息的缓冲区
tx
})
.clone()
}
}
任务:实现房间状态管理,确保:
- 客户端初始加入
#general房间。 /join <room>命令可实现房间切换(退订旧房间,订阅新房间)。- 消息能广播至发送者当前所在房间的所有客户端。
💡 提示 —— 客户端任务结构
每个客户端任务需要两个并发循环:
- 从 TCP 读取 → 解析命令或广播至房间。
- 从广播接收端读取 → 写入 TCP。
利用 tokio::select! 同时运行两者:
#![allow(unused)]
fn main() {
loop {
tokio::select! {
result = reader.read_line(&mut line) => {
// 解析命令或广播消息
}
result = room_rx.recv() => {
// 接收到广播并转发给客户端
}
}
}
}
第 3 步:命令系统
实现命令协议:
| 命令 | 行为 |
|---|---|
/join <room> | 离开当前房间,加入新房间,并同步发布通知消息 |
/nick <name> | 修改显示名称 |
/rooms | 列出所有活跃房间及人数 |
/quit | 优雅断开连接 |
| 其他内容 | 作为聊天消息进行广播 |
任务:从输入行解析命令。对于 /rooms,需读取 RoomMap —— 利用 RwLock::read() 以避免阻塞其他客户端。
第 4 步:优雅停机
加入 Ctrl+C 处理机制,确保服务器:
- 停止接收新连接。
- 向所有房间发送“服务器正在停机…”的消息。
- 等待待处理的消息排空。
- 干净地退出。
第 5 步:错误处理与边界情况
强化服务器的健壮性:
- 处理滞后接收端:如果某个客户端过慢导致丢失消息,
broadcast::recv()会返回RecvError::Lagged(n)。请优雅地处理该错误(记录日志并继续,不要崩溃)。 - 昵称校验:拒绝空名或过长的昵称。
- 背压控制:广播通道缓冲区是有界的。如果某客户端跟不上进度,会触发
Lagged错误。 - 超时机制:断开空闲超过 5 分钟的客户端连接。
第 6 步:集成测试
编写测试用例:启动服务器,连接两个客户端,并验证消息是否能准确送达。
评估标准
| 标准 | 目标 |
|---|---|
| 并发能力 | 多房间多客户端并发,无阻塞 |
| 正确性 | 消息仅发送至同房间客户端 |
| 优雅停机 | Ctrl+C 能干净退出并完成残留消息发送 |
| 错误处理 | 正确处理滞后接收端、断连及超时 |
| 代码组织 | 实现逻辑与网络层的清晰分离 |
Async Rust: From Futures to Production
Speaker Intro
- Principal Firmware Architect in Microsoft SCHIE (Silicon and Cloud Hardware Infrastructure Engineering) team
- Industry veteran with expertise in security, systems programming (firmware, operating systems, hypervisors), CPU and platform architecture, and C++ systems
- Started programming in Rust in 2017 (@AWS EC2), and have been in love with the language ever since
A deep-dive guide to asynchronous programming in Rust. Unlike most async tutorials that start with tokio::main and hand-wave the internals, this guide builds understanding from first principles — the Future trait, polling, state machines — then progresses to real-world patterns, runtime selection, and production pitfalls.
Who This Is For
- Rust developers who can write synchronous Rust but find async confusing
- Developers from C#, Go, Python, or JavaScript who know
async/awaitbut not Rust’s model - Anyone who’s been bitten by
Future is not Send,Pin<Box<dyn Future>>, or “why does my program hang?”
Prerequisites
You should be comfortable with:
- Ownership, borrowing, and lifetimes
- Traits and generics (including
impl Trait) - Using
Result<T, E>and the?operator - Basic multi-threading (
std::thread::spawn,Arc,Mutex)
No prior async Rust experience is needed.
How to Use This Book
Read linearly the first time. Parts I–III build on each other. Each chapter has:
| Symbol | Meaning |
|---|---|
| 🟢 | Beginner — foundational concept |
| 🟡 | Intermediate — requires earlier chapters |
| 🔴 | Advanced — deep internals or production patterns |
Each chapter includes:
- A “What you’ll learn” block at the top
- Mermaid diagrams for visual learners
- An inline exercise with a hidden solution
- Key Takeaways summarizing the core ideas
- Cross-references to related chapters
Pacing Guide
| Chapters | Topic | Suggested Time | Checkpoint |
|---|---|---|---|
| 1–5 | How Async Works | 6–8 hours | You can explain Future, Poll, Pin, and why Rust has no built-in runtime |
| 6–10 | The Ecosystem | 6–8 hours | You can build futures by hand, choose a runtime, and use tokio’s API |
| 11–13 | Production Async | 6–8 hours | You can write production-grade async code with streams, proper error handling, and graceful shutdown |
| Capstone | Chat Server | 4–6 hours | You’ve built a real async application integrating all concepts |
Total estimated time: 22–30 hours
Working Through Exercises
Every content chapter has an inline exercise. The capstone (Ch 16) integrates everything into a single project. For maximum learning:
- Try the exercise before expanding the solution — struggling is where learning happens
- Type the code, don’t copy-paste — muscle memory matters for Rust’s syntax
- Run every example —
cargo new async-exercisesand test as you go
Table of Contents
Part I: How Async Works
- 1. Why Async is Different in Rust 🟢 — The fundamental difference: Rust has no built-in runtime
- 2. The Future Trait 🟡 —
poll(),Waker, and the contract that makes it all work - 3. How Poll Works 🟡 — The polling state machine and a minimal executor
- 4. Pin and Unpin 🔴 — Why self-referential structs need pinning
- 5. The State Machine Reveal 🟢 — What the compiler actually generates from
async fn
Part II: The Ecosystem
- 6. Building Futures by Hand 🟡 — TimerFuture, Join, Select from scratch
- 7. Executors and Runtimes 🟡 — tokio, smol, async-std, embassy — how to choose
- 8. Tokio Deep Dive 🟡 — Runtime flavors, spawn, channels, sync primitives
- 9. When Tokio Isn’t the Right Fit 🟡 — LocalSet, FuturesUnordered, runtime-agnostic design
- 10. Async Traits 🟡 — RPITIT, dyn dispatch, trait_variant, async closures
Part III: Production Async
- 11. Streams and AsyncIterator 🟡 — Async iteration, AsyncRead/Write, stream combinators
- 12. Common Pitfalls 🔴 — 9 production bugs and how to avoid them
- 13. Production Patterns 🔴 — Graceful shutdown, backpressure, Tower middleware
- 14. Async Is an Optimization, Not an Architecture 🔴 — Sync core / async shell, the function coloring tax
Appendices
- Summary and Reference Card — Quick-lookup tables and decision trees
- Capstone Project: Async Chat Server — Build a complete async application
1. Why Async is Different in Rust 🟢
What you’ll learn:
- Why Rust has no built-in async runtime (and what that means for you)
- The three key properties: lazy execution, no runtime, zero-cost abstraction
- When async is the right tool (and when it’s slower)
- How Rust’s model compares to C#, Go, Python, and JavaScript
The Fundamental Difference
Most languages with async/await hide the machinery. C# has the CLR thread pool. JavaScript has the event loop. Go has goroutines and a scheduler built into the runtime. Python has asyncio.
Rust has nothing.
There is no built-in runtime, no thread pool, no event loop. The async keyword is a zero-cost compilation strategy — it transforms your function into a state machine that implements the Future trait. Someone else (an executor) must drive that state machine forward.
Three Key Properties of Rust Async
graph LR
subgraph "C# / JS / Go"
EAGER["Eager Execution<br/>Task starts immediately"]
BUILTIN["Built-in Runtime<br/>Thread pool included"]
GC["GC-Managed<br/>No lifetime concerns"]
end
subgraph "Rust (and Python*)"
LAZY["Lazy Execution<br/>Nothing happens until polled/awaited"]
BYOB["Bring Your Own Runtime<br/>You choose the executor"]
OWNED["Ownership Applies<br/>Lifetimes, Send, Sync matter"]
end
EAGER -. "opposite" .-> LAZY
BUILTIN -. "opposite" .-> BYOB
GC -. "opposite" .-> OWNED
style LAZY fill:#e8f5e8,color:#000
style BYOB fill:#e8f5e8,color:#000
style OWNED fill:#e8f5e8,color:#000
style EAGER fill:#e3f2fd,color:#000
style BUILTIN fill:#e3f2fd,color:#000
style GC fill:#e3f2fd,color:#000
* Python coroutines are lazy like Rust futures — they don’t execute until awaited or scheduled. However, Python still uses GC and has no ownership/lifetime concerns.
No Built-In Runtime
// This compiles but does NOTHING:
async fn fetch_data() -> String {
"hello".to_string()
}
fn main() {
let future = fetch_data(); // Creates the Future, but doesn't execute it
// future is just a struct sitting on the stack
// No output, no side effects, nothing happens
drop(future); // Silently dropped — work was never started
}
Compare with C# where Task starts eagerly:
// C# — this immediately starts executing:
async Task<string> FetchData() => "hello";
var task = FetchData(); // Already running!
var result = await task; // Just waits for completion
Lazy Futures vs Eager Tasks
This is the single most important mental shift:
| C# / JavaScript | Python | Go | Rust | |
|---|---|---|---|---|
| Creation | Task starts executing immediately | Coroutine is lazy — returns an object, doesn’t run until awaited or scheduled | Goroutine starts immediately | Future does nothing until polled |
| Dropping | Detached task continues running | Unawaited coroutine is garbage-collected (with a warning) | Goroutine runs until return | Dropping a Future cancels it |
| Runtime | Built into the language/VM | asyncio event loop (must be explicitly started) | Built into the binary (M:N scheduler) | You choose (tokio, smol, etc.) |
| Scheduling | Automatic (thread pool) | Event loop + await or create_task() | Automatic (GMP scheduler) | Explicit (spawn, block_on) |
| Cancellation | CancellationToken (cooperative) | Task.cancel() (cooperative, raises CancelledError) | context.Context (cooperative) | Drop the future (immediate) |
// To actually RUN a future, you need an executor:
#[tokio::main]
async fn main() {
let result = fetch_data().await; // NOW it executes
println!("{result}");
}
When to Use Async (and When Not To)
graph TD
START["What kind of work?"]
IO["I/O-bound?<br/>(network, files, DB)"]
CPU["CPU-bound?<br/>(computation, parsing)"]
MANY["Many concurrent connections?<br/>(100+)"]
FEW["Few concurrent tasks?<br/>(<10)"]
USE_ASYNC["✅ Use async/await"]
USE_THREADS["✅ Use std::thread or rayon"]
USE_SPAWN_BLOCKING["✅ Use spawn_blocking()"]
MAYBE_SYNC["Consider synchronous code<br/>(simpler, less overhead)"]
START -->|Network, files, DB| IO
START -->|Computation| CPU
IO -->|Yes, many| MANY
IO -->|Just a few| FEW
MANY --> USE_ASYNC
FEW --> MAYBE_SYNC
CPU -->|Parallelize| USE_THREADS
CPU -->|Inside async context| USE_SPAWN_BLOCKING
style USE_ASYNC fill:#c8e6c9,color:#000
style USE_THREADS fill:#c8e6c9,color:#000
style USE_SPAWN_BLOCKING fill:#c8e6c9,color:#000
style MAYBE_SYNC fill:#fff3e0,color:#000
Rule of thumb: Async is for I/O concurrency (doing many things at once while waiting), not CPU parallelism (making one thing faster). If you have 10,000 network connections, async shines. If you’re crunching numbers, use rayon or OS threads.
When Async Can Be Slower
Async isn’t free. For low-concurrency workloads, synchronous code can outperform async:
| Cost | Why |
|---|---|
| State machine overhead | Each .await adds an enum variant; deeply nested futures produce large, complex state machines |
| Dynamic dispatch | Box<dyn Future> adds indirection and kills inlining |
| Context switching | Cooperative scheduling still has cost — the executor must manage a task queue, wakers, and I/O registrations |
| Compile time | Async code generates more complex types, slowing down compilation |
| Debuggability | Stack traces through state machines are harder to read (see Ch. 12) |
Benchmarking guidance: If fewer than ~10 concurrent I/O operations, profile before committing to async. A simple std::thread::spawn per connection scales fine to hundreds of threads on modern Linux.
Exercise: When Would You Use Async?
🏋️ Exercise (click to expand)
For each scenario, decide whether async is appropriate and explain why:
- A web server handling 10,000 concurrent WebSocket connections
- A CLI tool that compresses a single large file
- A service that queries 5 different databases and merges results
- A game engine running a physics simulation at 60 FPS
🔑 Solution
- Async — I/O-bound with massive concurrency. Each connection spends most time waiting for data. Threads would require 10K stacks.
- Sync/threads — CPU-bound, single task. Async adds overhead with no benefit. Use
rayonfor parallel compression. - Async — Five concurrent I/O waits.
tokio::join!runs all five queries simultaneously. - Sync/threads — CPU-bound, latency-sensitive. Async’s cooperative scheduling could introduce frame jitter.
Key Takeaways — Why Async is Different
- Rust futures are lazy — they do nothing until polled by an executor
- There is no built-in runtime — you choose (or build) your own
- Async is a zero-cost compilation strategy that produces state machines
- Async shines for I/O-bound concurrency; for CPU-bound work, use threads or rayon
See also: Ch 2 — The Future Trait for the trait that makes this all work, Ch 7 — Executors and Runtimes for choosing your runtime
2. The Future Trait 🟡
What you’ll learn:
- The
Futuretrait:Output,poll(),Context,Waker- How a waker tells the executor “poll me again”
- The contract: never call
wake()= your program silently hangs- Implementing a real future by hand (
Delay)
Anatomy of a Future
Everything in async Rust ultimately implements this trait:
#![allow(unused)]
fn main() {
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T), // The future has completed with value T
Pending, // The future is not ready yet — call me back later
}
}
That’s it. A Future is anything that can be polled — asked “are you done yet?” — and responds with either “yes, here’s the result” or “not yet, I’ll wake you up when I’m ready.”
Output, poll(), Context, Waker
sequenceDiagram
participant E as Executor
participant F as Future
participant R as Resource (I/O)
E->>F: poll(cx)
F->>R: Check: is data ready?
R-->>F: Not yet
F->>R: Register waker from cx
F-->>E: Poll::Pending
Note over R: ... time passes, data arrives ...
R->>E: waker.wake() — "I'm ready!"
E->>F: poll(cx) — try again
F->>R: Check: is data ready?
R-->>F: Yes! Here's the data
F-->>E: Poll::Ready(data)
Let’s break down each piece:
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
// A future that returns 42 immediately
struct Ready42;
impl Future for Ready42 {
type Output = i32; // What the future eventually produces
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<i32> {
Poll::Ready(42) // Always ready — no waiting
}
}
}
The components:
Output— the type of value produced when the future completespoll()— called by the executor to check progress; returnsReady(value)orPendingPin<&mut Self>— ensures the future won’t be moved in memory (we’ll cover why in Ch. 4)Context— carries theWakerso the future can signal the executor when it’s ready to make progress
The Waker Contract
The Waker is the callback mechanism. When a future returns Pending, it must arrange for waker.wake() to be called later — otherwise the executor will never poll it again and the program hangs.
#![allow(unused)]
fn main() {
use std::task::{Context, Poll, Waker};
use std::pin::Pin;
use std::future::Future;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
/// A future that completes after a delay (toy implementation)
struct Delay {
completed: Arc<Mutex<bool>>,
waker_stored: Arc<Mutex<Option<Waker>>>,
duration: Duration,
started: bool,
}
impl Delay {
fn new(duration: Duration) -> Self {
Delay {
completed: Arc::new(Mutex::new(false)),
waker_stored: Arc::new(Mutex::new(None)),
duration,
started: false,
}
}
}
impl Future for Delay {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
// Check if already completed
if *self.completed.lock().unwrap() {
return Poll::Ready(());
}
// Store the waker so the background thread can wake us
*self.waker_stored.lock().unwrap() = Some(cx.waker().clone());
// Start the background timer on first poll
if !self.started {
self.started = true;
let completed = Arc::clone(&self.completed);
let waker = Arc::clone(&self.waker_stored);
let duration = self.duration;
thread::spawn(move || {
thread::sleep(duration);
*completed.lock().unwrap() = true;
// CRITICAL: wake the executor so it polls us again
if let Some(w) = waker.lock().unwrap().take() {
w.wake(); // "Hey executor, I'm ready — poll me again!"
}
});
}
Poll::Pending // Not done yet
}
}
}
Key insight: In C#, the TaskScheduler handles waking automatically. In Rust, you (or the I/O library you use) are responsible for calling
waker.wake(). Forget it, and your program silently hangs.
Exercise: Implement a CountdownFuture
🏋️ Exercise (click to expand)
Challenge: Implement a CountdownFuture that counts down from N to 0, printing the current count each time it’s polled. When it reaches 0, it completes with Ready("Liftoff!").
Hint: The future needs to store the current count and decrement it on each poll. Remember to always re-register the waker!
🔑 Solution
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct CountdownFuture {
count: u32,
}
impl CountdownFuture {
fn new(start: u32) -> Self {
CountdownFuture { count: start }
}
}
impl Future for CountdownFuture {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.count == 0 {
println!("Liftoff!");
Poll::Ready("Liftoff!")
} else {
println!("{}...", self.count);
self.count -= 1;
cx.waker().wake_by_ref(); // Schedule re-poll immediately
Poll::Pending
}
}
}
}
Key takeaway: This future is polled once per count. Each time it returns Pending, it immediately wakes itself to be polled again. In production, you’d use a timer instead of busy-polling.
Key Takeaways — The Future Trait
Future::poll()returnsPoll::Ready(value)orPoll::Pending- A future must register a
Wakerbefore returningPending— the executor uses it to know when to re-pollPin<&mut Self>guarantees the future won’t be moved in memory (needed for self-referential state machines — see Ch 4)- Everything in async Rust —
async fn,.await, combinators — is built on this one trait
See also: Ch 3 — How Poll Works for the executor loop, Ch 6 — Building Futures by Hand for more complex implementations
3. How Poll Works 🟡
What you’ll learn:
- The executor’s poll loop: poll → pending → wake → poll again
- How to build a minimal executor from scratch
- Spurious wake rules and why they matter
- Utility functions:
poll_fn()andyield_now()
The Polling State Machine
The executor runs a loop: poll a future, if it’s Pending, park it until its waker fires, then poll again. This is fundamentally different from OS threads where the kernel handles scheduling.
stateDiagram-v2
[*] --> Idle : Future created
Idle --> Polling : executor calls poll()
Polling --> Complete : Ready(value)
Polling --> Waiting : Pending
Waiting --> Polling : waker.wake() called
Complete --> [*] : Value returned
Important: While in the Waiting state the future must have registered the waker with an I/O source. No registration = hang forever.
A Minimal Executor
To demystify executors, let’s build the simplest possible one:
use std::future::Future;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
use std::pin::Pin;
/// The simplest possible executor: busy-loop poll until Ready
fn block_on<F: Future>(mut future: F) -> F::Output {
// Pin the future on the stack
// SAFETY: `future` is never moved after this point — we only
// access it through the pinned reference until it completes.
let mut future = unsafe { Pin::new_unchecked(&mut future) };
// Create a no-op waker (just keeps polling — inefficient but simple)
fn noop_raw_waker() -> RawWaker {
fn no_op(_: *const ()) {}
fn clone(_: *const ()) -> RawWaker { noop_raw_waker() }
let vtable = &RawWakerVTable::new(clone, no_op, no_op, no_op);
RawWaker::new(std::ptr::null(), vtable)
}
// SAFETY: noop_raw_waker() returns a valid RawWaker with a correct vtable.
let waker = unsafe { Waker::from_raw(noop_raw_waker()) };
let mut cx = Context::from_waker(&waker);
// Busy-loop until the future completes
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(value) => return value,
Poll::Pending => {
// A real executor would park the thread here
// and wait for waker.wake() — we just spin
std::thread::yield_now();
}
}
}
}
// Usage:
fn main() {
let result = block_on(async {
println!("Hello from our mini executor!");
42
});
println!("Got: {result}");
}
Don’t use this in production! It busy-loops, wasting CPU. Real executors (tokio, smol) use
epoll/kqueue/io_uringto sleep until I/O is ready. But this shows the core idea: an executor is just a loop that callspoll().
Wake-Up Notifications
A real executor is event-driven. When all futures are Pending, the executor sleeps. The waker is an interrupt mechanism:
#![allow(unused)]
fn main() {
// Conceptual model of a real executor's main loop:
fn executor_loop(tasks: &mut TaskQueue) {
loop {
// 1. Poll all tasks that have been woken
while let Some(task) = tasks.get_woken_task() {
match task.poll() {
Poll::Ready(result) => task.complete(result),
Poll::Pending => { /* task stays in queue, waiting for wake */ }
}
}
// 2. Sleep until something wakes us up (epoll_wait, kevent, etc.)
// This is where mio/polling does the heavy lifting
tasks.wait_for_events(); // blocks until an I/O event or waker fires
}
}
}
Spurious Wakes
A future may be polled even when its I/O isn’t ready. This is called a spurious wake. Futures must handle this correctly:
#![allow(unused)]
fn main() {
impl Future for MyFuture {
type Output = Data;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Data> {
// ✅ CORRECT: Always re-check the actual condition
if let Some(data) = self.try_read_data() {
Poll::Ready(data)
} else {
// Re-register the waker (it might have changed!)
self.register_waker(cx.waker());
Poll::Pending
}
// ❌ WRONG: Assuming poll means data is ready
// let data = self.read_data(); // might block or panic
// Poll::Ready(data)
}
}
}
Rules for implementing poll():
- Never block — return
Pendingimmediately if not ready - Always re-register the waker — it may have changed between polls
- Handle spurious wakes — check the actual condition, don’t assume readiness
- Don’t poll after
Ready— behavior is unspecified (may panic, returnPending, or repeatReady). OnlyFusedFutureguarantees safe post-completion polling
🏋️ Exercise: Implement a CountdownFuture (click to expand)
Challenge: Implement a CountdownFuture that counts down from N to 0, printing the current count as a side-effect each time it’s polled. When it reaches 0, it completes with Ready("Liftoff!"). (Note: a Future produces only one final value — the printing is a side-effect, not a yielded value. For multiple async values, see Stream in Ch. 11.)
Hint: This doesn’t need a real I/O source — it can wake itself immediately with cx.waker().wake_by_ref() after each decrement.
🔑 Solution
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct CountdownFuture {
count: u32,
}
impl CountdownFuture {
fn new(start: u32) -> Self {
CountdownFuture { count: start }
}
}
impl Future for CountdownFuture {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.count == 0 {
Poll::Ready("Liftoff!")
} else {
println!("{}...", self.count);
self.count -= 1;
// Wake immediately — we're always ready to make progress
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
// Usage with our mini executor or tokio:
// let msg = block_on(CountdownFuture::new(5));
// prints: 5... 4... 3... 2... 1...
// msg == "Liftoff!"
}
Key takeaway: Even though this future is always ready to progress, it returns Pending to yield control between steps. It calls wake_by_ref() immediately so the executor re-polls it right away. This is the basis of cooperative multitasking — each future voluntarily yields.
Handy Utilities: poll_fn and yield_now
Two utilities from the standard library and tokio that avoid writing full Future impls:
#![allow(unused)]
fn main() {
use std::future::poll_fn;
use std::task::Poll;
// poll_fn: create a one-off future from a closure
let value = poll_fn(|cx| {
// Do something with cx.waker(), return Ready or Pending
Poll::Ready(42)
}).await;
// Real-world use: bridge a callback-based API into async
async fn read_when_ready(source: &MySource) -> Data {
poll_fn(|cx| source.poll_read(cx)).await
}
}
#![allow(unused)]
fn main() {
// yield_now: voluntarily yield control to the executor
// Useful in CPU-heavy async loops to avoid starving other tasks
async fn cpu_heavy_work(items: &[Item]) {
for (i, item) in items.iter().enumerate() {
process(item); // CPU work
// Every 100 items, yield to let other tasks run
if i % 100 == 0 {
tokio::task::yield_now().await;
}
}
}
}
When to use
yield_now(): If your async function does CPU work in a loop without any.awaitpoints, it monopolizes the executor thread. Insertyield_now().awaitperiodically to enable cooperative multitasking.
Key Takeaways — How Poll Works
- An executor repeatedly calls
poll()on futures that have been woken- Futures must handle spurious wakes — always re-check the actual condition
poll_fn()lets you create ad-hoc futures from closuresyield_now()is a cooperative scheduling escape hatch for CPU-heavy async code
See also: Ch 2 — The Future Trait for the trait definition, Ch 5 — The State Machine Reveal for what the compiler generates
4. Pin and Unpin 🔴
What you’ll learn:
- Why self-referential structs break when moved in memory
- What
Pin<P>guarantees and how it prevents moves- The three practical pinning patterns:
Box::pin(),tokio::pin!(),Pin::new()- When
Unpingives you an escape hatch
Why Pin Exists
This is the most confusing concept in async Rust. Let’s build the intuition step by step.
The Problem: Self-Referential Structs
When the compiler transforms an async fn into a state machine, that state machine may contain references to its own fields. This creates a self-referential struct — and moving it in memory would invalidate those internal references.
#![allow(unused)]
fn main() {
// What the compiler generates (simplified) for:
// async fn example() {
// let data = vec![1, 2, 3];
// let reference = &data; // Points to data above
// use_ref(reference).await;
// }
// Becomes something like:
enum ExampleStateMachine {
State0 {
data: Vec<i32>,
// reference: &Vec<i32>, // PROBLEM: points to `data` above
// // If this struct moves, the pointer is dangling!
},
State1 {
data: Vec<i32>,
reference: *const Vec<i32>, // Internal pointer to data field
},
Complete,
}
}
graph LR
subgraph "Before Move (Valid)"
A["data: [1,2,3]<br/>at addr 0x1000"]
B["reference: 0x1000<br/>(points to data)"]
B -->|"valid"| A
end
subgraph "After Move (INVALID)"
C["data: [1,2,3]<br/>at addr 0x2000"]
D["reference: 0x1000<br/>(still points to OLD location!)"]
D -->|"dangling!"| E["💥 0x1000<br/>(freed/garbage)"]
end
style E fill:#ffcdd2,color:#000
style D fill:#ffcdd2,color:#000
style B fill:#c8e6c9,color:#000
Self-Referential Structs
This isn’t an academic concern. Every async fn that holds a reference across an .await point creates a self-referential state machine:
#![allow(unused)]
fn main() {
async fn problematic() {
let data = String::from("hello");
let slice = &data[..]; // slice borrows data
some_io().await; // <-- .await point: state machine stores both data AND slice
println!("{slice}"); // uses the reference after await
}
// The generated state machine has `data: String` and `slice: &str`
// where slice points INTO data. Moving the state machine = dangling pointer.
}
Pin in Practice
Pin<P> is a wrapper that prevents moving the value behind the pointer:
#![allow(unused)]
fn main() {
use std::pin::Pin;
let mut data = String::from("hello");
// Pin it — now it can't be moved
let pinned: Pin<&mut String> = Pin::new(&mut data);
// Can still use it:
println!("{}", pinned.as_ref().get_ref()); // "hello"
// But we can't get &mut String back (which would allow mem::swap):
// let mutable: &mut String = Pin::into_inner(pinned); // Only if String: Unpin
// String IS Unpin, so this actually works for String.
// But for self-referential state machines (which are !Unpin), it's blocked.
}
In real code, you mostly encounter Pin in three places:
#![allow(unused)]
fn main() {
// 1. poll() signature — all futures are polled through Pin
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Output>;
// 2. Box::pin() — heap-allocate and pin a future
let future: Pin<Box<dyn Future<Output = i32>>> = Box::pin(async { 42 });
// 3. tokio::pin!() — pin a future on the stack
tokio::pin!(my_future);
// Now my_future: Pin<&mut impl Future>
}
The Unpin Escape Hatch
Most types in Rust are Unpin — they don’t contain self-references, so pinning is a no-op. Only compiler-generated state machines (from async fn) are !Unpin.
#![allow(unused)]
fn main() {
// These are all Unpin — pinning them does nothing special:
// i32, String, Vec<T>, HashMap<K,V>, Box<T>, &T, &mut T
// These are !Unpin — they MUST be pinned before polling:
// The state machines generated by `async fn` and `async {}`
// Practical implication:
// If you write a Future by hand and it has NO self-references,
// implement Unpin to make it easier to work with:
impl Unpin for MySimpleFuture {} // "I'm safe to move, trust me"
}
Quick Reference
| What | When | How |
|---|---|---|
| Pin a future on the heap | Storing in a collection, returning from function | Box::pin(future) |
| Pin a future on the stack | Local use in select! or manual polling | tokio::pin!(future) or pin_mut! from pin-utils |
| Pin in function signature | Accepting pinned futures | future: Pin<&mut F> |
| Require Unpin | When you need to move a future after creation | F: Future + Unpin |
🏋️ Exercise: Pin and Move (click to expand)
Challenge: Which of these code snippets compile? For each one that doesn’t, explain why and fix it.
#![allow(unused)]
fn main() {
// Snippet A
let fut = async { 42 };
let pinned = Box::pin(fut);
let moved = pinned; // Move the Box
let result = moved.await;
// Snippet B
let fut = async { 42 };
tokio::pin!(fut);
let moved = fut; // Move the pinned future
let result = moved.await;
// Snippet C
use std::pin::Pin;
let mut fut = async { 42 };
let pinned = Pin::new(&mut fut);
}
🔑 Solution
Snippet A: ✅ Compiles. Box::pin() puts the future on the heap. Moving the Box moves the pointer, not the future itself. The future stays pinned in its heap location.
Snippet B: ✅ Compiles. tokio::pin! pins the future to the stack and rebinds fut as Pin<&mut ...>. let moved = fut moves the Pin wrapper (a pointer), not the underlying future — the future stays pinned on the stack. This is just like Box::pin: moving the Box doesn’t move the heap allocation. However, fut is consumed by the move, so you can’t use fut afterwards — only moved:
#![allow(unused)]
fn main() {
let fut = async { 42 };
tokio::pin!(fut);
let moved = fut; // Moves the Pin<&mut> wrapper — OK
// fut.await; // ❌ Error: fut was moved
let result = moved.await; // ✅ Use moved instead
}
Snippet C: ❌ Does not compile. Pin::new() requires T: Unpin. Async blocks generate !Unpin types. Fix: Use Box::pin() or unsafe Pin::new_unchecked():
#![allow(unused)]
fn main() {
let fut = async { 42 };
let pinned = Box::pin(fut); // Heap-pin — works with !Unpin
}
Key takeaway: Box::pin() is the safe, easy way to pin !Unpin futures. tokio::pin!() pins on the stack — you can move the Pin<&mut> wrapper (it’s just a pointer), but the underlying future stays put. Pin::new() only works with Unpin types.
Key Takeaways — Pin and Unpin
Pin<P>is a wrapper that prevents the pointee from being moved — essential for self-referential state machinesBox::pin()is the safe, easy default for pinning futures on the heaptokio::pin!()pins on the stack — you can move thePin<&mut>wrapper, but the underlying future stays putUnpinis an auto-trait opt-out: types that implementUnpincan be moved even when pinned (most types areUnpin; async blocks are not)
See also: Ch 2 — The Future Trait for
Pin<&mut Self>in poll, Ch 5 — The State Machine Reveal for why async state machines are self-referential
5. The State Machine Reveal 🟢
What you’ll learn:
- How the compiler transforms
async fninto an enum state machine- Side-by-side comparison: source code vs generated states
- Why large stack allocations in
async fnblow up future sizes- The drop optimization: values drop as soon as they’re no longer needed
What the Compiler Actually Generates
When you write async fn, the compiler transforms your sequential-looking code into an enum-based state machine. Understanding this transformation is the key to understanding async Rust’s performance characteristics and many of its quirks.
Side-by-Side: async fn vs State Machine
#![allow(unused)]
fn main() {
// What you write:
async fn fetch_two_pages() -> String {
let page1 = http_get("https://example.com/a").await;
let page2 = http_get("https://example.com/b").await;
format!("{page1}\n{page2}")
}
}
The compiler generates something conceptually like this:
#![allow(unused)]
fn main() {
enum FetchTwoPagesStateMachine {
// State 0: About to call http_get for page1
Start,
// State 1: Waiting for page1, holding the future
WaitingPage1 {
fut1: HttpGetFuture,
},
// State 2: Got page1, waiting for page2
WaitingPage2 {
page1: String,
fut2: HttpGetFuture,
},
// Terminal state
Complete,
}
impl Future for FetchTwoPagesStateMachine {
type Output = String;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<String> {
loop {
match self.as_mut().get_mut() {
Self::Start => {
let fut1 = http_get("https://example.com/a");
*self.as_mut().get_mut() = Self::WaitingPage1 { fut1 };
}
Self::WaitingPage1 { fut1 } => {
let page1 = match Pin::new(fut1).poll(cx) {
Poll::Ready(v) => v,
Poll::Pending => return Poll::Pending,
};
let fut2 = http_get("https://example.com/b");
*self.as_mut().get_mut() = Self::WaitingPage2 { page1, fut2 };
}
Self::WaitingPage2 { page1, fut2 } => {
let page2 = match Pin::new(fut2).poll(cx) {
Poll::Ready(v) => v,
Poll::Pending => return Poll::Pending,
};
let result = format!("{page1}\n{page2}");
*self.as_mut().get_mut() = Self::Complete;
return Poll::Ready(result);
}
Self::Complete => panic!("polled after completion"),
}
}
}
}
}
Note: This desugaring is conceptual. The real compiler output uses
unsafepin projections — theget_mut()calls shown here requireUnpin, but async state machines are!Unpin. The goal is to illustrate state transitions, not produce compilable code.
stateDiagram-v2
[*] --> Start
Start --> WaitingPage1: Create http_get future #1
WaitingPage1 --> WaitingPage1: poll() → Pending
WaitingPage1 --> WaitingPage2: poll() → Ready(page1)
WaitingPage2 --> WaitingPage2: poll() → Pending
WaitingPage2 --> Complete: poll() → Ready(page2)
Complete --> [*]: Return format!("{page1}\\n{page2}")
State contents:
- WaitingPage1 — stores
fut1: HttpGetFuture(page2 not yet allocated)- WaitingPage2 — stores
page1: String,fut2: HttpGetFuture(fut1 has been dropped)
Why This Matters for Performance
Zero-cost: The state machine is a stack-allocated enum. No heap allocation per future, no garbage collector, no boxing — unless you explicitly use Box::pin().
Size: The enum’s size is the maximum of all its variants. Each .await point creates a new variant. This means:
#![allow(unused)]
fn main() {
async fn small() {
let a: u8 = 0;
yield_now().await;
let b: u8 = 0;
yield_now().await;
}
// Size ≈ max(size_of(u8), size_of(u8)) + discriminant + future sizes
// ≈ small!
async fn big() {
let buf: [u8; 1_000_000] = [0; 1_000_000]; // 1MB on the stack!
some_io().await;
process(&buf);
}
// Size ≈ 1MB + inner future sizes
// ⚠️ Don't stack-allocate huge buffers in async functions!
// Use Vec<u8> or Box<[u8]> instead.
}
Drop optimization: When a state machine transitions, it drops values no longer needed. In the example above, fut1 is dropped when we transition from WaitingPage1 to WaitingPage2 — the compiler inserts the drop automatically.
Practical rule: Large stack allocations in
async fnblow up the future’s size. If you see stack overflows in async code, check for large arrays or deeply nested futures. UseBox::pin()to heap-allocate sub-futures if needed.
Exercise: Predict the State Machine
🏋️ Exercise (click to expand)
Challenge: Given this async function, sketch the state machine the compiler generates. How many states (enum variants) does it have? What values are stored in each?
#![allow(unused)]
fn main() {
async fn pipeline(url: &str) -> Result<usize, Error> {
let response = fetch(url).await?;
let body = response.text().await?;
let parsed = parse(body).await?;
Ok(parsed.len())
}
}
🔑 Solution
Five states:
- Start — stores
url - WaitingFetch — stores
url,fetchfuture - WaitingText — stores
response,text()future - WaitingParse — stores
body,parsefuture - Done — returned
Ok(parsed.len())
Each .await creates a yield point = a new enum variant. The ? adds early-exit paths but doesn’t add extra states — it’s just a match on the Poll::Ready value.
Key Takeaways — The State Machine Reveal
async fncompiles to an enum with one variant per.awaitpoint- The future’s size = max of all variant sizes — large stack values blow it up
- The compiler inserts drops at state transitions automatically
- Use
Box::pin()or heap allocation when future size becomes a problem
See also: Ch 4 — Pin and Unpin for why the generated enum needs pinning, Ch 6 — Building Futures by Hand to build these state machines yourself
6. Building Futures by Hand 🟡
What you’ll learn:
- Implementing a
TimerFuturewith thread-based waking- Building a
Joincombinator: run two futures concurrently- Building a
Selectcombinator: race two futures- How combinators compose — futures all the way down
A Simple Timer Future
Now let’s build real, useful futures from scratch. This cements the theory from chapters 2-5.
TimerFuture: A Complete Example
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use std::thread;
use std::time::{Duration, Instant};
pub struct TimerFuture {
shared_state: Arc<Mutex<SharedState>>,
}
struct SharedState {
completed: bool,
waker: Option<Waker>,
}
impl TimerFuture {
pub fn new(duration: Duration) -> Self {
let shared_state = Arc::new(Mutex::new(SharedState {
completed: false,
waker: None,
}));
// Spawn a thread that sets completed=true after the duration
let thread_shared_state = Arc::clone(&shared_state);
thread::spawn(move || {
thread::sleep(duration);
let mut state = thread_shared_state.lock().unwrap();
state.completed = true;
if let Some(waker) = state.waker.take() {
waker.wake(); // Notify the executor
}
});
TimerFuture { shared_state }
}
}
impl Future for TimerFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let mut state = self.shared_state.lock().unwrap();
if state.completed {
Poll::Ready(())
} else {
// Store the waker so the timer thread can wake us
// IMPORTANT: Always update the waker — the executor may
// have changed it between polls
state.waker = Some(cx.waker().clone());
Poll::Pending
}
}
}
// Usage:
// async fn example() {
// println!("Starting timer...");
// TimerFuture::new(Duration::from_secs(2)).await;
// println!("Timer done!");
// }
//
// ⚠️ This spawns an OS thread per timer — fine for learning, but in
// production use `tokio::time::sleep` which is backed by a shared
// timer wheel and requires zero extra threads.
}
Join: Running Two Futures Concurrently
Join polls two futures and completes when both finish. This is how tokio::join! works internally:
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
/// Polls two futures concurrently, returns both results as a tuple
pub struct Join<A, B>
where
A: Future,
B: Future,
{
a: MaybeDone<A>,
b: MaybeDone<B>,
}
enum MaybeDone<F: Future> {
Pending(F),
Done(F::Output),
Taken, // Output has been taken
}
// MaybeDone<F> stores F::Output, which the compiler can't prove
// is Unpin even when F: Unpin. Since we only use Join with Unpin
// futures and never pin-project into fields, implementing Unpin
// by hand is safe and lets us call self.get_mut() in poll().
impl<A: Future + Unpin, B: Future + Unpin> Unpin for Join<A, B> {}
impl<A, B> Join<A, B>
where
A: Future,
B: Future,
{
pub fn new(a: A, b: B) -> Self {
Join {
a: MaybeDone::Pending(a),
b: MaybeDone::Pending(b),
}
}
}
impl<A, B> Future for Join<A, B>
where
A: Future + Unpin,
B: Future + Unpin,
{
type Output = (A::Output, B::Output);
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
// Poll A if not done
if let MaybeDone::Pending(ref mut fut) = this.a {
if let Poll::Ready(val) = Pin::new(fut).poll(cx) {
this.a = MaybeDone::Done(val);
}
}
// Poll B if not done
if let MaybeDone::Pending(ref mut fut) = this.b {
if let Poll::Ready(val) = Pin::new(fut).poll(cx) {
this.b = MaybeDone::Done(val);
}
}
// Both done?
match (&this.a, &this.b) {
(MaybeDone::Done(_), MaybeDone::Done(_)) => {
// Take both outputs
let a_val = match std::mem::replace(&mut this.a, MaybeDone::Taken) {
MaybeDone::Done(v) => v,
_ => unreachable!(),
};
let b_val = match std::mem::replace(&mut this.b, MaybeDone::Taken) {
MaybeDone::Done(v) => v,
_ => unreachable!(),
};
Poll::Ready((a_val, b_val))
}
_ => Poll::Pending, // At least one is still pending
}
}
}
// Usage (async blocks are !Unpin, so wrap them with Box::pin):
// let (page1, page2) = Join::new(
// Box::pin(http_get("https://example.com/a")),
// Box::pin(http_get("https://example.com/b")),
// ).await;
// Both requests run concurrently!
}
Key insight: “Concurrent” here means interleaved on the same thread. Join doesn’t spawn threads — it polls both futures in the same
poll()call. This is cooperative concurrency, not parallelism.
graph LR
subgraph "Future Combinators"
direction TB
TIMER["TimerFuture<br/>Single future, wake after delay"]
JOIN["Join<A, B><br/>Wait for BOTH"]
SELECT["Select<A, B><br/>Wait for FIRST"]
RETRY["RetryFuture<br/>Re-create on failure"]
end
TIMER --> JOIN
TIMER --> SELECT
SELECT --> RETRY
style TIMER fill:#d4efdf,stroke:#27ae60,color:#000
style JOIN fill:#e8f4f8,stroke:#2980b9,color:#000
style SELECT fill:#fef9e7,stroke:#f39c12,color:#000
style RETRY fill:#fadbd8,stroke:#e74c3c,color:#000
Select: Racing Two Futures
Select completes when either future finishes first (the other is dropped):
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub enum Either<A, B> {
Left(A),
Right(B),
}
/// Returns whichever future completes first; drops the other
pub struct Select<A, B> {
a: A,
b: B,
}
impl<A, B> Select<A, B>
where
A: Future + Unpin,
B: Future + Unpin,
{
pub fn new(a: A, b: B) -> Self {
Select { a, b }
}
}
impl<A, B> Future for Select<A, B>
where
A: Future + Unpin,
B: Future + Unpin,
{
type Output = Either<A::Output, B::Output>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// Poll A first
if let Poll::Ready(val) = Pin::new(&mut self.a).poll(cx) {
return Poll::Ready(Either::Left(val));
}
// Then poll B
if let Poll::Ready(val) = Pin::new(&mut self.b).poll(cx) {
return Poll::Ready(Either::Right(val));
}
Poll::Pending
}
}
// Usage with timeout:
// match Select::new(http_get(url), TimerFuture::new(timeout)).await {
// Either::Left(response) => println!("Got response: {}", response),
// Either::Right(()) => println!("Request timed out!"),
// }
}
Fairness note: Our
Selectalways polls A first — if both are ready, A always wins. Tokio’sselect!macro randomizes the poll order for fairness.
🏋️ Exercise: Build a RetryFuture (click to expand)
Challenge: Build a RetryFuture<F, Fut> that takes a closure F: Fn() -> Fut and retries up to N times if the inner future returns Err. It should return the first Ok result or the last Err.
Hint: You’ll need states for “running attempt” and “all attempts exhausted.”
🔑 Solution
#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub struct RetryFuture<F, Fut, T, E>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, E>> + Unpin,
{
factory: F,
current: Option<Fut>,
remaining: usize,
last_error: Option<E>,
}
impl<F, Fut, T, E> RetryFuture<F, Fut, T, E>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, E>> + Unpin,
{
pub fn new(max_attempts: usize, factory: F) -> Self {
let current = Some((factory)());
RetryFuture {
factory,
current,
remaining: max_attempts.saturating_sub(1),
last_error: None,
}
}
}
impl<F, Fut, T, E> Future for RetryFuture<F, Fut, T, E>
where
F: Fn() -> Fut + Unpin,
Fut: Future<Output = Result<T, E>> + Unpin,
T: Unpin,
E: Unpin,
{
type Output = Result<T, E>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
loop {
if let Some(ref mut fut) = self.current {
match Pin::new(fut).poll(cx) {
Poll::Ready(Ok(val)) => return Poll::Ready(Ok(val)),
Poll::Ready(Err(e)) => {
self.last_error = Some(e);
if self.remaining > 0 {
self.remaining -= 1;
self.current = Some((self.factory)());
// Loop to poll the new future immediately
} else {
return Poll::Ready(Err(self.last_error.take().unwrap()));
}
}
Poll::Pending => return Poll::Pending,
}
} else {
return Poll::Ready(Err(self.last_error.take().unwrap()));
}
}
}
}
// Usage:
// let result = RetryFuture::new(3, || async {
// http_get("https://flaky-server.com/api").await
// }).await;
}
Key takeaway: The retry future is itself a state machine: it holds the current attempt and creates new inner futures on failure. This is how combinators compose — futures all the way down.
Key Takeaways — Building Futures by Hand
- A future needs three things: state, a
poll()implementation, and a waker registrationJoinpolls both sub-futures;Selectreturns whichever finishes first- Combinators are themselves futures wrapping other futures — it’s turtles all the way down
- Building futures by hand gives deep insight, but in production use
tokio::join!/select!
See also: Ch 2 — The Future Trait for the trait definition, Ch 8 — Tokio Deep Dive for production-grade equivalents
7. Executors and Runtimes 🟡
What you’ll learn:
- What an executor does: poll + sleep efficiently
- The six major runtimes: mio, io_uring, tokio, async-std, smol, embassy
- A decision tree for choosing the right runtime
- Why runtime-agnostic library design matters
What an Executor Does
An executor has two jobs:
- Poll futures when they’re ready to make progress
- Sleep efficiently when no futures are ready (using OS I/O notification APIs)
graph TB
subgraph Executor["Executor (e.g., tokio)"]
QUEUE["Task Queue"]
POLLER["I/O Poller<br/>(epoll/kqueue/io_uring)"]
THREADS["Worker Thread Pool"]
end
subgraph Tasks
T1["Task 1<br/>(HTTP request)"]
T2["Task 2<br/>(DB query)"]
T3["Task 3<br/>(File read)"]
end
subgraph OS["Operating System"]
NET["Network Stack"]
DISK["Disk I/O"]
end
T1 --> QUEUE
T2 --> QUEUE
T3 --> QUEUE
QUEUE --> THREADS
THREADS -->|"poll()"| T1
THREADS -->|"poll()"| T2
THREADS -->|"poll()"| T3
POLLER <-->|"register/notify"| NET
POLLER <-->|"register/notify"| DISK
POLLER -->|"wake tasks"| QUEUE
style Executor fill:#e3f2fd,color:#000
style OS fill:#f3e5f5,color:#000
mio: The Foundation Layer
mio (Metal I/O) is not an executor — it’s the lowest-level cross-platform I/O notification library. It wraps epoll (Linux), kqueue (macOS/BSD), and IOCP (Windows).
#![allow(unused)]
fn main() {
// Conceptual mio usage (simplified):
use mio::{Events, Interest, Poll, Token};
use mio::net::TcpListener;
let mut poll = Poll::new()?;
let mut events = Events::with_capacity(128);
let mut server = TcpListener::bind("0.0.0.0:8080")?;
poll.registry().register(&mut server, Token(0), Interest::READABLE)?;
// Event loop — blocks until something happens
loop {
poll.poll(&mut events, None)?; // Sleeps until I/O event
for event in events.iter() {
match event.token() {
Token(0) => { /* server has a new connection */ }
_ => { /* other I/O ready */ }
}
}
}
}
Most developers never touch mio directly — tokio and smol build on top of it.
io_uring: The Completion-Based Future
Linux’s io_uring (kernel 5.1+) represents a fundamental shift from the readiness-based I/O model that mio/epoll use:
Readiness-based (epoll / mio / tokio):
1. Ask: "Is this socket readable?" → epoll_wait()
2. Kernel: "Yes, it's ready" → EPOLLIN event
3. App: read(fd, buf) → might still block briefly!
Completion-based (io_uring):
1. Submit: "Read from this socket into this buffer" → SQE
2. Kernel: does the read asynchronously
3. App: gets completed result with data → CQE
graph LR
subgraph "Readiness Model (epoll)"
A1["App: is it ready?"] --> K1["Kernel: yes"]
K1 --> A2["App: now read()"]
A2 --> K2["Kernel: here's data"]
end
subgraph "Completion Model (io_uring)"
B1["App: read this for me"] --> K3["Kernel: working..."]
K3 --> B2["App: got result + data"]
end
style B1 fill:#c8e6c9,color:#000
style B2 fill:#c8e6c9,color:#000
The ownership challenge: io_uring requires the kernel to own the buffer until the operation completes. This conflicts with Rust’s standard AsyncRead trait which borrows the buffer. That’s why tokio-uring has different I/O traits:
#![allow(unused)]
fn main() {
// Standard tokio (readiness-based) — borrows the buffer:
let n = stream.read(&mut buf).await?; // buf is borrowed
// tokio-uring (completion-based) — takes ownership of the buffer:
let (result, buf) = stream.read(buf).await; // buf is moved in, returned back
let n = result?;
}
// Cargo.toml: tokio-uring = "0.5"
// NOTE: Linux-only, requires kernel 5.1+
fn main() {
tokio_uring::start(async {
let file = tokio_uring::fs::File::open("data.bin").await.unwrap();
let buf = vec![0u8; 4096];
let (result, buf) = file.read_at(buf, 0).await;
let bytes_read = result.unwrap();
println!("Read {} bytes: {:?}", bytes_read, &buf[..bytes_read]);
});
}
| Aspect | epoll (tokio) | io_uring (tokio-uring) |
|---|---|---|
| Model | Readiness notification | Completion notification |
| Syscalls | epoll_wait + read/write | Batched SQE/CQE ring |
| Buffer ownership | App retains (&mut buf) | Ownership transfer (move buf) |
| Platform | Linux, macOS (kqueue), Windows (IOCP) | Linux 5.1+ only |
| Zero-copy | No (userspace copy) | Yes (registered buffers) |
| Maturity | Production-ready | Experimental |
When to use io_uring: High-throughput file I/O or networking where syscall overhead is the bottleneck (databases, storage engines, proxies serving 100k+ connections). For most applications, standard tokio with epoll is the right choice.
tokio: The Batteries-Included Runtime
The dominant async runtime in the Rust ecosystem. Used by Axum, Hyper, Tonic, and most production Rust servers.
// Cargo.toml:
// [dependencies]
// tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
// Spawns a multi-threaded runtime with work-stealing scheduler
let handle = tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
"done"
});
let result = handle.await.unwrap();
println!("{result}");
}
tokio features: Timer, I/O, TCP/UDP, Unix sockets, signal handling, sync primitives (Mutex, RwLock, Semaphore, channels), fs, process, tracing integration.
async-std: The Standard Library Mirror
Mirrors the std API with async versions. Less popular than tokio but simpler for beginners.
// Cargo.toml:
// [dependencies]
// async-std = { version = "1", features = ["attributes"] }
#[async_std::main]
async fn main() {
use async_std::fs;
let content = fs::read_to_string("hello.txt").await.unwrap();
println!("{content}");
}
smol: The Minimalist Runtime
Small, zero-dependency async runtime. Great for libraries that want async without pulling in tokio.
// Cargo.toml:
// [dependencies]
// smol = "2"
fn main() {
smol::block_on(async {
let result = smol::unblock(|| {
// Runs blocking code on a thread pool
std::fs::read_to_string("hello.txt")
}).await.unwrap();
println!("{result}");
});
}
embassy: Async for Embedded (no_std)
Async runtime for embedded systems. No heap allocation, no std required.
// Runs on microcontrollers (e.g., STM32, nRF52, RP2040)
#[embassy_executor::main]
async fn main(spawner: embassy_executor::Spawner) {
// Blink an LED with async/await — no RTOS needed!
let mut led = Output::new(p.PA5, Level::Low, Speed::Low);
loop {
led.set_high();
Timer::after(Duration::from_millis(500)).await;
led.set_low();
Timer::after(Duration::from_millis(500)).await;
}
}
Runtime Decision Tree
graph TD
START["Choosing a Runtime"]
Q1{"Building a<br/>network server?"}
Q2{"Need tokio ecosystem<br/>(Axum, Tonic, Hyper)?"}
Q3{"Building a library?"}
Q4{"Embedded /<br/>no_std?"}
Q5{"Want minimal<br/>dependencies?"}
TOKIO["🟢 tokio<br/>Best ecosystem, most popular"]
SMOL["🔵 smol<br/>Minimal, no ecosystem lock-in"]
EMBASSY["🟠 embassy<br/>Embedded-first, no alloc"]
ASYNC_STD["🟣 async-std<br/>std-like API, good for learning"]
AGNOSTIC["🔵 runtime-agnostic<br/>Use futures crate only"]
START --> Q1
Q1 -->|Yes| Q2
Q1 -->|No| Q3
Q2 -->|Yes| TOKIO
Q2 -->|No| Q5
Q3 -->|Yes| AGNOSTIC
Q3 -->|No| Q4
Q4 -->|Yes| EMBASSY
Q4 -->|No| Q5
Q5 -->|Yes| SMOL
Q5 -->|No| ASYNC_STD
style TOKIO fill:#c8e6c9,color:#000
style SMOL fill:#bbdefb,color:#000
style EMBASSY fill:#ffe0b2,color:#000
style ASYNC_STD fill:#e1bee7,color:#000
style AGNOSTIC fill:#bbdefb,color:#000
Runtime Comparison Table
| Feature | tokio | async-std | smol | embassy |
|---|---|---|---|---|
| Ecosystem | Dominant | Small | Minimal | Embedded |
| Multi-threaded | ✅ Work-stealing | ✅ | ✅ | ❌ (single-core) |
| no_std | ❌ | ❌ | ❌ | ✅ |
| Timer | ✅ Built-in | ✅ Built-in | Via async-io | ✅ HAL-based |
| I/O | ✅ Own abstractions | ✅ std mirror | ✅ Via async-io | ✅ HAL drivers |
| Channels | ✅ Rich set | ✅ | Via async-channel | ✅ |
| Learning curve | Medium | Low | Low | High (HW) |
| Binary size | Large | Medium | Small | Tiny |
🏋️ Exercise: Runtime Comparison (click to expand)
Challenge: Write the same program using three different runtimes (tokio, smol, and async-std). The program should:
- Fetch a URL (simulate with a sleep)
- Read a file (simulate with a sleep)
- Print both results
This exercise demonstrates that the async/await code is the same — only the runtime setup differs.
🔑 Solution
// ----- tokio version -----
// Cargo.toml: tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
let (url_result, file_result) = tokio::join!(
async {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
"Response from URL"
},
async {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
"Contents of file"
},
);
println!("URL: {url_result}, File: {file_result}");
}
// ----- smol version -----
// Cargo.toml: smol = "2", futures-lite = "2"
fn main() {
smol::block_on(async {
let (url_result, file_result) = futures_lite::future::zip(
async {
smol::Timer::after(std::time::Duration::from_millis(100)).await;
"Response from URL"
},
async {
smol::Timer::after(std::time::Duration::from_millis(50)).await;
"Contents of file"
},
).await;
println!("URL: {url_result}, File: {file_result}");
});
}
// ----- async-std version -----
// Cargo.toml: async-std = { version = "1", features = ["attributes"] }
#[async_std::main]
async fn main() {
let (url_result, file_result) = futures::future::join(
async {
async_std::task::sleep(std::time::Duration::from_millis(100)).await;
"Response from URL"
},
async {
async_std::task::sleep(std::time::Duration::from_millis(50)).await;
"Contents of file"
},
).await;
println!("URL: {url_result}, File: {file_result}");
}
Key takeaway: The async business logic is identical across runtimes. Only the entry point and timer/IO APIs differ. This is why writing runtime-agnostic libraries (using only std::future::Future) is valuable.
Key Takeaways — Executors and Runtimes
- An executor’s job: poll futures when woken, sleep efficiently using OS I/O APIs
- tokio is the default for servers; smol for minimal footprint; embassy for embedded
- Your business logic should depend on
std::future::Future, not a specific runtime- io_uring (Linux 5.1+) is the future of high-perf I/O but the ecosystem is still maturing
See also: Ch 8 — Tokio Deep Dive for tokio specifics, Ch 9 — When Tokio Isn’t the Right Fit for alternatives
8. Tokio Deep Dive 🟡
What you’ll learn:
- Runtime flavors: multi-thread vs current-thread and when to use each
tokio::spawn, the'staticrequirement, andJoinHandle- Task cancellation semantics (cancel-on-drop)
- Sync primitives: Mutex, RwLock, Semaphore, and all four channel types
Runtime Flavors: Multi-Thread vs Current-Thread
Tokio offers two runtime configurations:
// Multi-threaded (default with #[tokio::main])
// Uses a work-stealing thread pool — tasks can move between threads
#[tokio::main]
async fn main() {
// N worker threads (default = number of CPU cores)
// Tasks are Send + 'static
}
// Current-thread — everything runs on one thread
#[tokio::main(flavor = "current_thread")]
async fn main() {
// Single-threaded — tasks don't need to be Send
// Lighter weight, good for simple tools or WASM
}
// Manual runtime construction:
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.unwrap();
rt.block_on(async {
println!("Running on custom runtime");
});
graph TB
subgraph "Multi-Thread (default)"
MT_Q1["Thread 1<br/>Task A, Task D"]
MT_Q2["Thread 2<br/>Task B"]
MT_Q3["Thread 3<br/>Task C, Task E"]
STEAL["Work Stealing:<br/>idle threads steal from busy ones"]
MT_Q1 <--> STEAL
MT_Q2 <--> STEAL
MT_Q3 <--> STEAL
end
subgraph "Current-Thread"
ST_Q["Single Thread<br/>Task A → Task B → Task C → Task D"]
end
style MT_Q1 fill:#c8e6c9,color:#000
style MT_Q2 fill:#c8e6c9,color:#000
style MT_Q3 fill:#c8e6c9,color:#000
style ST_Q fill:#bbdefb,color:#000
tokio::spawn and the ’static Requirement
tokio::spawn puts a future onto the runtime’s task queue. Because it might run on any worker thread at any time, the future must be Send + 'static:
#![allow(unused)]
fn main() {
use tokio::task;
async fn example() {
let data = String::from("hello");
// ✅ Works: move ownership into the task
let handle = task::spawn(async move {
println!("{data}");
data.len()
});
let len = handle.await.unwrap();
println!("Length: {len}");
}
async fn problem() {
let data = String::from("hello");
// ❌ FAILS: data is borrowed, not 'static
// task::spawn(async {
// println!("{data}"); // borrows `data` — not 'static
// });
// ❌ FAILS: Rc is not Send
// let rc = std::rc::Rc::new(42);
// task::spawn(async move {
// println!("{rc}"); // Rc is !Send — can't cross thread boundary
// });
}
}
Why 'static? The spawned task runs independently — it might outlive the scope that created it. The compiler can’t prove the references will remain valid, so it requires owned data.
Why Send? The task might be resumed on a different thread than where it was suspended. All data held across .await points must be safe to send between threads.
#![allow(unused)]
fn main() {
// Common pattern: clone shared data into the task
let shared = Arc::new(config);
for i in 0..10 {
let shared = Arc::clone(&shared); // Clone the Arc, not the data
tokio::spawn(async move {
process_item(i, &shared).await;
});
}
}
JoinHandle and Task Cancellation
#![allow(unused)]
fn main() {
use tokio::task::JoinHandle;
use tokio::time::{sleep, Duration};
async fn cancellation_example() {
let handle: JoinHandle<String> = tokio::spawn(async {
sleep(Duration::from_secs(10)).await;
"completed".to_string()
});
// Cancel the task by dropping the handle? NO — task keeps running!
// drop(handle); // Task continues in the background
// To actually cancel, call abort():
handle.abort();
// Awaiting an aborted task returns JoinError
match handle.await {
Ok(val) => println!("Got: {val}"),
Err(e) if e.is_cancelled() => println!("Task was cancelled"),
Err(e) => println!("Task panicked: {e}"),
}
}
}
Important: Dropping a
JoinHandledoes NOT cancel the task in tokio. The task becomes detached and keeps running. You must explicitly call.abort()to cancel it. This is different from dropping aFuturedirectly, which does cancel/drop the underlying computation.
Tokio Sync Primitives
Tokio provides async-aware synchronization primitives. The key principle: don’t use std::sync::Mutex across .await points.
#![allow(unused)]
fn main() {
use tokio::sync::{Mutex, RwLock, Semaphore, mpsc, oneshot, broadcast, watch};
// --- Mutex ---
// Async mutex: the lock() method is async and won't block the thread
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
{
let mut guard = data.lock().await; // Non-blocking lock
guard.push(4);
} // Guard dropped here — lock released
// --- Channels ---
// mpsc: Multiple producer, single consumer
let (tx, mut rx) = mpsc::channel::<String>(100); // Bounded buffer
tokio::spawn(async move {
tx.send("hello".into()).await.unwrap();
});
let msg = rx.recv().await.unwrap();
// oneshot: Single value, single consumer
let (tx, rx) = oneshot::channel::<i32>();
tx.send(42).unwrap(); // No await needed — either sends or fails
let val = rx.await.unwrap();
// broadcast: Multiple producers, multiple consumers (all get every message)
let (tx, _) = broadcast::channel::<String>(100);
let mut rx1 = tx.subscribe();
let mut rx2 = tx.subscribe();
// watch: Single value, multiple consumers (only latest value)
let (tx, rx) = watch::channel(0u64);
tx.send(42).unwrap();
println!("Latest: {}", *rx.borrow());
}
Note:
.unwrap()is used for brevity throughout these channel examples. In production, handle send/receive errors gracefully — a failed.send()means the receiver was dropped, and a failed.recv()means the channel is closed.
graph LR
subgraph "Channel Types"
direction TB
MPSC["mpsc<br/>N→1<br/>Buffered queue"]
ONESHOT["oneshot<br/>1→1<br/>Single value"]
BROADCAST["broadcast<br/>N→N<br/>All receivers get all"]
WATCH["watch<br/>1→N<br/>Latest value only"]
end
P1["Producer 1"] --> MPSC
P2["Producer 2"] --> MPSC
MPSC --> C1["Consumer"]
P3["Producer"] --> ONESHOT
ONESHOT --> C2["Consumer"]
P4["Producer"] --> BROADCAST
BROADCAST --> C3["Consumer 1"]
BROADCAST --> C4["Consumer 2"]
P5["Producer"] --> WATCH
WATCH --> C5["Consumer 1"]
WATCH --> C6["Consumer 2"]
Case Study: Choosing the Right Channel for a Notification Service
You’re building a notification service where:
- Multiple API handlers produce events
- A single background task batches and sends them
- A config watcher updates rate limits at runtime
- A shutdown signal must reach all components
Which channels for each?
| Requirement | Channel | Why |
|---|---|---|
| API handlers → Batcher | mpsc (bounded) | N producers, 1 consumer. Bounded for backpressure — if the batcher falls behind, API handlers slow down instead of OOM |
| Config watcher → Rate limiter | watch | Only the latest config matters. Multiple readers (each worker) see the current value |
| Shutdown signal → All components | broadcast | Every component must receive the shutdown notification independently |
| Single health-check response | oneshot | Request/response pattern — one value, then done |
graph LR
subgraph "Notification Service"
direction TB
API1["API Handler 1"] -->|mpsc| BATCH["Batcher"]
API2["API Handler 2"] -->|mpsc| BATCH
CONFIG["Config Watcher"] -->|watch| RATE["Rate Limiter"]
CTRL["Ctrl+C"] -->|broadcast| API1
CTRL -->|broadcast| BATCH
CTRL -->|broadcast| RATE
end
style API1 fill:#d4efdf,stroke:#27ae60,color:#000
style API2 fill:#d4efdf,stroke:#27ae60,color:#000
style BATCH fill:#e8f4f8,stroke:#2980b9,color:#000
style CONFIG fill:#fef9e7,stroke:#f39c12,color:#000
style RATE fill:#fef9e7,stroke:#f39c12,color:#000
style CTRL fill:#fadbd8,stroke:#e74c3c,color:#000
🏋️ Exercise: Build a Task Pool (click to expand)
Challenge: Build a function run_with_limit that accepts a list of async closures and a concurrency limit, executing at most N tasks simultaneously. Use tokio::sync::Semaphore.
🔑 Solution
#![allow(unused)]
fn main() {
use std::future::Future;
use std::sync::Arc;
use tokio::sync::Semaphore;
async fn run_with_limit<F, Fut, T>(tasks: Vec<F>, limit: usize) -> Vec<T>
where
F: FnOnce() -> Fut + Send + 'static,
Fut: Future<Output = T> + Send + 'static,
T: Send + 'static,
{
let semaphore = Arc::new(Semaphore::new(limit));
let mut handles = Vec::new();
for task in tasks {
let permit = Arc::clone(&semaphore);
let handle = tokio::spawn(async move {
let _permit = permit.acquire().await.unwrap();
// Permit is held while task runs, then dropped
task().await
});
handles.push(handle);
}
let mut results = Vec::new();
for handle in handles {
results.push(handle.await.unwrap());
}
results
}
// Usage:
// let tasks: Vec<_> = urls.into_iter().map(|url| {
// move || async move { fetch(url).await }
// }).collect();
// let results = run_with_limit(tasks, 10).await; // Max 10 concurrent
}
Key takeaway: Semaphore is the standard way to limit concurrency in tokio. Each task acquires a permit before starting work. When the semaphore is full, new tasks wait asynchronously (non-blocking) until a slot opens.
Key Takeaways — Tokio Deep Dive
- Use
multi_threadfor servers (default);current_threadfor CLI tools, tests, or!Sendtypestokio::spawnrequires'staticfutures — useArcor channels to share data- Dropping a
JoinHandledoes not cancel the task — call.abort()explicitly- Choose sync primitives by need:
Mutexfor shared state,Semaphorefor concurrency limits,mpsc/oneshot/broadcast/watchfor communication
See also: Ch 9 — When Tokio Isn’t the Right Fit for alternatives to spawn, Ch 12 — Common Pitfalls for MutexGuard-across-await bugs
9. When Tokio Isn’t the Right Fit 🟡
What you’ll learn:
- The
'staticproblem: whentokio::spawnforces you intoArceverywhereLocalSetfor!SendfuturesFuturesUnorderedfor borrow-friendly concurrency (no spawn needed)JoinSetfor managed task groups- Writing runtime-agnostic libraries
graph TD
START["Need concurrent futures?"] --> STATIC{"Can futures be 'static?"}
STATIC -->|Yes| SEND{"Are futures Send?"}
STATIC -->|No| FU["FuturesUnordered<br/>Runs on current task"]
SEND -->|Yes| SPAWN["tokio::spawn<br/>Multi-threaded"]
SEND -->|No| LOCAL["LocalSet<br/>Single-threaded"]
SPAWN --> MANAGE{"Need to track/abort tasks?"}
MANAGE -->|Yes| JOINSET["JoinSet / TaskTracker"]
MANAGE -->|No| HANDLE["JoinHandle"]
style START fill:#f5f5f5,stroke:#333,color:#000
style FU fill:#d4efdf,stroke:#27ae60,color:#000
style SPAWN fill:#e8f4f8,stroke:#2980b9,color:#000
style LOCAL fill:#fef9e7,stroke:#f39c12,color:#000
style JOINSET fill:#e8daef,stroke:#8e44ad,color:#000
style HANDLE fill:#e8f4f8,stroke:#2980b9,color:#000
The ’static Future Problem
Tokio’s spawn requires 'static futures. This means you can’t borrow local data in spawned tasks:
#![allow(unused)]
fn main() {
async fn process_items(items: &[String]) {
// ❌ Can't do this — items is borrowed, not 'static
// for item in items {
// tokio::spawn(async {
// process(item).await;
// });
// }
// 😐 Workaround 1: Clone everything
for item in items {
let item = item.clone();
tokio::spawn(async move {
process(&item).await;
});
}
// 😐 Workaround 2: Use Arc
let items = Arc::new(items.to_vec());
for i in 0..items.len() {
let items = Arc::clone(&items);
tokio::spawn(async move {
process(&items[i]).await;
});
}
}
}
This is annoying! In Go, you can just go func() { use(item) } with a closure. In Rust, the ownership system forces you to think about who owns what and how long it lives.
Alternatives to tokio::spawn
Not every problem requires spawn. Here are three tools that each solve a
different constraint:
#![allow(unused)]
fn main() {
// 1. FuturesUnordered — avoids 'static entirely (no spawn!)
use futures::stream::{FuturesUnordered, StreamExt};
async fn process_items(items: &[String]) {
let futures: FuturesUnordered<_> = items
.iter()
.map(|item| async move {
// ✅ Can borrow item — no spawn, no 'static needed!
process(item).await
})
.collect();
// Drive all futures to completion
futures.for_each(|result| async move {
println!("Result: {result:?}");
}).await;
}
// 2. tokio::task::LocalSet — run !Send futures on current thread
// ⚠️ Still requires 'static — solves Send, not 'static
use tokio::task::LocalSet;
let local_set = LocalSet::new();
local_set.run_until(async {
tokio::task::spawn_local(async {
// Can use Rc, Cell, and other !Send types here
let rc = std::rc::Rc::new(42);
println!("{rc}");
}).await.unwrap();
}).await;
// 3. tokio JoinSet (tokio 1.21+) — managed set of spawned tasks
// ⚠️ Still requires 'static + Send — solves task *management*,
// not the 'static problem. Useful for tracking, aborting, and
// joining a dynamic group of tasks.
use tokio::task::JoinSet;
async fn with_joinset() {
let mut set = JoinSet::new();
for i in 0..10 {
// i is Copy and moved into the closure — already 'static.
// You'd still need Arc or clone for borrowed data.
set.spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
i * 2
});
}
while let Some(result) = set.join_next().await {
println!("Task completed: {:?}", result.unwrap());
}
}
}
Which tool solves which problem?
Constraint you hit Tool Avoids 'static?Avoids Send?Can’t make futures 'staticFuturesUnordered✅ Yes ✅ Yes Futures are 'staticbut!SendLocalSet❌ No ✅ Yes Need to track / abort spawned tasks JoinSet❌ No ❌ No
Lightweight Runtimes for Libraries
If you’re writing a library — don’t force users into tokio:
#![allow(unused)]
fn main() {
// ❌ BAD: Library forces tokio on users
pub async fn my_lib_function() {
tokio::time::sleep(Duration::from_secs(1)).await;
// Now your users MUST use tokio
}
// ✅ GOOD: Library is runtime-agnostic
pub async fn my_lib_function() {
// Use only types from std::future and futures crate
do_computation().await;
}
// ✅ GOOD: Accept a generic future for I/O operations
pub async fn fetch_with_retry<F, Fut, T, E>(
operation: F,
max_retries: usize,
) -> Result<T, E>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, E>>,
{
for attempt in 0..max_retries {
match operation().await {
Ok(val) => return Ok(val),
Err(e) if attempt == max_retries - 1 => return Err(e),
Err(_) => continue,
}
}
unreachable!()
}
}
Rule of thumb: Libraries should depend on
futurescrate, nottokio. Applications should depend ontokio(or their chosen runtime). This keeps the ecosystem composable.
🏋️ Exercise: FuturesUnordered vs Spawn (click to expand)
Challenge: Write the same function two ways — once using tokio::spawn (requires 'static) and once using FuturesUnordered (borrows data). The function receives &[String] and returns the length of each string after a simulated async lookup.
Compare: Which approach requires .clone()? Which can borrow the input slice?
🔑 Solution
#![allow(unused)]
fn main() {
use futures::stream::{FuturesUnordered, StreamExt};
use tokio::time::{sleep, Duration};
// Version 1: tokio::spawn — requires 'static, must clone
async fn lengths_with_spawn(items: &[String]) -> Vec<usize> {
let mut handles = Vec::new();
for item in items {
let owned = item.clone(); // Must clone — spawn requires 'static
handles.push(tokio::spawn(async move {
sleep(Duration::from_millis(10)).await;
owned.len()
}));
}
let mut results = Vec::new();
for handle in handles {
results.push(handle.await.unwrap());
}
results
}
// Version 2: FuturesUnordered — borrows data, no clone needed
async fn lengths_without_spawn(items: &[String]) -> Vec<usize> {
let futures: FuturesUnordered<_> = items
.iter()
.map(|item| async move {
sleep(Duration::from_millis(10)).await;
item.len() // ✅ Borrows item — no clone!
})
.collect();
futures.collect().await
}
#[tokio::test]
async fn test_both_versions() {
let items = vec!["hello".into(), "world".into(), "rust".into()];
let v1 = lengths_with_spawn(&items).await;
// Note: v1 preserves insertion order (sequential join)
let mut v2 = lengths_without_spawn(&items).await;
v2.sort(); // FuturesUnordered returns in completion order
assert_eq!(v1, vec![5, 5, 4]);
assert_eq!(v2, vec![4, 5, 5]);
}
}
Key takeaway: FuturesUnordered avoids the 'static requirement by running all futures on the current task (no thread migration). The trade-off: all futures share one task — if one blocks, the others stall. Use spawn for CPU-heavy work that should run on separate threads.
Key Takeaways — When Tokio Isn’t the Right Fit
FuturesUnorderedruns futures concurrently on the current task — no'staticrequirementLocalSetenables!Sendfutures on a single-threaded executorJoinSet(tokio 1.21+) provides managed task groups with automatic cleanup- For libraries: depend only on
std::future::Future+futurescrate, not tokio directly
See also: Ch 8 — Tokio Deep Dive for when spawn is the right tool, Ch 11 — Streams for
buffer_unordered()as another concurrency limiter
10. Async Traits 🟡
What you’ll learn:
- Why async methods in traits took years to stabilize
- RPITIT: native async trait methods (Rust 1.75+)
- The dyn dispatch challenge and
trait_variantworkaround- Async closures (Rust 1.85+):
async Fn()andasync FnOnce()
graph TD
subgraph "Async Trait Approaches"
direction TB
RPITIT["RPITIT (Rust 1.75+)<br/>async fn in trait<br/>Static dispatch only"]
VARIANT["trait_variant<br/>Auto-generates Send variant<br/>Enables dyn dispatch"]
BOXED["Box<dyn Future><br/>Manual boxing<br/>Works everywhere"]
CLOSURE["Async Closures (1.85+)<br/>async Fn() / async FnOnce()<br/>Callbacks & middleware"]
end
RPITIT -->|"Need dyn?"| VARIANT
RPITIT -->|"Pre-1.75?"| BOXED
CLOSURE -->|"Replaces"| BOXED
style RPITIT fill:#d4efdf,stroke:#27ae60,color:#000
style VARIANT fill:#e8f4f8,stroke:#2980b9,color:#000
style BOXED fill:#fef9e7,stroke:#f39c12,color:#000
style CLOSURE fill:#e8daef,stroke:#8e44ad,color:#000
The History: Why It Took So Long
Async methods in traits were Rust’s most requested feature for years. The problem:
#![allow(unused)]
fn main() {
// This didn't compile until Rust 1.75 (Dec 2023):
trait DataStore {
async fn get(&self, key: &str) -> Option<String>;
}
// Why? Because async fn returns `impl Future<Output = T>`,
// and `impl Trait` in trait return position wasn't supported.
}
The fundamental challenge: when a trait method returns impl Future, each implementor returns a different concrete type. The compiler needs to know the size of the return type, but trait methods are dynamically dispatched.
RPITIT: Return Position Impl Trait in Trait
Since Rust 1.75, this just works for static dispatch:
#![allow(unused)]
fn main() {
trait DataStore {
async fn get(&self, key: &str) -> Option<String>;
// Desugars to:
// fn get(&self, key: &str) -> impl Future<Output = Option<String>>;
}
struct InMemoryStore {
data: std::collections::HashMap<String, String>,
}
impl DataStore for InMemoryStore {
async fn get(&self, key: &str) -> Option<String> {
self.data.get(key).cloned()
}
}
// ✅ Works with generics (static dispatch):
async fn lookup<S: DataStore>(store: &S, key: &str) {
if let Some(val) = store.get(key).await {
println!("{key} = {val}");
}
}
}
dyn Dispatch and Send Bounds
The limitation: you can’t use dyn DataStore directly because the compiler doesn’t know the size of the returned future:
#![allow(unused)]
fn main() {
// ❌ Doesn't work:
// async fn lookup_dyn(store: &dyn DataStore, key: &str) { ... }
// Error: the trait `DataStore` is not dyn-compatible because method `get`
// is `async`
// ✅ Workaround: Return a boxed future
trait DynDataStore {
fn get(&self, key: &str) -> Pin<Box<dyn Future<Output = Option<String>> + Send + '_>>;
}
// Or use the trait_variant macro (see below)
}
The Send problem: In multi-threaded runtimes, spawned tasks must be Send. But async trait methods don’t automatically add Send bounds:
#![allow(unused)]
fn main() {
trait Worker {
async fn run(self); // Future might or might not be Send
}
struct MyWorker;
impl Worker for MyWorker {
async fn run(self) {
// If this uses !Send types, the future is !Send
let rc = std::rc::Rc::new(42);
some_work().await;
println!("{rc}");
}
}
// ❌ This fails because the future is !Send (Rc is !Send):
// tokio::spawn(worker.run()); // Requires Send + 'static
//
// Note: We use `self` (owned) here because tokio::spawn also
// requires 'static — a future borrowing &self can't be 'static.
// Even without Rc, `async fn run(&self)` wouldn't be spawnable.
}
The trait_variant Crate
The trait_variant crate (from the Rust async working group) generates a Send variant automatically:
#![allow(unused)]
fn main() {
// Cargo.toml: trait-variant = "0.1"
#[trait_variant::make(SendDataStore: Send)]
trait DataStore {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: String);
}
// Now you have two traits:
// - DataStore: no Send bound on the futures
// - SendDataStore: all futures are Send
// Both have the same methods, implementors implement DataStore
// and get SendDataStore for free if their futures are Send.
// Use SendDataStore when you need to spawn:
async fn spawn_lookup(store: Arc<dyn SendDataStore>) {
tokio::spawn(async move {
store.get("key").await;
});
}
}
Quick Reference: Async Traits
| Approach | Static Dispatch | Dynamic Dispatch | Send | Syntax Overhead |
|---|---|---|---|---|
Native async fn in trait | ✅ | ❌ | Implicit | None |
trait_variant | ✅ | ✅ | Explicit | #[trait_variant::make] |
Manual Box::pin | ✅ | ✅ | Explicit | High |
async-trait crate | ✅ | ✅ | #[async_trait] | Medium (proc macro) |
Recommendation: For new code (Rust 1.75+), use native async traits with
trait_variantwhen you needdyndispatch. Theasync-traitcrate is still widely used but boxes every future — the native approach is zero-cost for static dispatch.
Async Closures (Rust 1.85+)
Since Rust 1.85, async closures are stable — closures that capture their environment and return a future:
#![allow(unused)]
fn main() {
// Before 1.85: awkward workaround
let urls = vec!["https://a.com", "https://b.com"];
let fetchers: Vec<_> = urls.iter().map(|url| {
let url = url.to_string();
// Returns a non-async closure that returns an async block
move || async move { reqwest::get(&url).await }
}).collect();
// After 1.85: async closures just work
let fetchers: Vec<_> = urls.iter().map(|url| {
async move || { reqwest::get(url).await }
// ↑ This is an async closure — captures url, returns a Future
}).collect();
}
Async closures implement the new AsyncFn, AsyncFnMut, and AsyncFnOnce traits, which mirror Fn, FnMut, FnOnce:
#![allow(unused)]
fn main() {
// Generic function accepting an async closure
async fn retry<F>(max: usize, f: F) -> Result<String, Error>
where
F: AsyncFn() -> Result<String, Error>,
{
for _ in 0..max {
if let Ok(val) = f().await {
return Ok(val);
}
}
f().await
}
}
Migration tip: If you have code using
Fn() -> impl Future<Output = T>, consider switching toAsyncFn() -> Tfor cleaner signatures.
🏋️ Exercise: Design an Async Service Trait (click to expand)
Challenge: Design a Cache trait with async get and set methods. Implement it twice: once with a HashMap (in-memory) and once with a simulated Redis backend (use tokio::time::sleep to simulate network latency). Write a generic function that works with both.
🔑 Solution
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration};
trait Cache {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: String);
}
// --- In-memory implementation ---
struct MemoryCache {
store: Mutex<HashMap<String, String>>,
}
impl MemoryCache {
fn new() -> Self {
MemoryCache {
store: Mutex::new(HashMap::new()),
}
}
}
impl Cache for MemoryCache {
async fn get(&self, key: &str) -> Option<String> {
self.store.lock().await.get(key).cloned()
}
async fn set(&self, key: &str, value: String) {
self.store.lock().await.insert(key.to_string(), value);
}
}
// --- Simulated Redis implementation ---
struct RedisCache {
store: Mutex<HashMap<String, String>>,
latency: Duration,
}
impl RedisCache {
fn new(latency_ms: u64) -> Self {
RedisCache {
store: Mutex::new(HashMap::new()),
latency: Duration::from_millis(latency_ms),
}
}
}
impl Cache for RedisCache {
async fn get(&self, key: &str) -> Option<String> {
sleep(self.latency).await; // Simulate network round-trip
self.store.lock().await.get(key).cloned()
}
async fn set(&self, key: &str, value: String) {
sleep(self.latency).await;
self.store.lock().await.insert(key.to_string(), value);
}
}
// --- Generic function working with any Cache ---
async fn cache_demo<C: Cache>(cache: &C, label: &str) {
cache.set("greeting", "Hello, async!".into()).await;
let val = cache.get("greeting").await;
println!("[{label}] greeting = {val:?}");
}
#[tokio::main]
async fn main() {
let mem = MemoryCache::new();
cache_demo(&mem, "memory").await;
let redis = RedisCache::new(50);
cache_demo(&redis, "redis").await;
}
Key takeaway: The same generic function works with both implementations through static dispatch. No boxing, no allocation overhead. For dynamic dispatch, add trait_variant::make(SendCache: Send).
Key Takeaways — Async Traits
- Since Rust 1.75, you can write
async fndirectly in traits (no#[async_trait]crate needed)trait_variant::makeauto-generates aSendvariant for dynamic dispatch- Async closures (
async Fn()) stabilized in 1.85 — use for callbacks and middleware- Prefer static dispatch (
<S: Service>) overdynfor performance-critical code
See also: Ch 13 — Production Patterns for Tower’s
Servicetrait, Ch 6 — Building Futures by Hand for manual trait implementations
11. Streams and AsyncIterator 🟡
What you’ll learn:
- The
Streamtrait: async iteration over multiple values- Creating streams:
stream::iter,async_stream,unfold- Stream combinators:
map,filter,buffer_unordered,fold- Async I/O traits:
AsyncRead,AsyncWrite,AsyncBufRead
Stream Trait Overview
A Stream is to Iterator what Future is to a single value — it yields multiple values asynchronously:
#![allow(unused)]
fn main() {
// std::iter::Iterator (synchronous, multiple values)
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
// futures::Stream (async, multiple values)
trait Stream {
type Item;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>>;
}
}
graph LR
subgraph "Sync"
VAL["Value<br/>(T)"]
ITER["Iterator<br/>(multiple T)"]
end
subgraph "Async"
FUT["Future<br/>(async T)"]
STREAM["Stream<br/>(async multiple T)"]
end
VAL -->|"make async"| FUT
ITER -->|"make async"| STREAM
VAL -->|"make multiple"| ITER
FUT -->|"make multiple"| STREAM
style VAL fill:#e3f2fd,color:#000
style ITER fill:#e3f2fd,color:#000
style FUT fill:#c8e6c9,color:#000
style STREAM fill:#c8e6c9,color:#000
Creating Streams
#![allow(unused)]
fn main() {
use futures::stream::{self, StreamExt};
use tokio::time::{interval, Duration};
use tokio_stream::wrappers::IntervalStream;
// 1. From an iterator
let s = stream::iter(vec![1, 2, 3]);
// 2. From an async generator (using async_stream crate)
// Cargo.toml: async-stream = "0.3"
use async_stream::stream;
fn countdown(from: u32) -> impl futures::Stream<Item = u32> {
stream! {
for i in (0..=from).rev() {
tokio::time::sleep(Duration::from_millis(500)).await;
yield i;
}
}
}
// 3. From a tokio interval
let tick_stream = IntervalStream::new(interval(Duration::from_secs(1)));
// 4. From a channel receiver (tokio_stream::wrappers)
let (tx, rx) = tokio::sync::mpsc::channel::<String>(100);
let rx_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
// 5. From unfold (generate from async state)
let s = stream::unfold(0u32, |state| async move {
if state >= 5 {
None // Stream ends
} else {
let next = state + 1;
Some((state, next)) // yield `state`, new state is `next`
}
});
}
Consuming Streams
#![allow(unused)]
fn main() {
use futures::stream::{self, StreamExt};
async fn stream_examples() {
let s = stream::iter(vec![1, 2, 3, 4, 5]);
// for_each — process each item
s.for_each(|x| async move {
println!("{x}");
}).await;
// map + collect
let doubled: Vec<i32> = stream::iter(vec![1, 2, 3])
.map(|x| x * 2)
.collect()
.await;
// filter
let evens: Vec<i32> = stream::iter(1..=10)
.filter(|x| futures::future::ready(x % 2 == 0))
.collect()
.await;
// buffer_unordered — process N items concurrently
let results: Vec<_> = stream::iter(vec!["url1", "url2", "url3"])
.map(|url| async move {
// Simulate HTTP fetch
tokio::time::sleep(Duration::from_millis(100)).await;
format!("response from {url}")
})
.buffer_unordered(10) // Up to 10 concurrent fetches
.collect()
.await;
// take, skip, zip, chain — just like Iterator
let first_three: Vec<i32> = stream::iter(1..=100)
.take(3)
.collect()
.await;
}
}
Comparison with C# IAsyncEnumerable
| Feature | Rust Stream | C# IAsyncEnumerable<T> |
|---|---|---|
| Syntax | stream! { yield x; } | await foreach / yield return |
| Cancellation | Drop the stream | CancellationToken |
| Backpressure | Consumer controls poll rate | Consumer controls MoveNextAsync |
| Built-in | No (needs futures crate) | Yes (since C# 8.0) |
| Combinators | .map(), .filter(), .buffer_unordered() | LINQ + System.Linq.Async |
| Error handling | Stream<Item = Result<T, E>> | Throw in async iterator |
#![allow(unused)]
fn main() {
// Rust: Stream of database rows
// NOTE: try_stream! (not stream!) is required when using ? inside the body.
// stream! doesn't propagate errors — try_stream! yields Err(e) and ends.
fn get_users(db: &Database) -> impl Stream<Item = Result<User, DbError>> + '_ {
try_stream! {
let mut cursor = db.query("SELECT * FROM users").await?;
while let Some(row) = cursor.next().await {
yield User::from_row(row?);
}
}
}
// Consume:
let mut users = pin!(get_users(&db));
while let Some(result) = users.next().await {
match result {
Ok(user) => println!("{}", user.name),
Err(e) => eprintln!("Error: {e}"),
}
}
}
// C# equivalent:
async IAsyncEnumerable<User> GetUsers() {
await using var reader = await db.QueryAsync("SELECT * FROM users");
while (await reader.ReadAsync()) {
yield return User.FromRow(reader);
}
}
// Consume:
await foreach (var user in GetUsers()) {
Console.WriteLine(user.Name);
}
🏋️ Exercise: Build an Async Stats Aggregator (click to expand)
Challenge: Given a stream of sensor readings Stream<Item = f64>, write an async function that consumes the stream and returns (count, min, max, average). Use StreamExt combinators — don’t just collect into a Vec.
Hint: Use .fold() to accumulate state across the stream.
🔑 Solution
#![allow(unused)]
fn main() {
use futures::stream::{self, StreamExt};
#[derive(Debug)]
struct Stats {
count: usize,
min: f64,
max: f64,
sum: f64,
}
impl Stats {
fn average(&self) -> f64 {
if self.count == 0 { 0.0 } else { self.sum / self.count as f64 }
}
}
async fn compute_stats<S: futures::Stream<Item = f64> + Unpin>(stream: S) -> Stats {
stream
.fold(
Stats { count: 0, min: f64::INFINITY, max: f64::NEG_INFINITY, sum: 0.0 },
|mut acc, value| async move {
acc.count += 1;
acc.min = acc.min.min(value);
acc.max = acc.max.max(value);
acc.sum += value;
acc
},
)
.await
}
#[tokio::test]
async fn test_stats() {
let readings = stream::iter(vec![23.5, 24.1, 22.8, 25.0, 23.9]);
let stats = compute_stats(readings).await;
assert_eq!(stats.count, 5);
assert!((stats.min - 22.8).abs() < f64::EPSILON);
assert!((stats.max - 25.0).abs() < f64::EPSILON);
assert!((stats.average() - 23.86).abs() < 0.01);
}
}
Key takeaway: Stream combinators like .fold() process items one-at-a-time without collecting into memory — essential for processing large or unbounded data streams.
Async I/O Traits: AsyncRead, AsyncWrite, AsyncBufRead
Just as std::io::Read/Write are the foundation of synchronous I/O, their async counterparts are the foundation of async I/O. These traits are provided by tokio::io (or futures::io for runtime-agnostic code):
#![allow(unused)]
fn main() {
// tokio::io — the async versions of std::io traits
/// Read bytes from a source asynchronously
pub trait AsyncRead {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>, // Tokio's safe wrapper around uninitialized memory
) -> Poll<io::Result<()>>;
}
/// Write bytes to a sink asynchronously
pub trait AsyncWrite {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>>;
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>>;
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>>;
}
/// Buffered reading with line support
pub trait AsyncBufRead: AsyncRead {
fn poll_fill_buf(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<&[u8]>>;
fn consume(self: Pin<&mut Self>, amt: usize);
}
}
In practice, you rarely call these poll_* methods directly. Instead, use the extension traits AsyncReadExt and AsyncWriteExt which provide .await-friendly helper methods:
#![allow(unused)]
fn main() {
use tokio::io::{AsyncReadExt, AsyncWriteExt, AsyncBufReadExt};
use tokio::net::TcpStream;
use tokio::io::BufReader;
async fn io_examples() -> tokio::io::Result<()> {
let mut stream = TcpStream::connect("127.0.0.1:8080").await?;
// AsyncWriteExt: write_all, write_u32, write_buf, etc.
stream.write_all(b"GET / HTTP/1.0\r\n\r\n").await?;
// AsyncReadExt: read, read_exact, read_to_end, read_to_string
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
// AsyncBufReadExt: read_line, lines(), split()
let file = tokio::fs::File::open("config.txt").await?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await? {
println!("{line}");
}
Ok(())
}
}
Implementing custom async I/O — wrap a protocol over raw TCP:
#![allow(unused)]
fn main() {
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use std::pin::Pin;
use std::task::{Context, Poll};
/// A length-prefixed protocol: [u32 length][payload bytes]
struct FramedStream<T> {
inner: T,
}
impl<T: AsyncRead + AsyncReadExt + Unpin> FramedStream<T> {
/// Read one complete frame
async fn read_frame(&mut self) -> tokio::io::Result<Vec<u8>>
{
// Read the 4-byte length prefix
let len = self.inner.read_u32().await? as usize;
// Read exactly that many bytes
let mut payload = vec![0u8; len];
self.inner.read_exact(&mut payload).await?;
Ok(payload)
}
}
impl<T: AsyncWrite + AsyncWriteExt + Unpin> FramedStream<T> {
/// Write one complete frame
async fn write_frame(&mut self, data: &[u8]) -> tokio::io::Result<()>
{
self.inner.write_u32(data.len() as u32).await?;
self.inner.write_all(data).await?;
self.inner.flush().await?;
Ok(())
}
}
}
| Sync Trait | Async Trait (tokio) | Async Trait (futures) | Extension Trait |
|---|---|---|---|
std::io::Read | tokio::io::AsyncRead | futures::io::AsyncRead | AsyncReadExt |
std::io::Write | tokio::io::AsyncWrite | futures::io::AsyncWrite | AsyncWriteExt |
std::io::BufRead | tokio::io::AsyncBufRead | futures::io::AsyncBufRead | AsyncBufReadExt |
std::io::Seek | tokio::io::AsyncSeek | futures::io::AsyncSeek | AsyncSeekExt |
tokio vs futures I/O traits: They’re similar but not identical — tokio’s
AsyncReadusesReadBuf(handles uninitialized memory safely), whilefutures::AsyncReaduses&mut [u8]. Usetokio_util::compatto convert between them.
Copy utilities:
tokio::io::copy(&mut reader, &mut writer)is the async equivalent ofstd::io::copy— useful for proxy servers or file transfers.tokio::io::copy_bidirectionalcopies both directions concurrently.
🏋️ Exercise: Build an Async Line Counter (click to expand)
Challenge: Write an async function that takes any AsyncBufRead source and returns the number of non-empty lines. It should work with files, TCP streams, or any buffered reader.
Hint: Use AsyncBufReadExt::lines() and count lines where !line.is_empty().
🔑 Solution
#![allow(unused)]
fn main() {
use tokio::io::AsyncBufReadExt;
async fn count_non_empty_lines<R: tokio::io::AsyncBufRead + Unpin>(
reader: R,
) -> tokio::io::Result<usize> {
let mut lines = reader.lines();
let mut count = 0;
while let Some(line) = lines.next_line().await? {
if !line.is_empty() {
count += 1;
}
}
Ok(count)
}
// Works with any AsyncBufRead:
// let file = tokio::io::BufReader::new(tokio::fs::File::open("data.txt").await?);
// let count = count_non_empty_lines(file).await?;
//
// let tcp = tokio::io::BufReader::new(TcpStream::connect("...").await?);
// let count = count_non_empty_lines(tcp).await?;
}
Key takeaway: By programming against AsyncBufRead instead of a concrete type, your I/O code is reusable across files, sockets, pipes, and even in-memory buffers (tokio::io::BufReader::new(std::io::Cursor::new(data))).
Key Takeaways — Streams and AsyncIterator
Streamis the async equivalent ofIterator— yieldsPoll::Ready(Some(item))orPoll::Ready(None).buffer_unordered(N)processes N stream items concurrently — the key concurrency tool for streamsasync_stream::stream!is the easiest way to create custom streams (usesyield)AsyncRead/AsyncBufReadenable generic, reusable I/O code across files, sockets, and pipes
See also: Ch 9 — When Tokio Isn’t the Right Fit for
FuturesUnordered(related pattern), Ch 13 — Production Patterns for backpressure with bounded channels
12. Common Pitfalls 🔴
What you’ll learn:
- 9 common async Rust bugs and how to fix each one
- Why blocking the executor is the #1 mistake (and how
spawn_blockingfixes it)- Cancellation hazards: what happens when a future is dropped mid-await
- Debugging:
tokio-console,tracing,#[instrument]- Testing:
#[tokio::test],time::pause(), trait-based mocking
Blocking the Executor
The #1 mistake in async Rust: running blocking code on the async executor thread. This starves other tasks.
#![allow(unused)]
fn main() {
// ❌ WRONG: Blocks the entire executor thread
async fn bad_handler() -> String {
let data = std::fs::read_to_string("big_file.txt").unwrap(); // BLOCKS!
process(&data)
}
// ✅ CORRECT: Offload blocking work to a dedicated thread pool
async fn good_handler() -> String {
let data = tokio::task::spawn_blocking(|| {
std::fs::read_to_string("big_file.txt").unwrap()
}).await.unwrap();
process(&data)
}
// ✅ ALSO CORRECT: Use tokio's async fs
async fn also_good_handler() -> String {
let data = tokio::fs::read_to_string("big_file.txt").await.unwrap();
process(&data)
}
}
graph TB
subgraph "❌ Blocking Call on Executor"
T1_BAD["Thread 1: std::fs::read()<br/>🔴 BLOCKED for 500ms"]
T2_BAD["Thread 2: handling requests<br/>🟢 Working alone"]
TASKS_BAD["100 pending tasks<br/>⏳ Starved"]
T1_BAD -->|"can't poll"| TASKS_BAD
end
subgraph "✅ spawn_blocking"
T1_GOOD["Thread 1: polling futures<br/>🟢 Available"]
T2_GOOD["Thread 2: polling futures<br/>🟢 Available"]
BT["Blocking pool thread:<br/>std::fs::read()<br/>🔵 Separate pool"]
TASKS_GOOD["100 tasks<br/>✅ All making progress"]
T1_GOOD -->|"polls"| TASKS_GOOD
T2_GOOD -->|"polls"| TASKS_GOOD
end
std::thread::sleep vs tokio::time::sleep
#![allow(unused)]
fn main() {
// ❌ WRONG: Blocks the executor thread for 5 seconds
async fn bad_delay() {
std::thread::sleep(Duration::from_secs(5)); // Thread can't poll anything else!
}
// ✅ CORRECT: Yields to the executor, other tasks can run
async fn good_delay() {
tokio::time::sleep(Duration::from_secs(5)).await; // Non-blocking!
}
}
Holding MutexGuard Across .await
#![allow(unused)]
fn main() {
use std::sync::Mutex; // std Mutex — NOT async-aware
// ⚠️ RISKY: MutexGuard held across .await
async fn bad_mutex(data: &Mutex<Vec<String>>) {
let mut guard = data.lock().unwrap();
guard.push("item".into());
some_io().await; // Guard is held here — blocks other threads from locking!
guard.push("another".into());
}
// NOTE: This compiles! std::sync::MutexGuard is !Send, but the compiler only
// enforces Send on the Future when you pass it to something that requires it
// (e.g., tokio::spawn). Calling bad_mutex(...).await directly compiles fine.
// However, tokio::spawn(bad_mutex(data)) will fail with a Send bound error.
}
Why this is usually a problem — but not always:
Holding a std::sync::Mutex across .await blocks the OS thread for the
duration of the I/O, preventing the executor from polling other tasks on that
thread. For short critical sections this is wasteful; for long I/O it’s a
performance trap.
However, there are legitimate cases where you must hold a lock across an
.await — the same way a database transaction holds a lock between read and
commit. Dropping and re-acquiring the lock introduces a TOCTOU (time-of-check
to time-of-use) race: another task can modify the data between your two
critical sections. The right fix depends on the use case:
#![allow(unused)]
fn main() {
// OPTION 1: Scope the guard — works when operations are independent
async fn scoped_mutex(data: &Mutex<Vec<String>>) {
{
let mut guard = data.lock().unwrap();
guard.push("item".into());
} // Guard dropped here
some_io().await; // Lock is released — other tasks can proceed
{
let mut guard = data.lock().unwrap();
guard.push("another".into());
}
}
// ⚠️ Careful: another task can lock + modify the Vec between the two sections.
// This is fine if the two pushes are independent, but wrong if "another"
// depends on state set by "item".
// OPTION 2: Use tokio::sync::Mutex — holds lock across .await without
// blocking the OS thread. Best when you need transactional
// read-modify-write across an await point.
use tokio::sync::Mutex as AsyncMutex;
async fn async_mutex(data: &AsyncMutex<Vec<String>>) {
let mut guard = data.lock().await; // Async lock — doesn't block the thread
guard.push("item".into());
some_io().await; // OK — tokio Mutex guard is Send
guard.push("another".into());
// Guard held the whole time — no TOCTOU race, no thread blocked.
}
}
When to use which Mutex:
std::sync::Mutex: Short critical sections with no.awaitinsidetokio::sync::Mutex: When you need to hold the lock across.awaitpoints (transactional semantics, TOCTOU avoidance)parking_lot::Mutex: Drop-instdreplacement, faster, smaller, still no.awaitRule of thumb: Don’t blindly split a critical section around an
.await. Ask whether the two halves are truly independent. If they aren’t — if the second half depends on state from the first — usetokio::sync::Mutexor redesign the data flow.
Cancellation Hazards
Dropping a future cancels it — but this can leave things in an inconsistent state:
#![allow(unused)]
fn main() {
// ❌ DANGEROUS: Resource leak on cancellation
async fn transfer(from: &Account, to: &Account, amount: u64) {
from.debit(amount).await; // If cancelled HERE...
to.credit(amount).await; // ...money vanishes!
}
// ✅ SAFE: Make operations atomic or use compensation
async fn safe_transfer(from: &Account, to: &Account, amount: u64) -> Result<(), Error> {
// Use a database transaction (all-or-nothing)
let tx = db.begin_transaction().await?;
tx.debit(from, amount).await?;
tx.credit(to, amount).await?;
tx.commit().await?; // Only commits if everything succeeded
Ok(())
}
// ✅ ALSO SAFE: Use tokio::select! with cancellation awareness
tokio::select! {
result = transfer(from, to, amount) => {
// Transfer completed
}
_ = shutdown_signal() => {
// Don't cancel mid-transfer — let it finish
// Or: roll back explicitly
}
}
}
No Async Drop
Rust’s Drop trait is synchronous — you cannot .await inside drop(). This is a frequent source of confusion:
#![allow(unused)]
fn main() {
struct DbConnection { /* ... */ }
impl Drop for DbConnection {
fn drop(&mut self) {
// ❌ Can't do this — drop() is sync!
// self.connection.shutdown().await;
// ✅ Workaround 1: Spawn a cleanup task (fire-and-forget)
let conn = self.connection.take();
tokio::spawn(async move {
let _ = conn.shutdown().await;
});
// ✅ Workaround 2: Use a synchronous close
// self.connection.blocking_close();
}
}
}
Best practice: Provide an explicit async fn close(self) method and document that callers should use it. Rely on Drop only as a safety net, not the primary cleanup path.
select! Fairness and Starvation
#![allow(unused)]
fn main() {
use tokio::sync::mpsc;
// ❌ UNFAIR: busy_stream always wins, slow_stream starves
async fn unfair(mut fast: mpsc::Receiver<i32>, mut slow: mpsc::Receiver<i32>) {
loop {
tokio::select! {
Some(v) = fast.recv() => println!("fast: {v}"),
Some(v) = slow.recv() => println!("slow: {v}"),
// If both are ready, tokio randomly picks one.
// But if `fast` is ALWAYS ready, `slow` rarely gets polled.
}
}
}
// ✅ FAIR: Use biased select or drain in batches
async fn fair(mut fast: mpsc::Receiver<i32>, mut slow: mpsc::Receiver<i32>) {
loop {
tokio::select! {
biased; // Always check in order — explicit priority
Some(v) = slow.recv() => println!("slow: {v}"), // Priority!
Some(v) = fast.recv() => println!("fast: {v}"),
}
}
}
}
Accidental Sequential Execution
#![allow(unused)]
fn main() {
// ❌ SEQUENTIAL: Takes 2 seconds total
async fn slow() {
let a = fetch("url_a").await; // 1 second
let b = fetch("url_b").await; // 1 second (waits for a to finish first!)
}
// ✅ CONCURRENT: Takes 1 second total
async fn fast() {
let (a, b) = tokio::join!(
fetch("url_a"), // Both start immediately
fetch("url_b"),
);
}
// ✅ ALSO CONCURRENT: Using let + join
async fn also_fast() {
let fut_a = fetch("url_a"); // Create future (lazy — not started yet)
let fut_b = fetch("url_b"); // Create future
let (a, b) = tokio::join!(fut_a, fut_b); // NOW both run concurrently
}
}
Trap:
let a = fetch(url).await; let b = fetch(url).await;is sequential! The second.awaitdoesn’t start until the first finishes. Usejoin!orspawnfor concurrency.
Case Study: Debugging a Hung Production Service
A real-world scenario: a service handles requests fine for 10 minutes, then stops responding. No errors in logs. CPU at 0%.
Diagnosis steps:
- Attach
tokio-console— reveals 200+ tasks stuck inPendingstate - Check task details — all waiting on the same
Mutex::lock().await - Root cause — one task held a
std::sync::MutexGuardacross an.awaitand panicked, poisoning the mutex. All other tasks now fail onlock().unwrap()
The fix:
| Before (broken) | After (fixed) |
|---|---|
std::sync::Mutex | tokio::sync::Mutex |
.lock().unwrap() across .await | Scope lock before .await |
| No timeout on lock acquisition | tokio::time::timeout(dur, mutex.lock()) |
| No recovery on poisoned mutex | tokio::sync::Mutex doesn’t poison |
Prevention checklist:
- Use
tokio::sync::Mutexif the guard crosses any.await - Add
#[tracing::instrument]to async functions for span tracking - Run
tokio-consolein staging to catch hung tasks early - Add health check endpoints that verify task responsiveness
🏋️ Exercise: Spot the Bugs (click to expand)
Challenge: Find all the async pitfalls in this code and fix them.
#![allow(unused)]
fn main() {
use std::sync::Mutex;
async fn process_requests(urls: Vec<String>) -> Vec<String> {
let results = Mutex::new(Vec::new());
for url in &urls {
let response = reqwest::get(url).await.unwrap().text().await.unwrap();
std::thread::sleep(std::time::Duration::from_millis(100)); // Rate limit
let mut guard = results.lock().unwrap();
guard.push(response);
expensive_parse(&guard).await; // Parse all results so far
}
results.into_inner().unwrap()
}
}
🔑 Solution
Bugs found:
- Sequential fetches — URLs are fetched one at a time instead of concurrently
std::thread::sleep— Blocks the executor thread- MutexGuard held across
.await—guardis alive whenexpensive_parseis awaited - No concurrency — Should use
join!orFuturesUnordered
#![allow(unused)]
fn main() {
use tokio::sync::Mutex;
use std::sync::Arc;
use futures::stream::{self, StreamExt};
async fn process_requests(urls: Vec<String>) -> Vec<String> {
// Fix 4: Process URLs concurrently with buffer_unordered
let results: Vec<String> = stream::iter(urls)
.map(|url| async move {
let response = reqwest::get(&url).await.unwrap().text().await.unwrap();
// Fix 2: Use tokio::time::sleep instead of std::thread::sleep
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
response
})
.buffer_unordered(10) // Up to 10 concurrent requests
.collect()
.await;
// Fix 3: Parse after collecting — no mutex needed at all!
for result in &results {
expensive_parse(result).await;
}
results
}
}
Key takeaway: Often you can restructure async code to eliminate mutexes entirely. Collect results with streams/join, then process. Simpler, faster, no deadlock risk.
Debugging Async Code
Async stack traces are notoriously cryptic — they show the executor’s poll loop rather than your logical call chain. Here are the essential debugging tools.
tokio-console: Real-Time Task Inspector
tokio-console gives you an htop-like view of every spawned task: its state, poll duration, waker activity, and resource usage.
# Cargo.toml
[dependencies]
console-subscriber = "0.4"
tokio = { version = "1", features = ["full", "tracing"] }
#[tokio::main]
async fn main() {
console_subscriber::init(); // Replaces the default tracing subscriber
// ... rest of your application
}
Then in another terminal:
$ RUSTFLAGS="--cfg tokio_unstable" cargo run # Required compile-time flag
$ tokio-console # Connects to 127.0.0.1:6669
tracing + #[instrument]: Structured Logging for Async
The tracing crate understands Future lifetimes. Spans stay open across .await points, giving you a logical call stack even when the OS thread has moved on:
#![allow(unused)]
fn main() {
use tracing::{info, instrument};
#[instrument(skip(db_pool), fields(user_id = %user_id))]
async fn handle_request(user_id: u64, db_pool: &Pool) -> Result<Response> {
info!("looking up user");
let user = db_pool.get_user(user_id).await?; // span stays open across .await
info!(email = %user.email, "found user");
let orders = fetch_orders(user_id).await?; // still the same span
Ok(build_response(user, orders))
}
}
Output (with tracing_subscriber::fmt::json()):
{"timestamp":"...","level":"INFO","span":{"name":"handle_request","user_id":"42"},"message":"looking up user"}
{"timestamp":"...","level":"INFO","span":{"name":"handle_request","user_id":"42"},"fields":{"email":"[email protected]"},"message":"found user"}
Debugging Checklist
| Symptom | Likely Cause | Tool |
|---|---|---|
| Task hangs forever | Missing .await or deadlocked Mutex | tokio-console task view |
| Low throughput | Blocking call on async thread | tokio-console poll-time histogram |
Future is not Send | Non-Send type held across .await | Compiler error + #[instrument] to locate |
| Mysterious cancellation | Parent select! dropped a branch | tracing span lifecycle events |
Tip: Enable
RUSTFLAGS="--cfg tokio_unstable"to get task-level metrics in tokio-console. This is a compile-time flag, not a runtime one.
Testing Async Code
Async code introduces unique testing challenges — you need a runtime, time control, and strategies for testing concurrent behavior.
Basic async tests with #[tokio::test]:
#![allow(unused)]
fn main() {
// Cargo.toml
// [dev-dependencies]
// tokio = { version = "1", features = ["full", "test-util"] }
#[tokio::test]
async fn test_basic_async() {
let result = fetch_data().await;
assert_eq!(result, "expected");
}
// Single-threaded test (useful for !Send types):
#[tokio::test(flavor = "current_thread")]
async fn test_single_threaded() {
let rc = std::rc::Rc::new(42);
let val = async { *rc }.await;
assert_eq!(val, 42);
}
// Multi-threaded with explicit worker count:
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_concurrent_behavior() {
// Tests race conditions with real concurrency
let counter = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let c1 = counter.clone();
let c2 = counter.clone();
let (a, b) = tokio::join!(
tokio::spawn(async move { c1.fetch_add(1, std::sync::atomic::Ordering::SeqCst) }),
tokio::spawn(async move { c2.fetch_add(1, std::sync::atomic::Ordering::SeqCst) }),
);
a.unwrap();
b.unwrap();
assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 2);
}
}
Time manipulation — test timeouts without actually waiting:
#![allow(unused)]
fn main() {
use tokio::time::{self, Duration, Instant};
#[tokio::test]
async fn test_timeout_behavior() {
// Pause time — sleep() advances instantly, no real wall-clock delay
time::pause();
let start = Instant::now();
time::sleep(Duration::from_secs(3600)).await; // "waits" 1 hour — takes 0ms
assert!(start.elapsed() >= Duration::from_secs(3600));
// Test ran in milliseconds, not an hour!
}
#[tokio::test]
async fn test_retry_timing() {
time::pause();
// Test that our retry logic waits the expected durations
let start = Instant::now();
let result = retry_with_backoff(|| async {
Err::<(), _>("simulated failure")
}, 3, Duration::from_secs(1))
.await;
assert!(result.is_err());
// 1s + 2s + 4s = 7s of backoff (exponential)
assert!(start.elapsed() >= Duration::from_secs(7));
}
#[tokio::test]
async fn test_deadline_exceeded() {
time::pause();
let result = tokio::time::timeout(
Duration::from_secs(5),
async {
// Simulate slow operation
time::sleep(Duration::from_secs(10)).await;
"done"
}
).await;
assert!(result.is_err()); // Timed out
}
}
Mocking async dependencies — use trait objects or generics:
#![allow(unused)]
fn main() {
// Define a trait for the dependency:
trait Storage {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: String);
}
// Production implementation:
struct RedisStorage { /* ... */ }
impl Storage for RedisStorage {
async fn get(&self, key: &str) -> Option<String> {
// Real Redis call
todo!()
}
async fn set(&self, key: &str, value: String) {
todo!()
}
}
// Test mock:
struct MockStorage {
data: std::sync::Mutex<std::collections::HashMap<String, String>>,
}
impl MockStorage {
fn new() -> Self {
MockStorage { data: std::sync::Mutex::new(std::collections::HashMap::new()) }
}
}
impl Storage for MockStorage {
async fn get(&self, key: &str) -> Option<String> {
self.data.lock().unwrap().get(key).cloned()
}
async fn set(&self, key: &str, value: String) {
self.data.lock().unwrap().insert(key.to_string(), value);
}
}
// Tested function is generic over Storage:
async fn cache_lookup<S: Storage>(store: &S, key: &str) -> String {
match store.get(key).await {
Some(val) => val,
None => {
let val = "computed".to_string();
store.set(key, val.clone()).await;
val
}
}
}
#[tokio::test]
async fn test_cache_miss_then_hit() {
let mock = MockStorage::new();
// First call: miss → computes and stores
let val = cache_lookup(&mock, "key1").await;
assert_eq!(val, "computed");
// Second call: hit → returns stored value
let val = cache_lookup(&mock, "key1").await;
assert_eq!(val, "computed");
assert!(mock.data.lock().unwrap().contains_key("key1"));
}
}
Testing channels and task communication:
#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_producer_consumer() {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
tokio::spawn(async move {
for i in 0..5 {
tx.send(i).await.unwrap();
}
// tx dropped here — channel closes
});
let mut received = Vec::new();
while let Some(val) = rx.recv().await {
received.push(val);
}
assert_eq!(received, vec![0, 1, 2, 3, 4]);
}
}
| Test Pattern | When to Use | Key Tool |
|---|---|---|
#[tokio::test] | All async tests | tokio = { features = ["macros", "rt"] } |
time::pause() | Testing timeouts, retries, periodic tasks | tokio::time::pause() |
| Trait mocking | Testing business logic without I/O | Generic <S: Storage> |
current_thread flavor | Testing !Send types or deterministic scheduling | #[tokio::test(flavor = "current_thread")] |
multi_thread flavor | Testing race conditions | #[tokio::test(flavor = "multi_thread")] |
Key Takeaways — Common Pitfalls
- Never block the executor — use
spawn_blockingfor CPU/sync work- Never hold a
MutexGuardacross.await— scope locks tightly or usetokio::sync::Mutex- Cancellation drops the future instantly — use “cancel-safe” patterns for partial operations
- Use
tokio-consoleand#[tracing::instrument]for debugging async code- Test async code with
#[tokio::test]andtime::pause()for deterministic timing
See also: Ch 8 — Tokio Deep Dive for sync primitives, Ch 13 — Production Patterns for graceful shutdown and structured concurrency
13. Production Patterns 🔴
What you’ll learn:
- Graceful shutdown with
watchchannels andselect!- Backpressure: bounded channels prevent OOM
- Structured concurrency:
JoinSetandTaskTracker- Timeouts, retries, and exponential backoff
- Error handling:
thiserrorvsanyhow, the double-?pattern- Tower: the middleware pattern used by axum, tonic, and hyper
Graceful Shutdown
Production servers must shut down cleanly — finish in-flight requests, flush buffers, close connections:
use tokio::signal;
use tokio::sync::watch;
async fn main_server() {
// Create a shutdown signal channel
let (shutdown_tx, shutdown_rx) = watch::channel(false);
// Spawn the server
let server_handle = tokio::spawn(run_server(shutdown_rx.clone()));
// Wait for Ctrl+C
signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
println!("Shutdown signal received, finishing in-flight requests...");
// Notify all tasks to shut down
// NOTE: .unwrap() is used for brevity. Production code should handle
// the case where all receivers have been dropped.
shutdown_tx.send(true).unwrap();
// Wait for server to finish (with timeout)
match tokio::time::timeout(
std::time::Duration::from_secs(30),
server_handle,
).await {
Ok(Ok(())) => println!("Server shut down gracefully"),
Ok(Err(e)) => eprintln!("Server error: {e}"),
Err(_) => eprintln!("Server shutdown timed out — forcing exit"),
}
}
async fn run_server(mut shutdown: watch::Receiver<bool>) {
loop {
tokio::select! {
// Accept new connections
conn = accept_connection() => {
let shutdown = shutdown.clone();
tokio::spawn(handle_connection(conn, shutdown));
}
// Shutdown signal
_ = shutdown.changed() => {
if *shutdown.borrow() {
println!("Stopping accepting new connections");
break;
}
}
}
}
// In-flight connections will finish on their own
// because they have their own shutdown_rx clone
}
async fn handle_connection(conn: Connection, mut shutdown: watch::Receiver<bool>) {
loop {
tokio::select! {
request = conn.next_request() => {
// Process the request fully — don't abandon mid-request
process_request(request).await;
}
_ = shutdown.changed() => {
if *shutdown.borrow() {
// Finish current request, then exit
break;
}
}
}
}
}
sequenceDiagram
participant OS as OS Signal
participant Main as Main Task
participant WCH as watch Channel
participant W1 as Worker 1
participant W2 as Worker 2
OS->>Main: SIGINT (Ctrl+C)
Main->>WCH: send(true)
WCH-->>W1: changed()
WCH-->>W2: changed()
Note over W1: Finish current request
Note over W2: Finish current request
W1-->>Main: Task complete
W2-->>Main: Task complete
Main->>Main: All workers done → exit
Backpressure with Bounded Channels
Unbounded channels can lead to OOM if the producer is faster than the consumer. Always use bounded channels in production:
#![allow(unused)]
fn main() {
use tokio::sync::mpsc;
async fn backpressure_example() {
// Bounded channel: max 100 items buffered
let (tx, mut rx) = mpsc::channel::<WorkItem>(100);
// Producer: slows down naturally when buffer is full
let producer = tokio::spawn(async move {
for i in 0..1_000_000 {
// send() is async — waits if buffer is full
// This creates natural backpressure!
tx.send(WorkItem { id: i }).await.unwrap();
}
});
// Consumer: processes items at its own pace
let consumer = tokio::spawn(async move {
while let Some(item) = rx.recv().await {
process(item).await; // Slow processing is OK — producer waits
}
});
let _ = tokio::join!(producer, consumer);
}
// Compare with unbounded — DANGEROUS:
// let (tx, rx) = mpsc::unbounded_channel(); // No backpressure!
// Producer can fill memory indefinitely
}
Structured Concurrency: JoinSet and TaskTracker
JoinSet groups related tasks and ensures they all complete:
#![allow(unused)]
fn main() {
use tokio::task::JoinSet;
use tokio::time::{sleep, Duration};
async fn structured_concurrency() {
let mut set = JoinSet::new();
// Spawn a batch of tasks
for url in get_urls() {
set.spawn(async move {
fetch_and_process(url).await
});
}
// Collect all results (order not guaranteed)
let mut results = Vec::new();
while let Some(result) = set.join_next().await {
match result {
Ok(Ok(data)) => results.push(data),
Ok(Err(e)) => eprintln!("Task error: {e}"),
Err(e) => eprintln!("Task panicked: {e}"),
}
}
// ALL tasks are done here — no dangling background work
println!("Processed {} items", results.len());
}
// TaskTracker (tokio-util 0.7.9+) — wait for all spawned tasks
use tokio_util::task::TaskTracker;
async fn with_tracker() {
let tracker = TaskTracker::new();
for i in 0..10 {
tracker.spawn(async move {
sleep(Duration::from_millis(100 * i)).await;
println!("Task {i} done");
});
}
tracker.close(); // No more tasks will be added
tracker.wait().await; // Wait for ALL tracked tasks
println!("All tasks finished");
}
}
Timeouts and Retries
#![allow(unused)]
fn main() {
use tokio::time::{timeout, sleep, Duration};
// Simple timeout
async fn with_timeout() -> Result<Response, Error> {
match timeout(Duration::from_secs(5), fetch_data()).await {
Ok(Ok(response)) => Ok(response),
Ok(Err(e)) => Err(Error::Fetch(e)),
Err(_) => Err(Error::Timeout),
}
}
// Exponential backoff retry
async fn retry_with_backoff<F, Fut, T, E>(
max_attempts: u32,
base_delay_ms: u64,
operation: F,
) -> Result<T, E>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
E: std::fmt::Display,
{
let mut delay = Duration::from_millis(base_delay_ms);
for attempt in 1..=max_attempts {
match operation().await {
Ok(result) => return Ok(result),
Err(e) => {
if attempt == max_attempts {
eprintln!("Final attempt {attempt} failed: {e}");
return Err(e);
}
eprintln!("Attempt {attempt} failed: {e}, retrying in {delay:?}");
sleep(delay).await;
delay *= 2; // Exponential backoff
}
}
}
unreachable!()
}
// Usage:
// let result = retry_with_backoff(3, 100, || async {
// reqwest::get("https://api.example.com/data").await
// }).await?;
}
Production tip — add jitter: The function above uses pure exponential backoff, but in production many clients failing simultaneously will all retry at the same intervals (thundering herd). Add random jitter — e.g.,
sleep(delay + rand_jitter)whererand_jitteris0..delay/4— so retries spread out over time.
Error Handling in Async Code
Async introduces unique error propagation challenges — spawned tasks create error boundaries, timeout errors wrap inner errors, and ? interacts differently when futures cross task boundaries.
thiserror vs anyhow — choosing the right tool:
#![allow(unused)]
fn main() {
// thiserror: Define typed errors for libraries and public APIs
// Every variant is explicit — callers can match on specific errors
use thiserror::Error;
#[derive(Error, Debug)]
enum DiagError {
#[error("IPMI command failed: {0}")]
Ipmi(#[from] IpmiError),
#[error("Sensor {sensor} out of range: {value}°C (max {max}°C)")]
OverTemp { sensor: String, value: f64, max: f64 },
#[error("Operation timed out after {0:?}")]
Timeout(std::time::Duration),
#[error("Task panicked: {0}")]
TaskPanic(#[from] tokio::task::JoinError),
}
// anyhow: Quick error handling for applications and prototypes
// Wraps any error — no need to define types for every case
use anyhow::{Context, Result};
async fn run_diagnostics() -> Result<()> {
let config = load_config()
.await
.context("Failed to load diagnostic config")?; // Adds context
let result = run_gpu_test(&config)
.await
.context("GPU diagnostic failed")?; // Chains context
Ok(())
}
// anyhow prints: "GPU diagnostic failed: IPMI command failed: timeout"
}
| Crate | Use When | Error Type | Matching |
|---|---|---|---|
thiserror | Library code, public APIs | enum MyError { ... } | match err { MyError::Timeout => ... } |
anyhow | Applications, CLI tools, scripts | anyhow::Error (type-erased) | err.downcast_ref::<MyError>() |
| Both together | Library exposes thiserror, app wraps with anyhow | Best of both | Library errors are typed, app doesn’t care |
The double-? pattern with tokio::spawn:
#![allow(unused)]
fn main() {
use thiserror::Error;
use tokio::task::JoinError;
#[derive(Error, Debug)]
enum AppError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Task panicked: {0}")]
TaskPanic(#[from] JoinError),
}
async fn spawn_with_errors() -> Result<String, AppError> {
let handle = tokio::spawn(async {
let resp = reqwest::get("https://example.com").await?;
Ok::<_, reqwest::Error>(resp.text().await?)
});
// Double ?: First ? unwraps JoinError (task panic), second ? unwraps inner Result
let result = handle.await??;
Ok(result)
}
}
The error boundary problem — tokio::spawn erases context:
#![allow(unused)]
fn main() {
// ❌ Error context is lost across spawn boundaries:
async fn bad_error_handling() -> Result<()> {
let handle = tokio::spawn(async {
some_fallible_work().await // Returns Result<T, SomeError>
});
// handle.await returns Result<Result<T, SomeError>, JoinError>
// The inner error has no context about what task failed
let result = handle.await??;
Ok(())
}
// ✅ Add context at the spawn boundary:
async fn good_error_handling() -> Result<()> {
let handle = tokio::spawn(async {
some_fallible_work()
.await
.context("worker task failed") // Context before crossing boundary
});
let result = handle.await
.context("worker task panicked")??; // Context for JoinError too
Ok(())
}
}
Timeout errors — wrapping vs replacing:
#![allow(unused)]
fn main() {
use tokio::time::{timeout, Duration};
async fn with_timeout_context() -> Result<String, DiagError> {
let dur = Duration::from_secs(30);
match timeout(dur, fetch_sensor_data()).await {
Ok(Ok(data)) => Ok(data),
Ok(Err(e)) => Err(e), // Inner error preserved
Err(_) => Err(DiagError::Timeout(dur)), // Timeout → typed error
}
}
}
Tower: The Middleware Pattern
The Tower crate defines a composable Service trait — the backbone of async middleware in Rust (used by axum, tonic, hyper):
#![allow(unused)]
fn main() {
// Tower's core trait (simplified):
pub trait Service<Request> {
type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
fn call(&mut self, req: Request) -> Self::Future;
}
}
Middleware wraps a Service to add cross-cutting behavior — logging, timeouts, rate-limiting — without modifying inner logic:
#![allow(unused)]
fn main() {
use tower::{ServiceBuilder, timeout::TimeoutLayer, limit::RateLimitLayer};
use std::time::Duration;
let service = ServiceBuilder::new()
.layer(TimeoutLayer::new(Duration::from_secs(10))) // Outermost: timeout
.layer(RateLimitLayer::new(100, Duration::from_secs(1))) // Then: rate limit
.service(my_handler); // Innermost: your code
}
Why this matters: If you’ve used ASP.NET middleware or Express.js middleware, Tower is the Rust equivalent. It’s how production Rust services add cross-cutting concerns without code duplication.
Exercise: Graceful Shutdown with Worker Pool
🏋️ Exercise (click to expand)
Challenge: Build a task processor with a channel-based work queue, N worker tasks, and graceful shutdown on Ctrl+C. Workers should finish in-flight work before exiting.
🔑 Solution
use tokio::sync::{mpsc, watch};
use tokio::time::{sleep, Duration};
struct WorkItem { id: u64, payload: String }
#[tokio::main]
async fn main() {
let (work_tx, work_rx) = mpsc::channel::<WorkItem>(100);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let work_rx = std::sync::Arc::new(tokio::sync::Mutex::new(work_rx));
let mut handles = Vec::new();
for id in 0..4 {
let rx = work_rx.clone();
let mut shutdown = shutdown_rx.clone();
handles.push(tokio::spawn(async move {
loop {
let item = {
let mut rx = rx.lock().await;
tokio::select! {
item = rx.recv() => item,
_ = shutdown.changed() => {
if *shutdown.borrow() { None } else { continue }
}
}
};
match item {
Some(work) => {
println!("Worker {id}: processing {}", work.id);
sleep(Duration::from_millis(200)).await;
}
None => break,
}
}
}));
}
// Submit work
for i in 0..20 {
let _ = work_tx.send(WorkItem { id: i, payload: format!("task-{i}") }).await;
sleep(Duration::from_millis(50)).await;
}
// On Ctrl+C: signal shutdown, wait for workers
// NOTE: .unwrap() is used for brevity — handle errors in production.
tokio::signal::ctrl_c().await.unwrap();
shutdown_tx.send(true).unwrap();
for h in handles { let _ = h.await; }
println!("Shut down cleanly.");
}
Key Takeaways — Production Patterns
- Use a
watchchannel +select!for coordinated graceful shutdown- Bounded channels (
mpsc::channel(N)) provide backpressure — senders block when the buffer is fullJoinSetandTaskTrackerprovide structured concurrency: track, abort, and await task groups- Always add timeouts to network operations —
tokio::time::timeout(dur, fut)- Tower’s
Servicetrait is the standard middleware pattern for production Rust services
See also: Ch 8 — Tokio Deep Dive for channels and sync primitives, Ch 12 — Common Pitfalls for cancellation hazards during shutdown
14. Async Is an Optimization, Not an Architecture 🔴
What you’ll learn:
- Why async tends to contaminate entire codebases — and why that’s a design flaw, not a feature
- The “sync core, async shell” pattern for keeping most code testable and debuggable
- How to handle the hard case: logic that also needs I/O
- When
spawn_blockingis a fix vs. a symptom- When async genuinely belongs in your core logic
- Why sync-first libraries are more composable than async-first ones
You’ve now spent 13 chapters learning async Rust. Here’s the most important thing the book hasn’t told you: most of your code shouldn’t be async.
The Function Coloring Problem
Bob Nystrom’s “What Color is Your Function?” identifies the core issue: async functions can call sync functions, but sync functions cannot call async functions. Once one function goes async, everything above it in the call chain must follow.
In Rust this is worse than in C# or JavaScript, because async doesn’t just infect function signatures — it infects types:
| Sync code | Async equivalent | Why it’s different |
|---|---|---|
fn process(&self) | async fn process(&self) | Callers must be async too |
&mut T | Arc<Mutex<T>> | Spawned tasks need 'static + Send |
std::sync::Mutex | tokio::sync::Mutex | Different type if held across .await |
impl Trait return | impl Future<Output = T> + Send | Simpler since RPITIT (Rust 1.75, ch10), but still colored |
#[test] | #[tokio::test] | Tests need a runtime |
| Stack trace: 5 frames | Stack trace: 25 frames | Half are runtime internals |
Every row is a decision someone must make, get right, and maintain — and none of it is about business logic. The industry is moving away from this: Java’s Project Loom (virtual threads) and Go’s goroutines both let you write synchronous-looking code that the runtime multiplexes cheaply. Rust chose explicit async for zero-cost control, but that control has a complexity cost that should be paid consciously, not by default.
“But Threads Are Expensive”
The reflexive counter: “we need async because threads are expensive.” Mostly wrong at the scale where most teams operate.
- Stack memory: Each OS thread reserves 8MB of virtual address space (Linux default), but the OS only commits pages as touched — a mostly-idle thread uses 20-80KB of physical memory.
- Context switches: ~1-5µs on modern hardware. At 50 concurrent requests, this is noise. At 100K switches/second, it’s measurable.
- Creation cost: ~10-30µs per thread on Linux. A thread pool (rayon,
std::thread::scope) amortizes this to zero.
The honest threshold where async earns its complexity is roughly 1K-10K concurrent mostly-idle connections — the epoll/io_uring sweet spot where per-connection stacks become a real cost. Below that, a thread pool is simpler, faster to debug, and fast enough. Above that, async wins. Most services are below that.
The Hard Example: Logic That Also Needs I/O
A trivial pure function — fn add(a: i32, b: i32) -> i32 — obviously doesn’t need async. That’s not an interesting lesson. The interesting case is when business logic seems to need I/O in the middle: validation that checks inventory, pricing that queries an exchange rate, an order pipeline that looks up a customer.
Consider an order processing service. The async-everywhere version looks natural:
Version A: Async Through the Core
#![allow(unused)]
fn main() {
// orders.rs — async all the way down
pub async fn process_order(order: Order) -> Result<Receipt, OrderError> {
// Step 1: Validate — pure business rules, no I/O
validate_items(&order)?;
validate_quantities(&order)?;
// Step 2: Check inventory — needs a database call
let stock = inventory_client.check(&order.items).await?;
if !stock.all_available() {
return Err(OrderError::OutOfStock(stock.missing()));
}
// Step 3: Calculate pricing — pure math, but async because we're already here
let pricing = calculate_pricing(&order, &stock);
// Step 4: Apply discount — needs an external service call
let discount = discount_service.lookup(order.customer_id).await?;
let final_price = pricing.apply_discount(discount);
// Step 5: Format receipt — pure
Ok(Receipt::new(order, final_price))
}
}
This is reasonable async code. No Arc<Mutex> abuse — just sequential awaits. Most developers would write it this way and move on. But look at what happened: validate_items, validate_quantities, calculate_pricing, and Receipt::new are all pure functions that got dragged into an async context because steps 2 and 4 need I/O. The entire function must be async, its tests need a runtime, and every caller up the chain is now colored.
Version B: Sync Core, Async Shell
The alternative: separate what to decide from how to fetch:
#![allow(unused)]
fn main() {
// core.rs — pure business logic, zero async, zero tokio dependency
pub fn validate_order(order: &Order) -> Result<ValidatedOrder, OrderError> {
validate_items(order)?;
validate_quantities(order)?;
Ok(ValidatedOrder::from(order))
}
pub fn check_stock(
order: &ValidatedOrder,
stock: &StockResult,
) -> Result<StockedOrder, OrderError> {
if !stock.all_available() {
return Err(OrderError::OutOfStock(stock.missing()));
}
Ok(StockedOrder::from(order, stock))
}
pub fn finalize(
order: &StockedOrder,
discount: Discount,
) -> Receipt {
let pricing = calculate_pricing(order);
let final_price = pricing.apply_discount(discount);
Receipt::new(order, final_price)
}
}
#![allow(unused)]
fn main() {
// shell.rs — thin async orchestrator
//
// Note: the `?` on network calls requires `impl From<reqwest::Error> for OrderError`
// (or a unified error enum). See ch12 for async error handling patterns.
use crate::core;
pub async fn process_order(order: Order) -> Result<Receipt, OrderError> {
// Sync: validate
let validated = core::validate_order(&order)?;
// Async: fetch inventory (this is the shell's job)
let stock = inventory_client.check(&validated.items).await?;
// Sync: apply business rule to fetched data
let stocked = core::check_stock(&validated, &stock)?;
// Async: fetch discount
let discount = discount_service.lookup(order.customer_id).await?;
// Sync: finalize
Ok(core::finalize(&stocked, discount))
}
}
The async shell is a pipeline of fetch → decide → fetch → decide. Each “decide” step is a sync function that takes the I/O result as input instead of reaching out for it.
Testing the Difference
The sync core tests every business rule without a runtime or mocks:
#![allow(unused)]
fn main() {
#[test]
fn out_of_stock_rejects_order() {
let order = validated_order(vec![item("widget", 10)]);
let stock = stock_result(vec![("widget", 3)]); // only 3 available
let result = core::check_stock(&order, &stock);
assert_eq!(result.unwrap_err(), OrderError::OutOfStock(vec!["widget"]));
}
#[test]
fn discount_applied_correctly() {
let order = stocked_order(100_00); // price in cents
let receipt = core::finalize(&order, Discount::Percent(15));
assert_eq!(receipt.final_price, 85_00);
}
}
The async shell gets a thinner integration test that verifies the wiring, not the logic:
#![allow(unused)]
fn main() {
#[tokio::test]
async fn process_order_integration() {
let mock_inventory = mock_service(/* returns stock */);
let mock_discounts = mock_service(/* returns 10% */);
let receipt = process_order(sample_order()).await.unwrap();
assert!(receipt.final_price > 0);
// Logic correctness is already proven by core tests above
}
}
Why This Matters
| Concern | Async through the core | Sync core + async shell |
|---|---|---|
| Business rules testable without runtime | No | Yes |
Number of unit tests needing #[tokio::test] | All of them | Only integration tests |
| I/O failures entangled with logic errors | Yes — one Result type for both | No — sync returns logic errors, shell handles I/O errors |
validate_order reusable in CLI / WASM / batch | No — pulls in tokio transitively | Yes — pure fn |
| Stack traces through business logic | Interleaved with runtime frames | Clean |
| Can swap HTTP client for gRPC later | Requires changing core functions | Shell change only |
The key insight: the I/O calls in steps 2 and 4 don’t need to be inside the business logic. They’re inputs to it. The sync core takes StockResult and Discount as arguments. Where those values came from — HTTP, gRPC, a test fixture, a cache — is the shell’s concern.
The spawn_blocking Smell
Chapter 12 introduced spawn_blocking as a fix for accidentally blocking the executor. That’s the right fix when you have a one-off blocking call — std::fs::read, a compression library, a legacy FFI function.
But if you find yourself wrapping large sections of code in spawn_blocking:
#![allow(unused)]
fn main() {
async fn handler(req: Request) -> Response {
// If this is your codebase, the boundary is in the wrong place
tokio::task::spawn_blocking(move || {
let validated = validate(&req); // sync
let enriched = enrich(validated); // sync
let result = process(enriched); // sync
let output = format_response(result); // sync
output
}).await.unwrap()
}
}
…that’s the codebase telling you: this logic was never async to begin with. You don’t need spawn_blocking — you need a sync module that the async handler calls directly:
#![allow(unused)]
fn main() {
async fn handler(req: Request) -> Response {
// validate → enrich → process → format are all sync.
// No spawn_blocking needed — they're fast and CPU-light.
let response = my_core::handle(req);
response
}
}
Reserve spawn_blocking for genuinely heavy CPU work (parsing large payloads, image processing, compression) where the time cost would actually starve the executor. For ordinary business logic that runs in microseconds, a direct sync call is simpler and correct.
Libraries: Sync First, Async Wrapper Optional
The boundary question is even more consequential for library authors. A sync library can be used from both sync and async callers:
// A sync library — usable everywhere
let report = my_lib::analyze(&data);
// Caller A: sync CLI
fn main() {
let report = my_lib::analyze(&data);
println!("{report}");
}
// Caller B: async handler, works fine
async fn handler() -> Json<Report> {
let report = my_lib::analyze(&data); // sync call in async context — fine
Json(report)
}
// Caller C: heavy analysis — caller decides to offload
async fn handler_heavy() -> Json<Report> {
let data = data.clone();
let report = tokio::task::spawn_blocking(move || {
my_lib::analyze(&data) // caller controls the async boundary
}).await.unwrap();
Json(report)
}
An async library forces all callers into a runtime:
#![allow(unused)]
fn main() {
// An async library — only usable from async contexts
let report = my_lib::analyze(&data).await; // caller MUST be async
// Sync caller? Now you need block_on — and hope there's no nested runtime
let report = tokio::runtime::Runtime::new().unwrap().block_on(
my_lib::analyze(&data)
); // fragile, panic-prone if already inside a runtime
}
Default to sync APIs. If your library does pure computation, data transformation, or parsing, there is no reason for it to be async. If it does I/O, consider offering a sync core with an optional async convenience layer behind a feature flag — let the caller own the boundary decision.
When Async Belongs in the Core
Not everything can be cleanly separated. Async belongs in your core logic when:
-
Fan-out/fan-in is the logic. If your business rule is “query 5 pricing services concurrently and return the cheapest,” the concurrency is the logic, not plumbing. Forcing this through sync + threads is reinventing a worse async.
-
Streaming is the logic. Processing a continuous event stream with backpressure — the stream management is non-trivial business logic, not just an I/O wrapper.
-
Long-lived stateful connections. WebSocket handlers, gRPC bidirectional streams, and protocol state machines have state transitions inherently tied to I/O events. The capstone project in ch17 — an async chat server — is exactly this case: concurrent connections, room-based fan-out, and graceful shutdown are fundamentally async work.
The test: if removing async from a function would require replacing it with threads, channels, or manual polling, then async is pulling its weight. If removing async would just mean deleting the keyword with no other changes, it never needed to be async.
Decision Rule
graph TD
START["Should this function be async?"] --> IO{"Does it do I/O?"}
IO -->|No| SYNC["sync fn — always"]
IO -->|Yes| BOUNDARY{"Is it at the boundary?<br/>handler, main loop, accept()"}
BOUNDARY -->|Yes| ASYNC_SHELL["async fn — this is the shell"]
BOUNDARY -->|No| CORE_IO{"Is the I/O the core logic?<br/>fan-out, streaming, stateful conn"}
CORE_IO -->|Yes| ASYNC_CORE["async fn — justified"]
CORE_IO -->|No| EXTRACT["Extract logic into sync fn.<br/>Pass I/O results in as arguments."]
style SYNC fill:#d4efdf,stroke:#27ae60,color:#000
style ASYNC_SHELL fill:#e8f4f8,stroke:#2980b9,color:#000
style ASYNC_CORE fill:#e8f4f8,stroke:#2980b9,color:#000
style EXTRACT fill:#d4efdf,stroke:#27ae60,color:#000
Rule of thumb: Start sync. Add async only at the outermost I/O boundary. Pull it inward only when you can articulate which concurrent I/O operations justify the complexity tax.
🏋️ Exercise: Extract the Sync Core (click to expand)
The following axum handler has async contamination — business logic mixed with I/O. Refactor it into a sync core module and a thin async shell.
#![allow(unused)]
fn main() {
use axum::{Json, extract::Path};
async fn get_device_report(Path(device_id): Path<String>) -> Result<Json<Report>, AppError> {
// Fetch raw telemetry from the device over HTTP
let raw = reqwest::get(format!("http://bmc-{device_id}/telemetry"))
.await?
.json::<RawTelemetry>()
.await?;
// Business logic: convert raw sensor readings to calibrated values
let mut readings = Vec::new();
for sensor in &raw.sensors {
let calibrated = (sensor.raw_value as f64) * sensor.scale + sensor.offset;
if calibrated < sensor.min_valid || calibrated > sensor.max_valid {
return Err(AppError::SensorOutOfRange {
name: sensor.name.clone(),
value: calibrated,
});
}
readings.push(CalibratedReading {
name: sensor.name.clone(),
value: calibrated,
unit: sensor.unit.clone(),
});
}
// Business logic: classify device health
let critical_count = readings.iter()
.filter(|r| r.value > 90.0)
.count();
let health = if critical_count > 2 { Health::Critical }
else if critical_count > 0 { Health::Warning }
else { Health::Ok };
// Fetch device metadata from inventory service
let meta = reqwest::get(format!("http://inventory/devices/{device_id}"))
.await?
.json::<DeviceMetadata>()
.await?;
Ok(Json(Report {
device_id,
device_name: meta.name,
health,
readings,
timestamp: chrono::Utc::now(),
}))
}
}
Your goals:
- Create
core.rswith sync functions:calibrate_sensors,classify_health, andbuild_report - Create
shell.rswith a thin async handler that fetches, then calls the sync core - Write
#[test](not#[tokio::test]) for: a sensor out of range, health classification thresholds, and a normal report
Hints:
- The sync core should take
RawTelemetryandDeviceMetadataas inputs — it should never know those came from HTTP. - You’ll need to define small test helper functions (e.g.,
raw_telemetry(),sensor(),reading(),device_meta()) that construct test fixtures. Their signatures should be obvious from usage.
🔑 Solution
#![allow(unused)]
fn main() {
// core.rs — zero async dependency
pub fn calibrate_sensors(raw: &RawTelemetry) -> Result<Vec<CalibratedReading>, AppError> {
raw.sensors.iter().map(|sensor| {
let calibrated = (sensor.raw_value as f64) * sensor.scale + sensor.offset;
if calibrated < sensor.min_valid || calibrated > sensor.max_valid {
return Err(AppError::SensorOutOfRange {
name: sensor.name.clone(),
value: calibrated,
});
}
Ok(CalibratedReading {
name: sensor.name.clone(),
value: calibrated,
unit: sensor.unit.clone(),
})
}).collect()
}
pub fn classify_health(readings: &[CalibratedReading]) -> Health {
let critical_count = readings.iter()
.filter(|r| r.value > 90.0)
.count();
if critical_count > 2 { Health::Critical }
else if critical_count > 0 { Health::Warning }
else { Health::Ok }
}
pub fn build_report(
device_id: String,
readings: Vec<CalibratedReading>,
meta: &DeviceMetadata,
) -> Report {
Report {
device_id,
device_name: meta.name.clone(),
health: classify_health(&readings),
readings,
timestamp: chrono::Utc::now(),
}
}
}
#![allow(unused)]
fn main() {
// shell.rs — async boundary only
pub async fn get_device_report(
Path(device_id): Path<String>,
) -> Result<Json<Report>, AppError> {
let raw = reqwest::get(format!("http://bmc-{device_id}/telemetry"))
.await?
.json::<RawTelemetry>()
.await?;
let readings = core::calibrate_sensors(&raw)?;
let meta = reqwest::get(format!("http://inventory/devices/{device_id}"))
.await?
.json::<DeviceMetadata>()
.await?;
Ok(Json(core::build_report(device_id, readings, &meta)))
}
}
#![allow(unused)]
fn main() {
// core_tests.rs — no runtime needed
// Test fixture helpers — construct data without any I/O
fn sensor(name: &str, raw_value: f64, valid_range: std::ops::Range<f64>) -> RawSensor {
RawSensor {
name: name.into(),
raw_value,
scale: 1.0,
offset: 0.0,
min_valid: valid_range.start,
max_valid: valid_range.end,
unit: "unit".into(),
}
}
fn raw_telemetry(sensors: Vec<RawSensor>) -> RawTelemetry {
RawTelemetry { sensors }
}
fn reading(name: &str, value: f64) -> CalibratedReading {
CalibratedReading { name: name.into(), value, unit: "unit".into() }
}
fn device_meta(name: &str) -> DeviceMetadata {
DeviceMetadata { name: name.into() }
}
#[test]
fn sensor_out_of_range_rejected() {
let raw = raw_telemetry(vec![sensor("gpu_temp", 105.0, 0.0..100.0)]);
let result = core::calibrate_sensors(&raw);
assert!(matches!(result, Err(AppError::SensorOutOfRange { .. })));
}
#[test]
fn health_classification() {
let readings = vec![
reading("a", 50.0), // ok
reading("b", 95.0), // critical
reading("c", 91.0), // critical
reading("d", 92.0), // critical
];
assert_eq!(core::classify_health(&readings), Health::Critical);
}
#[test]
fn normal_report() {
let raw = raw_telemetry(vec![sensor("fan_rpm", 3000.0, 0.0..10000.0)]);
let readings = core::calibrate_sensors(&raw).unwrap();
let meta = device_meta("gpu-node-42");
let report = core::build_report("dev-1".into(), readings, &meta);
assert_eq!(report.health, Health::Ok);
assert_eq!(report.readings.len(), 1);
}
}
What changed: The async handler went from 30 lines of mixed logic and I/O to 8 lines of pure orchestration. The business rules (calibration math, range validation, health thresholds) are now tested with #[test], run in milliseconds, and have zero dependency on tokio, reqwest, or any HTTP mock server.
Key Takeaways:
- Async is an I/O multiplexing optimization, not an application architecture. Most business logic is sync.
- Sync core, async shell: keep business rules in pure sync functions that take I/O results as arguments. The async shell orchestrates fetches and calls the core.
- If you’re wrapping large blocks in
spawn_blocking, the boundary is in the wrong place — refactor the logic into a sync module instead.- Libraries should default to sync APIs. An async library forces all callers into a runtime; a sync library lets the caller own the async boundary.
- Async earns its keep for fan-out/fan-in, streaming, and stateful connections — the cases where the concurrency is the business logic.
See also: Ch12 — Common Pitfalls (spawn_blocking as a tactical fix) · Ch13 — Production Patterns (backpressure, structured concurrency) · Ch17 — Capstone: Async Chat Server (a case where async is the right architecture)
Exercises
Exercise 1: Async Echo Server
Build a TCP echo server that handles multiple clients concurrently.
Requirements:
- Listen on
127.0.0.1:8080 - Accept connections and echo back each line
- Handle client disconnections gracefully
- Print a log when clients connect/disconnect
🔑 Solution
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Echo server listening on :8080");
loop {
let (socket, addr) = listener.accept().await?;
println!("[{addr}] Connected");
tokio::spawn(async move {
let (reader, mut writer) = socket.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => {
println!("[{addr}] Disconnected");
break;
}
Ok(_) => {
print!("[{addr}] Echo: {line}");
if writer.write_all(line.as_bytes()).await.is_err() {
println!("[{addr}] Write error, disconnecting");
break;
}
}
Err(e) => {
eprintln!("[{addr}] Read error: {e}");
break;
}
}
}
});
}
}
Exercise 2: Concurrent URL Fetcher with Rate Limiting
Fetch a list of URLs concurrently, with at most 5 concurrent requests.
🔑 Solution
#![allow(unused)]
fn main() {
use futures::stream::{self, StreamExt};
use tokio::time::{sleep, Duration};
async fn fetch_urls(urls: Vec<String>) -> Vec<Result<String, String>> {
// buffer_unordered(5) ensures at most 5 futures are polled
// concurrently — no separate Semaphore needed here.
let results: Vec<_> = stream::iter(urls)
.map(|url| {
async move {
println!("Fetching: {url}");
match reqwest::get(&url).await {
Ok(resp) => match resp.text().await {
Ok(body) => Ok(body),
Err(e) => Err(format!("{url}: {e}")),
},
Err(e) => Err(format!("{url}: {e}")),
}
}
})
.buffer_unordered(5) // ← This alone limits concurrency to 5
.collect()
.await;
results
}
// NOTE: Use Semaphore when you need to limit concurrency across
// independently spawned tasks (tokio::spawn). Use buffer_unordered
// when processing a stream. Don't combine both for the same limit.
}
Exercise 3: Graceful Shutdown with Worker Pool
Build a task processor with:
- A channel-based work queue
- N worker tasks consuming from the queue
- Graceful shutdown on Ctrl+C: stop accepting, finish in-flight work
🔑 Solution
use tokio::sync::{mpsc, watch};
use tokio::time::{sleep, Duration};
struct WorkItem {
id: u64,
payload: String,
}
#[tokio::main]
async fn main() {
let (work_tx, work_rx) = mpsc::channel::<WorkItem>(100);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
// Spawn 4 workers
let mut worker_handles = Vec::new();
let work_rx = std::sync::Arc::new(tokio::sync::Mutex::new(work_rx));
for id in 0..4 {
let rx = work_rx.clone();
let mut shutdown = shutdown_rx.clone();
let handle = tokio::spawn(async move {
loop {
let item = {
let mut rx = rx.lock().await;
tokio::select! {
item = rx.recv() => item,
_ = shutdown.changed() => {
if *shutdown.borrow() { None } else { continue }
}
}
};
match item {
Some(work) => {
println!("Worker {id}: processing item {}", work.id);
sleep(Duration::from_millis(200)).await; // Simulate work
println!("Worker {id}: done with item {}", work.id);
}
None => {
println!("Worker {id}: channel closed, exiting");
break;
}
}
}
});
worker_handles.push(handle);
}
// Producer: submit some work
let producer = tokio::spawn(async move {
for i in 0..20 {
let _ = work_tx.send(WorkItem {
id: i,
payload: format!("task-{i}"),
}).await;
sleep(Duration::from_millis(50)).await;
}
});
// Wait for Ctrl+C
tokio::signal::ctrl_c().await.unwrap();
println!("\nShutdown signal received!");
shutdown_tx.send(true).unwrap();
producer.abort(); // Cancel the producer task
// Wait for workers to finish
for handle in worker_handles {
let _ = handle.await;
}
println!("All workers shut down. Goodbye!");
}
Exercise 4: Build a Simple Async Mutex from Scratch
Implement an async-aware mutex using channels (without using tokio::sync::Mutex).
Hint: Use a tokio::sync::mpsc channel with capacity 1 as a semaphore.
🔑 Solution
#![allow(unused)]
fn main() {
use std::cell::UnsafeCell;
use std::sync::Arc;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
pub struct SimpleAsyncMutex<T> {
data: Arc<UnsafeCell<T>>,
semaphore: Arc<Semaphore>,
}
// SAFETY: Access to T is serialized by the semaphore (max 1 permit).
unsafe impl<T: Send> Send for SimpleAsyncMutex<T> {}
unsafe impl<T: Send> Sync for SimpleAsyncMutex<T> {}
pub struct SimpleGuard<T> {
data: Arc<UnsafeCell<T>>,
_permit: OwnedSemaphorePermit, // Dropped on guard drop → releases lock
}
impl<T> SimpleAsyncMutex<T> {
pub fn new(value: T) -> Self {
SimpleAsyncMutex {
data: Arc::new(UnsafeCell::new(value)),
semaphore: Arc::new(Semaphore::new(1)),
}
}
pub async fn lock(&self) -> SimpleGuard<T> {
let permit = self.semaphore.clone().acquire_owned().await.unwrap();
SimpleGuard {
data: self.data.clone(),
_permit: permit,
}
}
}
impl<T> std::ops::Deref for SimpleGuard<T> {
type Target = T;
fn deref(&self) -> &T {
// SAFETY: We hold the only semaphore permit, so no other
// SimpleGuard exists → exclusive access is guaranteed.
unsafe { &*self.data.get() }
}
}
impl<T> std::ops::DerefMut for SimpleGuard<T> {
fn deref_mut(&mut self) -> &mut T {
// SAFETY: Same reasoning — single permit guarantees exclusivity.
unsafe { &mut *self.data.get() }
}
}
// When SimpleGuard is dropped, _permit is dropped,
// which releases the semaphore permit — another lock() can proceed.
// Usage:
// let mutex = SimpleAsyncMutex::new(vec![1, 2, 3]);
// {
// let mut guard = mutex.lock().await;
// guard.push(4);
// } // permit released here
}
Key takeaway: Async mutexes are typically built on top of semaphores. The semaphore provides the async wait mechanism — when locked, acquire() suspends the task until the permit is released. This is exactly how tokio::sync::Mutex works internally.
Why
UnsafeCelland notstd::sync::Mutex? A previous version of this exercise usedArc<Mutex<T>>withDeref/DerefMutcalling.lock().unwrap(). That doesn’t compile — the returned&Tborrows from a temporaryMutexGuardthat’s dropped immediately.UnsafeCellavoids the intermediate guard, and the semaphore-based serialization makes theunsafesound.
Exercise 5: Stream Pipeline
Build a data processing pipeline using streams:
- Generate numbers 1..=100
- Filter to even numbers
- Map each to its square
- Process 10 at a time concurrently (simulate with sleep)
- Collect results
🔑 Solution
use futures::stream::{self, StreamExt};
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let results: Vec<u64> = stream::iter(1u64..=100)
// Step 2: Filter evens
.filter(|x| futures::future::ready(x % 2 == 0))
// Step 3: Square each
.map(|x| x * x)
// Step 4: Process concurrently (simulate async work)
.map(|x| async move {
sleep(Duration::from_millis(50)).await;
println!("Processed: {x}");
x
})
.buffer_unordered(10) // 10 concurrent
// Step 5: Collect
.collect()
.await;
println!("Got {} results", results.len());
println!("Sum: {}", results.iter().sum::<u64>());
}
Exercise 6: Implement Select with Timeout
Without using tokio::select! or tokio::time::timeout, implement a function that races a future against a deadline and returns Either::Left(result) or Either::Right(()) on timeout.
Hint: Build on the Select combinator from Chapter 6 and the TimerFuture from the same chapter.
🔑 Solution
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
pub enum Either<A, B> {
Left(A),
Right(B),
}
pub struct Timeout<F> {
future: F,
timer: TimerFuture, // From Chapter 6
}
impl<F: Future + Unpin> Timeout<F> {
pub fn new(future: F, duration: Duration) -> Self {
Timeout {
future,
timer: TimerFuture::new(duration),
}
}
}
impl<F: Future + Unpin> Future for Timeout<F> {
type Output = Either<F::Output, ()>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// Check if the main future is done
if let Poll::Ready(val) = Pin::new(&mut self.future).poll(cx) {
return Poll::Ready(Either::Left(val));
}
// Check if the timer expired
if let Poll::Ready(()) = Pin::new(&mut self.timer).poll(cx) {
return Poll::Ready(Either::Right(()));
}
Poll::Pending
}
}
// Usage:
// match Timeout::new(fetch_data(), Duration::from_secs(5)).await {
// Either::Left(data) => println!("Got data: {data}"),
// Either::Right(()) => println!("Timed out!"),
// }
Key takeaway: select/timeout is just polling two futures and seeing which completes first. The entire async ecosystem is built from this simple primitive: poll, Pending/Ready, Waker.
Summary and Reference Card
Quick Reference Card
Async Mental Model
┌─────────────────────────────────────────────────────┐
│ async fn → State Machine (enum) → impl Future │
│ .await → poll() the inner future │
│ executor → loop { poll(); sleep_until_woken(); } │
│ waker → "hey executor, poll me again" │
│ Pin → "promise I won't move in memory" │
└─────────────────────────────────────────────────────┘
Common Patterns Cheat Sheet
| Goal | Use |
|---|---|
| Run two futures concurrently | tokio::join!(a, b) |
| Race two futures | tokio::select! { ... } |
| Spawn a background task | tokio::spawn(async { ... }) |
| Run blocking code in async | tokio::task::spawn_blocking(\|\| { ... }) |
| Limit concurrency | Semaphore::new(N) |
| Collect many task results | JoinSet |
| Share state across tasks | Arc<Mutex<T>> or channels |
| Graceful shutdown | watch::channel + select! |
| Process a stream N-at-a-time | .buffer_unordered(N) |
| Timeout a future | tokio::time::timeout(dur, fut) |
| Retry with backoff | Custom combinator (see Ch. 13) |
Pinning Quick Reference
| Situation | Use |
|---|---|
| Pin a future on the heap | Box::pin(fut) |
| Pin a future on the stack | tokio::pin!(fut) |
Pin an Unpin type | Pin::new(&mut val) — safe, free |
| Return a pinned trait object | -> Pin<Box<dyn Future<Output = T> + Send>> |
Channel Selection Guide
| Channel | Producers | Consumers | Values | Use When |
|---|---|---|---|---|
mpsc | N | 1 | Stream | Work queues, event buses |
oneshot | 1 | 1 | Single | Request/response, completion notification |
broadcast | N | N | All recv all | Fan-out notifications, shutdown signals |
watch | 1 | N | Latest only | Config updates, health status |
Mutex Selection Guide
| Mutex | Use When |
|---|---|
std::sync::Mutex | Lock is held briefly, never across .await |
tokio::sync::Mutex | Lock must be held across .await |
parking_lot::Mutex | High contention, no .await, need performance |
tokio::sync::RwLock | Many readers, few writers, locks cross .await |
Decision Quick Reference
Need concurrency?
├── I/O-bound → async/await
├── CPU-bound → rayon / std::thread
└── Mixed → spawn_blocking for CPU parts
Choosing runtime?
├── Server app → tokio
├── Library → runtime-agnostic (futures crate)
├── Embedded → embassy
└── Minimal → smol
Need concurrent futures?
├── Can be 'static + Send → tokio::spawn
├── Can be 'static + !Send → LocalSet
├── Can't be 'static → FuturesUnordered
└── Need to track/abort → JoinSet
Common Error Messages and Fixes
| Error | Cause | Fix |
|---|---|---|
future is not Send | Holding !Send type across .await | Scope the value so it’s dropped before .await, or use current_thread runtime |
borrowed value does not live long enough in spawn | tokio::spawn requires 'static | Use Arc, clone(), or FuturesUnordered |
the trait Future is not implemented for () | Missing .await | Add .await to the async call |
cannot borrow as mutable in poll | Self-referential borrow | Use Pin<&mut Self> correctly (see Ch. 4) |
| Program hangs silently | Forgot to call waker.wake() | Ensure every Pending path registers and triggers the waker |
Further Reading
| Resource | Why |
|---|---|
| Tokio Tutorial | Official hands-on guide — excellent for first projects |
| Async Book (official) | Covers Future, Pin, Stream at the language level |
| Jon Gjengset — Crust of Rust: async/await | 2-hour deep dive into internals with live coding |
| Alice Ryhl — Actors with Tokio | Production architecture pattern for stateful services |
| Without Boats — Pin, Unpin, and why Rust needs them | The original motivation from the language designer |
| Tokio mini-Redis | Complete async Rust project — study-quality production code |
| Tower documentation | Middleware/service architecture used by axum, tonic, hyper |
End of Async Rust Training Guide
Capstone Project: Async Chat Server
This project integrates patterns from across the book into a single, production-style application. You’ll build a multi-room async chat server using tokio, channels, streams, graceful shutdown, and proper error handling.
Estimated time: 4–6 hours | Difficulty: ★★★
What you’ll practice:
tokio::spawnand the'staticrequirement (Ch 8)- Channels:
mpscfor messages,broadcastfor rooms,watchfor shutdown (Ch 8)- Streams: reading lines from TCP connections (Ch 11)
- Common pitfalls: cancellation safety, MutexGuard across
.await(Ch 12)- Production patterns: graceful shutdown, backpressure (Ch 13)
- Async traits for pluggable backends (Ch 10)
The Problem
Build a TCP chat server where:
- Clients connect via TCP and join named rooms
- Messages are broadcast to all clients in the same room
- Commands:
/join <room>,/nick <name>,/rooms,/quit - The server shuts down gracefully on Ctrl+C — finishing in-flight messages
graph LR
C1["Client 1<br/>(Alice)"] -->|TCP| SERVER["Chat Server"]
C2["Client 2<br/>(Bob)"] -->|TCP| SERVER
C3["Client 3<br/>(Carol)"] -->|TCP| SERVER
SERVER --> R1["#general<br/>broadcast channel"]
SERVER --> R2["#rust<br/>broadcast channel"]
R1 -->|msg| C1
R1 -->|msg| C2
R2 -->|msg| C3
CTRL["Ctrl+C"] -->|watch| SERVER
style SERVER fill:#e8f4f8,stroke:#2980b9,color:#000
style R1 fill:#d4efdf,stroke:#27ae60,color:#000
style R2 fill:#d4efdf,stroke:#27ae60,color:#000
style CTRL fill:#fadbd8,stroke:#e74c3c,color:#000
Step 1: Basic TCP Accept Loop
Start with a server that accepts connections and echoes lines back:
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Chat server listening on :8080");
loop {
let (socket, addr) = listener.accept().await?;
println!("[{addr}] Connected");
tokio::spawn(async move {
let (reader, mut writer) = socket.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => {
let _ = writer.write_all(line.as_bytes()).await;
}
}
}
println!("[{addr}] Disconnected");
});
}
}
Your job: Verify this compiles and works with telnet localhost 8080.
Step 2: Room State with Broadcast Channels
Each room is a broadcast::Sender. All clients in a room subscribe to receive messages.
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
type RoomMap = Arc<RwLock<HashMap<String, broadcast::Sender<String>>>>;
fn get_or_create_room(rooms: &mut HashMap<String, broadcast::Sender<String>>, name: &str) -> broadcast::Sender<String> {
rooms.entry(name.to_string())
.or_insert_with(|| {
let (tx, _) = broadcast::channel(100); // 100-message buffer
tx
})
.clone()
}
}
Your job: Implement room state so that:
- Clients start in
#general /join <room>switches rooms (unsubscribe from old, subscribe to new)- Messages are broadcast to all clients in the sender’s current room
💡 Hint — Client task structure
Each client task needs two concurrent loops:
- Read from TCP → parse commands or broadcast to room
- Read from broadcast receiver → write to TCP
Use tokio::select! to run both:
#![allow(unused)]
fn main() {
loop {
tokio::select! {
// Client sent us a line
result = reader.read_line(&mut line) => {
match result {
Ok(0) | Err(_) => break,
Ok(_) => {
// Parse command or broadcast message
}
}
}
// Room broadcast received
result = room_rx.recv() => {
match result {
Ok(msg) => {
let _ = writer.write_all(msg.as_bytes()).await;
}
Err(_) => break,
}
}
}
}
}
Step 3: Commands
Implement the command protocol:
| Command | Action |
|---|---|
/join <room> | Leave current room, join new room, announce in both |
/nick <name> | Change display name |
/rooms | List all active rooms and member counts |
/quit | Disconnect gracefully |
| Anything else | Broadcast as a chat message |
Your job: Parse commands from the input line. For /rooms, you’ll need to read from the RoomMap — use RwLock::read() to avoid blocking other clients.
Step 4: Graceful Shutdown
Add Ctrl+C handling so the server:
- Stops accepting new connections
- Sends “Server shutting down…” to all rooms
- Waits for in-flight messages to drain
- Exits cleanly
#![allow(unused)]
fn main() {
use tokio::sync::watch;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
// In the accept loop:
loop {
tokio::select! {
result = listener.accept() => {
let (socket, addr) = result?;
// spawn client task with shutdown_rx.clone()
}
_ = tokio::signal::ctrl_c() => {
println!("Shutdown signal received");
shutdown_tx.send(true)?;
break;
}
}
}
}
Your job: Add shutdown_rx.changed() to each client’s select! loop so clients exit when shutdown is signaled.
Step 5: Error Handling and Edge Cases
Production-harden the server:
- Lagging receivers:
broadcast::recv()returnsRecvError::Lagged(n)if a slow client misses messages. Handle it gracefully (log + continue, don’t crash). - Nickname validation: Reject empty or too-long nicknames.
- Backpressure: The broadcast channel buffer is bounded (100). If a client can’t keep up, they get the
Laggederror. - Timeout: Disconnect clients that are idle for >5 minutes.
#![allow(unused)]
fn main() {
use tokio::time::{timeout, Duration};
// Wrap the read in a timeout:
match timeout(Duration::from_secs(300), reader.read_line(&mut line)).await {
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => break, // EOF, error, or timeout
Ok(Ok(_)) => { /* process line */ }
}
}
Step 6: Integration Test
Write a test that starts the server, connects two clients, and verifies message delivery:
#![allow(unused)]
fn main() {
#[tokio::test]
async fn two_clients_can_chat() {
// Start server in background
let server = tokio::spawn(run_server("127.0.0.1:0")); // Port 0 = OS picks
// Connect two clients
let mut client1 = TcpStream::connect(addr).await.unwrap();
let mut client2 = TcpStream::connect(addr).await.unwrap();
// Client 1 sends a message
client1.write_all(b"Hello from client 1\n").await.unwrap();
// Client 2 should receive it
let mut buf = vec![0u8; 1024];
let n = client2.read(&mut buf).await.unwrap();
let msg = String::from_utf8_lossy(&buf[..n]);
assert!(msg.contains("Hello from client 1"));
}
}
Evaluation Criteria
| Criterion | Target |
|---|---|
| Concurrency | Multiple clients in multiple rooms, no blocking |
| Correctness | Messages only go to clients in the same room |
| Graceful shutdown | Ctrl+C drains messages and exits cleanly |
| Error handling | Lagged receivers, disconnections, timeouts handled |
| Code organization | Clean separation: accept loop, client task, room state |
| Testing | At least 2 integration tests |
Extension Ideas
Once the basic chat server works, try these enhancements:
- Persistent history: Store last N messages per room; replay to new joiners
- WebSocket support: Accept both TCP and WebSocket clients using
tokio-tungstenite - Rate limiting: Use
tokio::time::Intervalto limit messages per client per second - Metrics: Track connected clients, messages/sec, room count via
prometheuscrate - TLS: Add
tokio-rustlsfor encrypted connections