Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

English Original

异步 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 章)将所有内容整合到一个项目中。为了获得最佳学习效果:

  1. 在展开答案前先尝试练习 —— 挣扎的过程正是学习发生的时候
  2. 动手输入代码,不要复制粘贴 —— 肌肉记忆对 Rust 的语法很重要
  3. 运行每个示例 —— cargo new async-exercises 并边做边测试

目录

第一部分:异步如何工作

第二部分:生态系统

第三部分:生产级异步

附录


English Original

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# / JavaScriptPythonGoRust
创建时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 可以轻松扩展到数百个线程。

练习:你会在什么时候使用异步?

🏋️ 练习(点击展开)

针对以下每个场景,决定使用异步是否合适,并说明原因:

  1. 一个处理 10,000 个并发 WebSocket 连接的 Web 服务器
  2. 一个压缩单个大文件的 CLI 工具
  3. 一个查询 5 个不同数据库并合并结果的服务
  4. 一个以 60 FPS 运行物理模拟的游戏引擎
🔑 答案
  1. 异步 —— I/O 密集型且具有极高并发。每个连接大部分时间都在等待数据。线程将需要 10K 个栈空间。
  2. 同步/线程 —— CPU 密集型,单一任务。异步只会增加开销而无收益。使用 rayon 进行并行压缩。
  3. 异步 —— 五个并发的 I/O 等待。tokio::join! 可以同时运行这五个查询。
  4. 同步/线程 —— CPU 密集型,对延迟敏感。异步的协作式调度可能会引入帧抖动(jitter)。

关键要诀 —— 为什么异步在 Rust 中不同

  • Rust 的 future 是惰性的 —— 除非被执行器轮询,否则它们什么都不做
  • 没有内置运行时 —— 你可以根据需求选择(或构建)自己的运行时
  • 异步是一种零成本编译策略,最终生成状态机
  • 异步在 I/O 密集型并发 中表现卓越;对于 CPU 密集型工作,请使用线程或 rayon

另请参阅: 第 2 章 —— Future Trait 了解使这一切运转的核心 trait,第 7 章 —— 执行器与运行时 了解如何选择运行时


English Original

2. Future Trait 🟡

你将学到:

  • Future trait 详解: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 了解更复杂的实现


English Original

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() 的规则:

  1. 绝不阻塞 —— 如果未就绪,立即返回 Pending。
  2. 始终重新注册 waker —— waker 在两次轮询之间可能会发生改变。
  3. 处理虚假唤醒 —— 检查实际条件,不要假定已就绪。
  4. 不要在返回 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() 允许你通过闭包创建临时 future
  • yield_now() 是计算密集型异步代码实现协作式调度的“逃生舱”

另请参阅: 第 2 章 —— Future Trait 了解 trait 定义,第 5 章 —— 揭秘状态机 了解编译器生成的代码


English Original

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接收固定的 Futurefuture: 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 章 —— 揭秘状态机 了解为什么异步状态机是自引用的


English Original

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())
}
}
🔑 答案

共有五个状态:

  1. Start —— 存储 url
  2. WaitingFetch —— 存储 url 和 fetch 返回的 future
  3. WaitingText —— 存储 response 和 text() 返回的 future
  4. WaitingParse —— 存储 body 和 parse 返回的 future
  5. Done —— 返回 Ok(parsed.len())

每个 .await 都会创建一个让出点 (yield point) = 一个新的枚举变体。? 增加了早期退出路径,但并不会增加额外的状态 —— 它只是对 Poll::Ready 值的一个 match 操作。

关键要诀 —— 揭秘状态机

  • async fn 编译为一个枚举,每个 .await 点对应一个变体
  • Future 的 体积 = 所有变体大小的最大值 —— 巨型栈变量会使其变大
  • 编译器在状态转换时会自动插入 drop 操作
  • 当 future 体积成为问题时,请使用 Box::pin() 或改为堆分配

另请参阅: 第 4 章 —— Pin 与 Unpin 了解为什么生成的枚举需要固定,第 6 章 —— 手动构建 Future 尝试自己构建这些状态机


English Original

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&lt;A, B&gt;<br/>等待两者全部完成"]
        SELECT["Select&lt;A, B&gt;<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 深度探索 了解生产级的替代方案


English Original

7. 执行器与运行时 🟡

你将学到:

  • 执行器的职责:轮询 + 高效睡眠
  • 六大主流运行时:mio, io_uring, tokio, async-std, smol, embassy
  • 选择合适运行时的决策树
  • 为什么运行时无关(runtime-agnostic)的库设计至关重要

执行器的职责

执行器有两个主要任务:

  1. 轮询 Future:当它们准备好取得进展时进行轮询。
  2. 高效睡眠:当没有 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

运行时对比表

特性tokioasync-stdsmolembassy
生态系统统治级别较小极小嵌入式
多线程支持✅ 任务窃取调度✅✅❌ (通常单核)
no_std 支持❌❌❌✅
计时器✅ 内置✅ 内置通过 async-io✅ 基于 HAL
I/O✅ 自有抽象✅ std 镜像通过 async-io✅ HAL 驱动
通道 (Channels)✅ 类型丰富✅通过 async-channel✅
学习曲线中等低低较高 (涉及硬体)
二进制体积较大中等小极小
🏋️ 实践任务:运行时对比实操 (点击展开)

挑战:使用三种不同的运行时(tokio, smol, 以及 async-std)编写同一个程序。程序要求:

  1. 获取一个 URL(通过 sleep 模拟)
  2. 读取一个文件(通过 sleep 模拟)
  3. 打印两个结果

该练习旨在证明:异步业务逻辑代码是完全相同的 —— 只有运行时的启动设置有所不同。

🔑 参考方案
// ----- 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 不适用时 了解替代方案。


English Original

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 要求 'static future —— 使用 Arc 或通道来共享数据。
  • 丢弃 JoinHandle 不会取消任务 —— 请显式调用 .abort()。
  • 根据需求选择同步原语:共享状态用 Mutex,限制并发用 Semaphore,组件间通信则在 mpsc/oneshot/broadcast/watch 中按需挑选。

另请参阅: 第 9 章 —— 当 Tokio 不适用时 寻找 spawn 的替代方案,第 12 章 —— 常见陷阱 了解跨 await 持有 MutexGuard 的潜在漏洞。


English Original

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!()
}
}

经验法则:类库应当依赖 futures crate,而非直接依赖 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() 这一并发限制器。


English Original

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&lt;dyn Future&gt;<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 的 Service trait,第 6 章 —— 手动构建 Future 了解手动实现方案。


English Original

11. 流 (Streams) 与 AsyncIterator 🟡

你将学到:

  • Stream trait:对多个值进行异步迭代
  • 创建流: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 StreamC# 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::Readtokio::io::AsyncReadfutures::io::AsyncReadAsyncReadExt
std::io::Writetokio::io::AsyncWritefutures::io::AsyncWriteAsyncWriteExt
std::io::BufReadtokio::io::AsyncBufReadfutures::io::AsyncBufReadAsyncBufReadExt
std::io::Seektokio::io::AsyncSeekfutures::io::AsyncSeekAsyncSeekExt

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 章 —— 生产模式 了解如何通过有界通道处理背压。


English Original

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%。

诊断步骤:

  1. 接入 tokio-console —— 发现 200 多个任务卡在 Pending 状态。
  2. 查看任务详情 —— 全都在等待同一个 Mutex::lock().await。
  3. 根因分析 —— 某个任务在持有一个 std::sync::MutexGuard 期间发生了 .await 并随后 panic,导致 mutex 被毒化(poisoned)。所有其他任务现在在调用 lock().unwrap() 时全部失败。

修复:

修复前 (受损)修复后 (完好)
std::sync::Mutextokio::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:

  1. 顺序获取 —— URL 是一条接一条获取的,没有并发。
  2. 使用了 std::thread::sleep —— 阻塞了执行器线程。
  3. 跨 .await 持有 MutexGuard —— 在 await expensive_parse 时 guard 依然存活。
  4. 缺乏并发设计 —— 应使用 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 章 —— 生产模式 了解优雅停机与结构化并发。


English Original

13. 生产模式 🔴

你将学到:

  • 使用 watch 通道与 select! 实现优雅停机(Graceful shutdown)
  • 背压(Backpressure):有界通道防止内存溢出(OOM)
  • 结构化并发:JoinSet 与 TaskTracker
  • 超时、重试与指数退避算法
  • 错误处理:thiserror vs anyhow,以及双重 ? 模式
  • 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类库代码、公共 APIenum 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 的 Service trait 是 Rust 异步中间件事实上的标准。

另请参阅: 第 8 章 —— Tokio 深度探索 了解通道与同步原语,第 12 章 —— 常见陷阱 了解停机过程中的取消陷阱。


English Original

14. 异步是手段而非目的 🔴

你将学到:

  • 为什么异步倾向于污染整个代码库 —— 以及为什么这其实是设计上的失策,而非特性
  • “同步核心,异步外壳”模式:让绝大部分代码保持可测试与易调试性
  • 如何处理棘手情况:那些 同时 需要进行 I/O 的逻辑
  • spawn_blocking 究竟是救急良药还是架构缺陷的征兆
  • 什么时候异步才真正属于你的核心逻辑
  • 为什么“同步优先”的库比“异步优先”的库更具组合性

你已经补完了 13 个章节来学习异步 Rust。现在,我要告诉你全书最重要的一点:你的大部分代码都不应该是异步的。

函数着色问题 (The Function Coloring Problem)

Bob Nystrom 的名篇 “你的函数是什么颜色?” 指出了核心矛盾:异步函数可以调用同步函数,但同步函数无法直接调用异步函数。一旦某个函数变成了异步,其调用链上方的所有函数都必须随之改变。

在 Rust 中,这比 C# 或 JavaScript 还要 严重,因为异步不仅会感染函数签名,还会感染类型系统:

同步代码异步等效代码差异所在
fn process(&self)async fn process(&self)调用方也必须是异步的
&mut TArc<Mutex<T>>派生任务需要满足 'static + Send
std::sync::Mutextokio::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(),
    }))
}
}

你的目标:

  1. 创建 core.rs,包含同步函数:calibrate_sensors、classify_health 和 build_report。
  2. 创建 shell.rs,包含轻量级异步处理器,负责数据获取并调用同步核心。
  3. 编写 #[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 服务器。


关键要诀:

  1. 异步是一种 I/O 复用优化,而非应用架构模式。绝大部分业务逻辑应当是同步的。
  2. 同步核心,异步外壳: 将业务规则保存在纯函数中,并将 I/O 结果作为参数传入。异步外壳负责编排数据获取并调用核心逻辑。
  3. 如果你发现自己在用 spawn_blocking 包裹大段逻辑,说明 边界划分错了 —— 请将其重构为同步模块。
  4. 库设计应首选同步 API。 异步库会强迫所有调用方绑定运行时;同步库则将选择权交由调用方。
  5. 异步在 扇出/扇入、并发流和有状态长连接 场景下实至名归 —— 在这些场景下,并发本身就是业务。

另请参阅: 第 12 章 —— 常见陷阱(spawn_blocking 的战术用法)· 第 13 章 —— 生产模式(背压、结构化并发)· 第 17 章 —— 实战项目:异步聊天服务器(异步架构的正确应用实例)


English Original

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. 生成数字 1 到 100
  2. 过滤出偶数
  3. 对每个数字求平方
  4. 并发处理:一次处理 10 个(使用 sleep 模拟耗时异步操作)
  5. 收集结果
🔑 参考方案
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
    }
}
}

English Original

总结与备忘卡 (Reference Card) 🟡

快速参考卡

异步思维模型

┌─────────────────────────────────────────────────────┐
│  async fn → 状态机 (enum) → 实现 Future Trait        │
│  .await   → 调用内部 future 的 poll() 方法           │
│  执行器    → 循环 { poll(); 睡眠直至被唤醒; }         │
│  Waker    → “嘿,执行器,再次轮询我”                 │
│  Pin      → “我承诺不会在内存中移动位置”             │
└─────────────────────────────────────────────────────┘

常见模式速查表

目标方法
并发运行两个 futuretokio::join!(a, b)
竞速运行两个 futuretokio::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) 快速参考

场景方法
在堆上固定 futureBox::pin(fut)
在栈上固定 futuretokio::pin!(fut)
固定一个 Unpin 类型Pin::new(&mut val) —— 安全且零开销
返回固定的 trait 对象-> Pin<Box<dyn Future<Output = T> + Send>>

通道 (Channel) 选择指南

通道生产者消费者传输内容适用场景
mpsc多个 (N)1流工作队列、事件总线
oneshot11单个值请求/响应模式、完成通知
broadcast多个 (N)多个 (N)所有人收到所有扇出通知、停机广播
watch1多个 (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/await2 小时带源码实测深度解析
Alice Ryhl —— 使用 Tokio 构建 Actor生产级有状态服务的架构模式
Without Boats —— Pin, Unpin, 及其设计初衷核心设计者的原始设计逻辑
Tokio mini-Redis完整的异步项目 —— 极具参考价值的代码库
Tower 官方文档axum/tonic 使用的中间件/服务模式

English Original

终极实战项目:异步聊天服务器

这个项目将书中各章节的模式整合到一个单体、生产级的应用程序中。你将构建一个多聊天室异步聊天服务器,综合运用 tokio、通道(channels)、流(streams)、优雅停机(graceful shutdown)以及完善的错误处理。

预计耗时:4–6 小时 | 难度:★★★

你将实践的内容:

  • tokio::spawn 及其 'static 约束 (第 8 章)
  • 通道:mpsc 处理消息,broadcast 处理房间,watch 处理停机 (第 8 章)
  • 流:从 TCP 连接中读取行内容 (第 11 章)
  • 常见陷阱:取消安全性、跨 .await 持有 MutexGuard (第 12 章)
  • 生产模式:优雅停机、背压控制 (第 13 章)
  • 异步 Trait 用于插件化后端 (第 10 章)

问题描述

构建一个 TCP 聊天服务器,具备以下功能:

  1. 客户端通过 TCP 连接并加入指定的命名聊天室。
  2. 消息会广播给同一房间内的所有客户端。
  3. 命令支持:/join <room>, /nick <name>, /rooms, /quit。
  4. 服务器停机:在按下 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> 命令可实现房间切换(退订旧房间,订阅新房间)。
  • 消息能广播至发送者当前所在房间的所有客户端。
💡 提示 —— 客户端任务结构

每个客户端任务需要两个并发循环:

  1. 从 TCP 读取 → 解析命令或广播至房间。
  2. 从广播接收端读取 → 写入 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 处理机制,确保服务器:

  1. 停止接收新连接。
  2. 向所有房间发送“服务器正在停机…”的消息。
  3. 等待待处理的消息排空。
  4. 干净地退出。

第 5 步:错误处理与边界情况

强化服务器的健壮性:

  1. 处理滞后接收端:如果某个客户端过慢导致丢失消息,broadcast::recv() 会返回 RecvError::Lagged(n)。请优雅地处理该错误(记录日志并继续,不要崩溃)。
  2. 昵称校验:拒绝空名或过长的昵称。
  3. 背压控制:广播通道缓冲区是有界的。如果某客户端跟不上进度,会触发 Lagged 错误。
  4. 超时机制:断开空闲超过 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/await but 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:

SymbolMeaning
🟢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

ChaptersTopicSuggested TimeCheckpoint
1–5How Async Works6–8 hoursYou can explain Future, Poll, Pin, and why Rust has no built-in runtime
6–10The Ecosystem6–8 hoursYou can build futures by hand, choose a runtime, and use tokio’s API
11–13Production Async6–8 hoursYou can write production-grade async code with streams, proper error handling, and graceful shutdown
CapstoneChat Server4–6 hoursYou’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:

  1. Try the exercise before expanding the solution — struggling is where learning happens
  2. Type the code, don’t copy-paste — muscle memory matters for Rust’s syntax
  3. Run every example — cargo new async-exercises and test as you go

Table of Contents

Part I: How Async Works

Part II: The Ecosystem

Part III: Production Async

Appendices


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# / JavaScriptPythonGoRust
CreationTask starts executing immediatelyCoroutine is lazy — returns an object, doesn’t run until awaited or scheduledGoroutine starts immediatelyFuture does nothing until polled
DroppingDetached task continues runningUnawaited coroutine is garbage-collected (with a warning)Goroutine runs until returnDropping a Future cancels it
RuntimeBuilt into the language/VMasyncio event loop (must be explicitly started)Built into the binary (M:N scheduler)You choose (tokio, smol, etc.)
SchedulingAutomatic (thread pool)Event loop + await or create_task()Automatic (GMP scheduler)Explicit (spawn, block_on)
CancellationCancellationToken (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:

CostWhy
State machine overheadEach .await adds an enum variant; deeply nested futures produce large, complex state machines
Dynamic dispatchBox<dyn Future> adds indirection and kills inlining
Context switchingCooperative scheduling still has cost — the executor must manage a task queue, wakers, and I/O registrations
Compile timeAsync code generates more complex types, slowing down compilation
DebuggabilityStack 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:

  1. A web server handling 10,000 concurrent WebSocket connections
  2. A CLI tool that compresses a single large file
  3. A service that queries 5 different databases and merges results
  4. A game engine running a physics simulation at 60 FPS
🔑 Solution
  1. Async — I/O-bound with massive concurrency. Each connection spends most time waiting for data. Threads would require 10K stacks.
  2. Sync/threads — CPU-bound, single task. Async adds overhead with no benefit. Use rayon for parallel compression.
  3. Async — Five concurrent I/O waits. tokio::join! runs all five queries simultaneously.
  4. 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 Future trait: 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 completes
  • poll() — called by the executor to check progress; returns Ready(value) or Pending
  • Pin<&mut Self> — ensures the future won’t be moved in memory (we’ll cover why in Ch. 4)
  • Context — carries the Waker so 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() returns Poll::Ready(value) or Poll::Pending
  • A future must register a Waker before returning Pending — the executor uses it to know when to re-poll
  • Pin<&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() and yield_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_uring to sleep until I/O is ready. But this shows the core idea: an executor is just a loop that calls poll().

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():

  1. Never block — return Pending immediately if not ready
  2. Always re-register the waker — it may have changed between polls
  3. Handle spurious wakes — check the actual condition, don’t assume readiness
  4. Don’t poll after Ready — behavior is unspecified (may panic, return Pending, or repeat Ready). Only FusedFuture guarantees 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 .await points, it monopolizes the executor thread. Insert yield_now().await periodically 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 closures
  • yield_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 Unpin gives 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

WhatWhenHow
Pin a future on the heapStoring in a collection, returning from functionBox::pin(future)
Pin a future on the stackLocal use in select! or manual pollingtokio::pin!(future) or pin_mut! from pin-utils
Pin in function signatureAccepting pinned futuresfuture: Pin<&mut F>
Require UnpinWhen you need to move a future after creationF: 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 machines
  • Box::pin() is the safe, easy default for pinning futures on the heap
  • tokio::pin!() pins on the stack — you can move the Pin<&mut> wrapper, but the underlying future stays put
  • Unpin is an auto-trait opt-out: types that implement Unpin can be moved even when pinned (most types are Unpin; 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 fn into an enum state machine
  • Side-by-side comparison: source code vs generated states
  • Why large stack allocations in async fn blow 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 unsafe pin projections — the get_mut() calls shown here require Unpin, 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 fn blow up the future’s size. If you see stack overflows in async code, check for large arrays or deeply nested futures. Use Box::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:

  1. Start — stores url
  2. WaitingFetch — stores url, fetch future
  3. WaitingText — stores response, text() future
  4. WaitingParse — stores body, parse future
  5. 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 fn compiles to an enum with one variant per .await point
  • 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 TimerFuture with thread-based waking
  • Building a Join combinator: run two futures concurrently
  • Building a Select combinator: 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&lt;A, B&gt;<br/>Wait for BOTH"]
        SELECT["Select&lt;A, B&gt;<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 Select always polls A first — if both are ready, A always wins. Tokio’s select! 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 registration
  • Join polls both sub-futures; Select returns 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:

  1. Poll futures when they’re ready to make progress
  2. 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]);
    });
}
Aspectepoll (tokio)io_uring (tokio-uring)
ModelReadiness notificationCompletion notification
Syscallsepoll_wait + read/writeBatched SQE/CQE ring
Buffer ownershipApp retains (&mut buf)Ownership transfer (move buf)
PlatformLinux, macOS (kqueue), Windows (IOCP)Linux 5.1+ only
Zero-copyNo (userspace copy)Yes (registered buffers)
MaturityProduction-readyExperimental

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

Featuretokioasync-stdsmolembassy
EcosystemDominantSmallMinimalEmbedded
Multi-threaded✅ Work-stealing✅✅❌ (single-core)
no_std❌❌❌✅
Timer✅ Built-in✅ Built-inVia async-io✅ HAL-based
I/O✅ Own abstractions✅ std mirror✅ Via async-io✅ HAL drivers
Channels✅ Rich set✅Via async-channel✅
Learning curveMediumLowLowHigh (HW)
Binary sizeLargeMediumSmallTiny
🏋️ Exercise: Runtime Comparison (click to expand)

Challenge: Write the same program using three different runtimes (tokio, smol, and async-std). The program should:

  1. Fetch a URL (simulate with a sleep)
  2. Read a file (simulate with a sleep)
  3. 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 'static requirement, and JoinHandle
  • 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 JoinHandle does 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 a Future directly, 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?

RequirementChannelWhy
API handlers → Batchermpsc (bounded)N producers, 1 consumer. Bounded for backpressure — if the batcher falls behind, API handlers slow down instead of OOM
Config watcher → Rate limiterwatchOnly the latest config matters. Multiple readers (each worker) see the current value
Shutdown signal → All componentsbroadcastEvery component must receive the shutdown notification independently
Single health-check responseoneshotRequest/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_thread for servers (default); current_thread for CLI tools, tests, or !Send types
  • tokio::spawn requires 'static futures — use Arc or channels to share data
  • Dropping a JoinHandle does not cancel the task — call .abort() explicitly
  • Choose sync primitives by need: Mutex for shared state, Semaphore for concurrency limits, mpsc/oneshot/broadcast/watch for 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 'static problem: when tokio::spawn forces you into Arc everywhere
  • LocalSet for !Send futures
  • FuturesUnordered for borrow-friendly concurrency (no spawn needed)
  • JoinSet for 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 hitToolAvoids 'static?Avoids Send?
Can’t make futures 'staticFuturesUnordered✅ Yes✅ Yes
Futures are 'static but !SendLocalSet❌ No✅ Yes
Need to track / abort spawned tasksJoinSet❌ 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 futures crate, not tokio. Applications should depend on tokio (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

  • FuturesUnordered runs futures concurrently on the current task — no 'static requirement
  • LocalSet enables !Send futures on a single-threaded executor
  • JoinSet (tokio 1.21+) provides managed task groups with automatic cleanup
  • For libraries: depend only on std::future::Future + futures crate, 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_variant workaround
  • Async closures (Rust 1.85+): async Fn() and async 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&lt;dyn Future&gt;<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

ApproachStatic DispatchDynamic DispatchSendSyntax Overhead
Native async fn in trait✅❌ImplicitNone
trait_variant✅✅Explicit#[trait_variant::make]
Manual Box::pin✅✅ExplicitHigh
async-trait crate✅✅#[async_trait]Medium (proc macro)

Recommendation: For new code (Rust 1.75+), use native async traits with trait_variant when you need dyn dispatch. The async-trait crate 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 to AsyncFn() -> T for 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 fn directly in traits (no #[async_trait] crate needed)
  • trait_variant::make auto-generates a Send variant for dynamic dispatch
  • Async closures (async Fn()) stabilized in 1.85 — use for callbacks and middleware
  • Prefer static dispatch (<S: Service>) over dyn for performance-critical code

See also: Ch 13 — Production Patterns for Tower’s Service trait, Ch 6 — Building Futures by Hand for manual trait implementations


11. Streams and AsyncIterator 🟡

What you’ll learn:

  • The Stream trait: 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

FeatureRust StreamC# IAsyncEnumerable<T>
Syntaxstream! { yield x; }await foreach / yield return
CancellationDrop the streamCancellationToken
BackpressureConsumer controls poll rateConsumer controls MoveNextAsync
Built-inNo (needs futures crate)Yes (since C# 8.0)
Combinators.map(), .filter(), .buffer_unordered()LINQ + System.Linq.Async
Error handlingStream<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 TraitAsync Trait (tokio)Async Trait (futures)Extension Trait
std::io::Readtokio::io::AsyncReadfutures::io::AsyncReadAsyncReadExt
std::io::Writetokio::io::AsyncWritefutures::io::AsyncWriteAsyncWriteExt
std::io::BufReadtokio::io::AsyncBufReadfutures::io::AsyncBufReadAsyncBufReadExt
std::io::Seektokio::io::AsyncSeekfutures::io::AsyncSeekAsyncSeekExt

tokio vs futures I/O traits: They’re similar but not identical — tokio’s AsyncRead uses ReadBuf (handles uninitialized memory safely), while futures::AsyncRead uses &mut [u8]. Use tokio_util::compat to convert between them.

Copy utilities: tokio::io::copy(&mut reader, &mut writer) is the async equivalent of std::io::copy — useful for proxy servers or file transfers. tokio::io::copy_bidirectional copies 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

  • Stream is the async equivalent of Iterator — yields Poll::Ready(Some(item)) or Poll::Ready(None)
  • .buffer_unordered(N) processes N stream items concurrently — the key concurrency tool for streams
  • async_stream::stream! is the easiest way to create custom streams (uses yield)
  • AsyncRead/AsyncBufRead enable 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_blocking fixes 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 .await inside
  • tokio::sync::Mutex: When you need to hold the lock across .await points (transactional semantics, TOCTOU avoidance)
  • parking_lot::Mutex: Drop-in std replacement, faster, smaller, still no .await

Rule 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 — use tokio::sync::Mutex or 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 .await doesn’t start until the first finishes. Use join! or spawn for 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:

  1. Attach tokio-console — reveals 200+ tasks stuck in Pending state
  2. Check task details — all waiting on the same Mutex::lock().await
  3. Root cause — one task held a std::sync::MutexGuard across an .await and panicked, poisoning the mutex. All other tasks now fail on lock().unwrap()

The fix:

Before (broken)After (fixed)
std::sync::Mutextokio::sync::Mutex
.lock().unwrap() across .awaitScope lock before .await
No timeout on lock acquisitiontokio::time::timeout(dur, mutex.lock())
No recovery on poisoned mutextokio::sync::Mutex doesn’t poison

Prevention checklist:

  • Use tokio::sync::Mutex if the guard crosses any .await
  • Add #[tracing::instrument] to async functions for span tracking
  • Run tokio-console in 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:

  1. Sequential fetches — URLs are fetched one at a time instead of concurrently
  2. std::thread::sleep — Blocks the executor thread
  3. MutexGuard held across .await — guard is alive when expensive_parse is awaited
  4. No concurrency — Should use join! or 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> {
    // 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

SymptomLikely CauseTool
Task hangs foreverMissing .await or deadlocked Mutextokio-console task view
Low throughputBlocking call on async threadtokio-console poll-time histogram
Future is not SendNon-Send type held across .awaitCompiler error + #[instrument] to locate
Mysterious cancellationParent select! dropped a branchtracing 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 PatternWhen to UseKey Tool
#[tokio::test]All async teststokio = { features = ["macros", "rt"] }
time::pause()Testing timeouts, retries, periodic taskstokio::time::pause()
Trait mockingTesting business logic without I/OGeneric <S: Storage>
current_thread flavorTesting !Send types or deterministic scheduling#[tokio::test(flavor = "current_thread")]
multi_thread flavorTesting race conditions#[tokio::test(flavor = "multi_thread")]

Key Takeaways — Common Pitfalls

  • Never block the executor — use spawn_blocking for CPU/sync work
  • Never hold a MutexGuard across .await — scope locks tightly or use tokio::sync::Mutex
  • Cancellation drops the future instantly — use “cancel-safe” patterns for partial operations
  • Use tokio-console and #[tracing::instrument] for debugging async code
  • Test async code with #[tokio::test] and time::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 watch channels and select!
  • Backpressure: bounded channels prevent OOM
  • Structured concurrency: JoinSet and TaskTracker
  • Timeouts, retries, and exponential backoff
  • Error handling: thiserror vs anyhow, 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) where rand_jitter is 0..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"
}
CrateUse WhenError TypeMatching
thiserrorLibrary code, public APIsenum MyError { ... }match err { MyError::Timeout => ... }
anyhowApplications, CLI tools, scriptsanyhow::Error (type-erased)err.downcast_ref::<MyError>()
Both togetherLibrary exposes thiserror, app wraps with anyhowBest of bothLibrary 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 watch channel + select! for coordinated graceful shutdown
  • Bounded channels (mpsc::channel(N)) provide backpressure — senders block when the buffer is full
  • JoinSet and TaskTracker provide structured concurrency: track, abort, and await task groups
  • Always add timeouts to network operations — tokio::time::timeout(dur, fut)
  • Tower’s Service trait 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_blocking is 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 codeAsync equivalentWhy it’s different
fn process(&self)async fn process(&self)Callers must be async too
&mut TArc<Mutex<T>>Spawned tasks need 'static + Send
std::sync::Mutextokio::sync::MutexDifferent type if held across .await
impl Trait returnimpl Future<Output = T> + SendSimpler since RPITIT (Rust 1.75, ch10), but still colored
#[test]#[tokio::test]Tests need a runtime
Stack trace: 5 framesStack trace: 25 framesHalf 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

ConcernAsync through the coreSync core + async shell
Business rules testable without runtimeNoYes
Number of unit tests needing #[tokio::test]All of themOnly integration tests
I/O failures entangled with logic errorsYes — one Result type for bothNo — sync returns logic errors, shell handles I/O errors
validate_order reusable in CLI / WASM / batchNo — pulls in tokio transitivelyYes — pure fn
Stack traces through business logicInterleaved with runtime framesClean
Can swap HTTP client for gRPC laterRequires changing core functionsShell 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:

  1. Create core.rs with sync functions: calibrate_sensors, classify_health, and build_report
  2. Create shell.rs with a thin async handler that fetches, then calls the sync core
  3. Write #[test] (not #[tokio::test]) for: a sensor out of range, health classification thresholds, and a normal report

Hints:

  • The sync core should take RawTelemetry and DeviceMetadata as 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:

  1. Async is an I/O multiplexing optimization, not an application architecture. Most business logic is sync.
  2. 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.
  3. If you’re wrapping large blocks in spawn_blocking, the boundary is in the wrong place — refactor the logic into a sync module instead.
  4. 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.
  5. 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 UnsafeCell and not std::sync::Mutex? A previous version of this exercise used Arc<Mutex<T>> with Deref/DerefMut calling .lock().unwrap(). That doesn’t compile — the returned &T borrows from a temporary MutexGuard that’s dropped immediately. UnsafeCell avoids the intermediate guard, and the semaphore-based serialization makes the unsafe sound.


Exercise 5: Stream Pipeline

Build a data processing pipeline using streams:

  1. Generate numbers 1..=100
  2. Filter to even numbers
  3. Map each to its square
  4. Process 10 at a time concurrently (simulate with sleep)
  5. 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

GoalUse
Run two futures concurrentlytokio::join!(a, b)
Race two futurestokio::select! { ... }
Spawn a background tasktokio::spawn(async { ... })
Run blocking code in asynctokio::task::spawn_blocking(\|\| { ... })
Limit concurrencySemaphore::new(N)
Collect many task resultsJoinSet
Share state across tasksArc<Mutex<T>> or channels
Graceful shutdownwatch::channel + select!
Process a stream N-at-a-time.buffer_unordered(N)
Timeout a futuretokio::time::timeout(dur, fut)
Retry with backoffCustom combinator (see Ch. 13)

Pinning Quick Reference

SituationUse
Pin a future on the heapBox::pin(fut)
Pin a future on the stacktokio::pin!(fut)
Pin an Unpin typePin::new(&mut val) — safe, free
Return a pinned trait object-> Pin<Box<dyn Future<Output = T> + Send>>

Channel Selection Guide

ChannelProducersConsumersValuesUse When
mpscN1StreamWork queues, event buses
oneshot11SingleRequest/response, completion notification
broadcastNNAll recv allFan-out notifications, shutdown signals
watch1NLatest onlyConfig updates, health status

Mutex Selection Guide

MutexUse When
std::sync::MutexLock is held briefly, never across .await
tokio::sync::MutexLock must be held across .await
parking_lot::MutexHigh contention, no .await, need performance
tokio::sync::RwLockMany 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

ErrorCauseFix
future is not SendHolding !Send type across .awaitScope the value so it’s dropped before .await, or use current_thread runtime
borrowed value does not live long enough in spawntokio::spawn requires 'staticUse Arc, clone(), or FuturesUnordered
the trait Future is not implemented for ()Missing .awaitAdd .await to the async call
cannot borrow as mutable in pollSelf-referential borrowUse Pin<&mut Self> correctly (see Ch. 4)
Program hangs silentlyForgot to call waker.wake()Ensure every Pending path registers and triggers the waker

Further Reading

ResourceWhy
Tokio TutorialOfficial hands-on guide — excellent for first projects
Async Book (official)Covers Future, Pin, Stream at the language level
Jon Gjengset — Crust of Rust: async/await2-hour deep dive into internals with live coding
Alice Ryhl — Actors with TokioProduction architecture pattern for stateful services
Without Boats — Pin, Unpin, and why Rust needs themThe original motivation from the language designer
Tokio mini-RedisComplete async Rust project — study-quality production code
Tower documentationMiddleware/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::spawn and the 'static requirement (Ch 8)
  • Channels: mpsc for messages, broadcast for rooms, watch for 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:

  1. Clients connect via TCP and join named rooms
  2. Messages are broadcast to all clients in the same room
  3. Commands: /join <room>, /nick <name>, /rooms, /quit
  4. 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:

  1. Read from TCP → parse commands or broadcast to room
  2. 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:

CommandAction
/join <room>Leave current room, join new room, announce in both
/nick <name>Change display name
/roomsList all active rooms and member counts
/quitDisconnect gracefully
Anything elseBroadcast 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:

  1. Stops accepting new connections
  2. Sends “Server shutting down…” to all rooms
  3. Waits for in-flight messages to drain
  4. 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:

  1. Lagging receivers: broadcast::recv() returns RecvError::Lagged(n) if a slow client misses messages. Handle it gracefully (log + continue, don’t crash).
  2. Nickname validation: Reject empty or too-long nicknames.
  3. Backpressure: The broadcast channel buffer is bounded (100). If a client can’t keep up, they get the Lagged error.
  4. 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

CriterionTarget
ConcurrencyMultiple clients in multiple rooms, no blocking
CorrectnessMessages only go to clients in the same room
Graceful shutdownCtrl+C drains messages and exits cleanly
Error handlingLagged receivers, disconnections, timeouts handled
Code organizationClean separation: accept loop, client task, room state
TestingAt least 2 integration tests

Extension Ideas

Once the basic chat server works, try these enhancements:

  1. Persistent history: Store last N messages per room; replay to new joiners
  2. WebSocket support: Accept both TCP and WebSocket clients using tokio-tungstenite
  3. Rate limiting: Use tokio::time::Interval to limit messages per client per second
  4. Metrics: Track connected clients, messages/sec, room count via prometheus crate
  5. TLS: Add tokio-rustls for encrypted connections