Skip to content

async 基础

本章讲 Rust 的异步(async)编程:async fn 语法糖背后的状态机、FuturePin、外部运行时 (Tokio)以及高并发 IO 的实战写法。前置知识:〈泛型与生命周期〉一章的 trait 与生命周期、〈智能指针与闭包〉一章的闭包、 并发编程Send/Sync 与线程模型。

本章目标

  • 能说清线程模型在高并发 IO 场景下的三项成本(栈内存、上下文切换、C10K 问题),并解释 事件循环(event loop)与 epoll/IOCP 为什么能绕开它们。
  • 能理解 async fn 返回的是 impl Future<Output = T>.await 的本质是「编译期把函数切成状态机」, 并知道 Pin 存在的唯一理由是防止自引用状态机被移动。
  • 能用 Tokio 写出可运行的 TCP 回声服务器、并发请求、超时与并发限流。
  • 能正确选择 join! / try_join! / spawn / select!,并理解「取消 = drop future」与 取消安全(cancel safety)的含义。
  • 能避开 7 类常见陷阱:'static 捕获、跨 .awaitMutexGuard、阻塞 runtime、 忘记 .await、递归 async、trait 里的 async fn 不保证 Send
  • 能判断一个项目该不该用 async

为什么需要 async

线程模型的成本

并发编程 里我们用 std::thread 解决并发:一个连接一个线程, 写起来像同步代码,非常直观。问题是这种模型的成本随并发的连接数线性增长:

成本项量级(x86_64 Linux/Windows 默认值)并发 1 万连接时
线程栈内存默认保留 2 MiB(Linux 8 MiB 虚拟)≥ 20 GB 虚拟地址空间
内核调度结构每线程一份内核栈 + task_struct内核对象数量爆炸
上下文切换1~5 µs,且切换时 CPU 缓存失效调度器大部分时间在切换
同步成本锁竞争、伪共享(false sharing)吞吐随核数下降

关键观察:处理一个网络请求时,线程 99% 的时间在等 IO(等网卡、等磁盘),CPU 是空闲的。 用宝贵的 OS 线程去「等」,是这类程序最大的浪费。

🧠 原理:C10K 问题(C10K problem)指「单机同时处理 1 万个连接」,由 Dan Kegel 在 1999 年 提出。当年用「一连接一线程」根本无法完成,今天也不是不能做——只是内存和调度开销让你在 1 万连接时就把机器吃满,而 CPU 利用率仍然很低。

阻塞 vs 非阻塞 IO

  • 阻塞 IO(blocking IO)read 系统调用在数据到达前不让出 CPU,线程被内核挂起。 同步代码好写,但线程被占住。
  • 非阻塞 IO(non-blocking IO)read 立刻返回 WouldBlock,调用者需要自己去问 「什么时候可读」。

WouldBlock 不能靠「循环重试」解决——那叫忙等(busy-wait),会把一个 CPU 核烧到 100%。 正确的做法是把「谁可读了」这件事交给内核:注册一批文件描述符(file descriptor,fd), 让内核在有事件时通知你。这就是 IO 多路复用(IO multiplexing)。

💡 对照:JS 的 fetch、Python asyncioawait reader.read() 背后都是这一层。 Rust 的独特之处是:标准库不绑定任何一种 IO 多路复用实现,它把这件事留给了运行时。

一句话原理:

  • Linux:epoll 维护一张被监视的 fd 集合,epoll_wait 一次性返回所有就绪的 fd。
  • Windows:IOCP(IO Completion Port,IO 完成端口)不用「就绪通知」而用「完成通知」, 提交异步 IO 请求后内核完成时把结果投递到完成队列。
  • macOS/BSD:kqueue

🧠 原理epoll就绪通知(可读/可写了,你自己去读),IOCP完成通知 (数据已经读进你给的缓冲区了)。这个差别解释了为什么 Tokio 在 Windows 上的实现细节 和 Linux 不同,但对使用者完全透明。

事件循环(event loop)

有了 IO 多路复用,一个线程就能管理上万个连接:

text
事件循环(单线程即可)
  ┌──────────────────────────────────────────────────┐
  │ 1. 问内核:哪些 fd 就绪了?   ← epoll_wait / IOCP │
  │ 2. 对每个就绪事件,唤醒「等它的任务」             │
  │ 3. 让这些任务继续跑一小段(直到它再次要等 IO)     │
  │ 4. 回到 1                                         │
  └──────────────────────────────────────────────────┘

「任务」不是 OS 线程,而是用户态的轻量单元:任务保存自己的执行进度,遇 IO 就让出线程。 保存执行进度这件事,在 JS/C# 里由运行时维护调用栈,在 Rust 里则由编译期生成的状态机完成。

Rust async 的定位

Rust 的三条设计选择,决定了它和 JS / Go / C# 都不同:

  1. 编译期生成状态机:每个 async fn 被编译成一个匿名类型,enum 的每个变体对应一个 .await 点。没有堆分配的协程栈,没有 GC。
  2. 标准库没有运行时(runtime)std 只提供 FuturePinWaker 这些接口, 不提供调度器、不提供 epoll 封装、也没有 block_on。你不引入运行时,就没有异步 IO。
  3. 需要 executor:谁把 Future 一遍遍 poll 到完成,谁就是 executor(执行器)。 Tokio、async-std、smol 都是第三方 crate,可以按场景替换。

⚠️ 陷阱:新手最常见的困惑是「我 cargo addtokio,为什么异步 IO 还要开 feature?」 因为 Tokio 把 rt(运行时)、net(网络)、fs(文件)、time(定时器)拆成了独立 feature, 编译期按需裁剪。用 features = ["full"] 是学习期最省事的选择。


async/await 的本质

async fn 返回 impl Future

rust
// async fn 是语法糖:返回一个实现了 Future 的匿名类型
async fn add(a: u32, b: u32) -> u32 {
    a + b
}

// 上面这句大致等价于下面这个签名(返回类型由编译器生成,写不出来)
fn add_desugared(a: u32, b: u32) -> impl std::future::Future<Output = u32> {
    async move { a + b }  // async 块同样返回 impl Future
}

要点:

  • async fn f() -> T 的返回类型是 impl Future<Output = T>不是 T
  • async { ... } 是异步块,同样得到一个 impl Future
  • 两种写法都惰性(lazy):调用它们不会执行函数体里任何一行代码。

惰性:不 await 就不执行

Future 是懒惰的,这一点和 JS 的 Promise 完全不同——Promise 一旦创建就开始跑了:

rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::time::Instant;

async fn fetch(name: &str) -> String {
    // 这一行只有被 poll 到才会真的睡眠
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    format!("body of {name}")
}

#[tokio::main]
async fn main() {
    let start = Instant::now();
    // 只构造了 future,没有任何字节被处理:耗时 0
    let pending = fetch("A");
    println!("构造后耗时:{:?}", start.elapsed());

    // 手动轮询一次才会真正开始;真实代码里用 .await 或交给 executor
    let body = pending.await;
    println!("await 后耗时:{:?},得到 {body}", start.elapsed());
}

输出:构造后耗时:0ns(或极小的值),await 后耗时:100ms 左右,得到 body of A

💡 对照:JS 的 new Promise(...) 会立即执行;Rust 的 future 是「按需驱动」的。 好处是 future 可以被取消(还没跑就 drop 掉),代价是忘记 .await 时什么都不会发生, 而且只得到一条警告(见「忘记 .await:future 根本没执行」)。

Future trait 的定义

rust
// std::future 的真实定义(简化了 unsafe 部分);这是接口声明,不构成可运行程序
pub trait Future {
    type Output;

    // 关键:&mut self,因为轮询要推进内部状态
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

pub enum Poll<T> {
    Ready(T),   // 完成了,值是 T
    Pending,    // 还没好,稍后再 poll
}

Context 里装着 Waker当 future 返回 Pending 时,它必须把 cx.waker() 存起来, 并在自己可以继续时调用 wake(),否则 executor 永远不知道要再 poll 它(任务「睡死」)。

状态机展开示意

async fn 的函数体被编译器改写成 enum。以下面这段为例:

rust
async fn download_and_save(url: &str) -> std::io::Result<usize> {
    let body = fetch(url).await;        // 挂起点 1
    let saved = save(&body).await;      // 挂起点 2
    Ok(saved)
}

编译器生成的类型大致等价于(示意,不是真实展开):

text
enum DownloadFuture<'a> {
    Start                       { url: &'a str },
    WaitingFetch                { fut: FetchFuture<'a> },
    WaitingSave                 { body: String, fut: SaveFuture<'a> },
    Done,
}

每次 poll 相当于:
  Start        → 取出 fut = fetch(url),转入 WaitingFetch,返回 Pending
  WaitingFetch → poll(fut):
                   Pending → 原样返回 Pending
                   Ready(body) → body 存进自身,取出 fut = save(&body),
                                 转入 WaitingSave,返回 Pending
  WaitingSave  → poll(fut):
                   Pending → 返回 Pending
                   Ready(n) → 转入 Done,返回 Ready(Ok(n))

ASCII 版控制流:

text
       调用 download_and_save(url)          ← 什么都不执行,只构造状态机


        ┌───────────────────┐
        │ Start             │ poll ──► 启动 fetch,Pending
        └─────────┬─────────┘

        ┌─────────▼─────────┐
        │ WaitingFetch      │ poll ──► fetch 完成,body 落在状态机里
        └─────────┬─────────┘

        ┌─────────▼─────────┐
        │ WaitingSave       │ poll ──► save 完成
        └─────────┬─────────┘

        ┌─────────▼─────────┐
        │ Done(Output)      │ poll ──► Ready(Ok(n))
        └───────────────────┘

两个直接结论:

  • .await挂起点:跨 .await 存活的局部变量都会成为状态机的字段。所以 「跨 .await 持有什么」会直接影响 future 的 Send
  • 状态机的类型大小 = 所有变体字段的最大占用,这个大小在编译期就是确定的,不需要堆分配。

Pin:为什么状态机不能移动

async 生成的状态机可能是自引用的。看这个段:

rust
// 只展示状态机里会出现「自引用」这件事,不构成可运行程序
async fn demo() {
    let s = String::from("hello");
    let r: &str = &s;          // 指向 s
    other().await;             // 挂起点:s 和 r 都要存进状态机
    println!("{r}");           // 醒来后继续用 r
}

展开后,状态机里同时有「String 本体」和「指向本体内部的引用」。如果这个状态机在内存里 被移动,内部引用就会指向旧地址,形成悬垂指针(dangling pointer)。而 Rust 的默认语义是 「值可以被随意移动」——所以需要一个类型系统层面的「不许移动」标记。

这就是 Pin<P>一个指针包装,承诺「它指向的值在被 drop 之前不会再被移动」。 一句话版本:

Pin<&mut Self> 保证 *self 的位置固定;当 Self: Unpin(可以安全移动)时, Pin<&mut Self> 可以像普通 &mut Self 一样用;当 Self: !Unpin 时,你拿不到 &mut Self, 因此结构上无法移动它。

rust
// Pin 的比喻用:非 Unpin 类型无法从 Pin 里取回 &mut,也就无法被移动
use std::marker::PhantomPinned;
use std::pin::Pin;

#[derive(Debug, Default)]
struct SelfRef {
    data: String,
    ptr: *const u8,
    // PhantomPinned 让 SelfRef 变成 !Unpin,模拟「里面有指向自己的指针」
    _pin: PhantomPinned,
}

fn main() {
    let s = SelfRef { data: String::from("hi"), ptr: std::ptr::null(), _pin: PhantomPinned };
    let boxed = Box::pin(s);            // 值被固定在堆上
    let _ok: &SelfRef = &*boxed;        // 共享借用没问题
    // let moved = std::mem::take(&mut *boxed);  // 取消注释会编译失败
}

取消注释后,rustc 报的真实错误是:

text
error[E0596]: cannot borrow data in dereference of `Pin<Box<SelfRef>>` as mutable
   |
17 |     let _moved = std::mem::take(&mut *boxed);
   |                                 ^^^^^^^^^^^ cannot borrow as mutable
   |
   = help: trait `DerefMut` is required to modify through a dereference,
     but it is not implemented for `Pin<Box<SelfRef>>`

🧠 原理Pin 不是运行时检查,也不是「锁」。它纯粹是把「不许移动」编码进类型系统: Pin<P> 只有在 P::Target: Unpin 时才实现 DerefMut。所以对 !Unpin 的 future, 所有需要移动的 API 都不再可用,你只能通过 Pin 提供的少数方法访问它。

实践中你不需要手写 Pin

场景写法
普通 async 代码不用管,.await 时编译器自动 pin
main / 测试里驱动 future#[tokio::main] / block_on,内部已 pin
需要 Box 一个 futureBox::pin(fut)
需要在结构体里存 future字段类型写 Pin<Box<dyn Future<Output = T> + Send>>
自己实现 Future需要写 fn poll(self: Pin<&mut Self>, ...)

🚀 进阶:手动 impl Future 一次。理解 Waker 的用法后,你会发现所有 async 框架 都是这个模式的放大版。

rust
// 依赖:无(纯 std)。目标:手动实现一个可被 block_on 驱动的倒计时 future
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct Countdown {
    from: u32,
}

impl Future for Countdown {
    type Output = u32;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        if self.from == 0 {
            Poll::Ready(0)          // 完成:返回 Output
        } else {
            self.from -= 1;
            // 真实轮子里这里把 waker 注册到 IO 事件源;
            // 示例里没有事件源,只能立刻自唤醒,让我们下次还能被 poll
            cx.waker().wake_by_ref();
            Poll::Pending           // 未完成:必须先安排好「谁来唤醒我」
        }
    }
}

// 需要一个 executor 才能跑起来;最小 block_on 见「最小 `block_on`:自己写一个 executor」
async fn use_countdown() -> u32 {
    Countdown { from: 3 }.await
}

fn main() {
    // 这里只是证明 Countdown 是一个合法的 Future,能被传给任何 executor
    fn assert_future<F: Future<Output = u32>>(_f: F) {}
    assert_future(Countdown { from: 3 });
    assert_future(use_countdown());
    println!("ok");
}

输出:ok

如果每次手写 impl Future 都太啰嗦,标准库的 poll_fn(1.64 稳定)能把「一次轮询逻辑」 写成一个闭包:

rust
// 依赖:无。poll_fn 把「手写 poll」压缩成一个闭包
use std::future::poll_fn;
use std::task::Poll;

async fn sum_three(a: u32, b: u32, c: u32) -> u32 {
    // 闭包捕获 step:状态就放在闭包里,不用自己定义结构体
    let mut step = 0u32;
    poll_fn(move |_cx| {
        step += 1;
        if step == 1 {
            Poll::Ready(a + b + c)
        } else {
            Poll::Pending
        }
    })
    .await
}

fn main() {
    fn assert_future<F: std::future::Future<Output = u32>>(_f: F) {}
    assert_future(sum_three(1, 2, 3));
    println!("ok");
}

运行时(runtime)

为什么标准库不带 executor

Rust 的目标之一是「能在没有操作系统的环境里跑」(嵌入式、内核、WASM)。调度策略、 IO 多路复用的选择、是否有多线程,都强依赖目标平台和业务场景。把这些塞进 std 会让所有程序被迫接受一份运行时。所以 std 只给接口

std 提供(接口层)谁来提供(实现层)
FuturePollContextWaker运行时:tokio / async-std / smol / embassy
PinUnpin同上
std::task::Wake(1.51)自定义 waker 时可省掉 RawWaker 样板代码

最小 block_on:自己写一个 executor

futures crate 的 block_on 只有一行,但要理解它为什么能工作,最好亲手写一个 60 行的版本。 下面这段只依赖 stdmain 可编译可运行),它展示 executor 的三件事: 持有 waker → 循环 poll → Pending 时睡觉

rust
// 依赖:无。目标:一个只支持「自己会唤醒自己」的 future 的最小 executor
use std::future::Future;
use std::sync::{Arc, Condvar, Mutex};
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};

struct Parker {
    awake: Mutex<bool>,
    cv: Condvar,
}

impl Parker {
    fn new() -> Self {
        Parker { awake: Mutex::new(false), cv: Condvar::new() }
    }

    fn park(&self) {
        let mut awake = self.awake.lock().unwrap();
        // 被唤醒前一直睡;醒来后必须清标记,否则下一轮 park 会直接穿过
        while !*awake {
            awake = self.cv.wait(awake).unwrap();
        }
        *awake = false;
    }
}

// Waker 是「裸函数指针 + 数据指针」,这里用 Arc<Parker> 当数据
fn clone_arc(data: *const ()) -> RawWaker {
    let arc = unsafe { Arc::from_raw(data as *const Parker) };
    let cloned = Arc::clone(&arc);
    std::mem::forget(arc);              // 只借不拿,必须 forget 以免计数出错
    RawWaker::new(Arc::into_raw(cloned) as *const (), &VTABLE)
}

fn wake_arc(data: *const ()) {
    let arc = unsafe { Arc::from_raw(data as *const Parker) };
    let mut awake = arc.awake.lock().unwrap();
    *awake = true;
    arc.cv.notify_one();
}

fn drop_arc(data: *const ()) {
    unsafe { drop(Arc::from_raw(data as *const Parker)) };
}

static VTABLE: RawWakerVTable = RawWakerVTable::new(clone_arc, wake_arc, wake_arc, drop_arc);

fn block_on<F: Future>(fut: F) -> F::Output {
    let parker = Arc::new(Parker::new());
    let raw = RawWaker::new(Arc::into_raw(Arc::clone(&parker)) as *const (), &VTABLE);
    // 这一处 unsafe 是 std 的接口要求,不是我们的逻辑需要
    let waker = unsafe { Waker::from_raw(raw) };
    let mut cx = Context::from_waker(&waker);

    let mut fut = Box::pin(fut);        // future 必须被 pin 在稳定地址上
    loop {
        match fut.as_mut().poll(&mut cx) {
            Poll::Ready(v) => return v, // 完成就交出结果
            Poll::Pending => parker.park(), // 没完成就睡,醒来再 poll
        }
    }
}

async fn add(a: u32, b: u32) -> u32 {
    a + b
}

fn main() {
    // add 里没有真正的 IO,第一次 poll 就 Ready
    println!("{}", block_on(add(1, 2)));
}

输出:3

对应到 futures crate,日常只需要:

rust
// 依赖:futures = "0.3"
fn main() {
    let total = futures::executor::block_on(async {
        let a = 1 + 1;
        a * 21
    });
    println!("{total}");
}

输出:42

⚠️ 陷阱futures::executor::block_on 没有 IO 驱动。它只能驱动「自己会唤醒自己」 的 future(如 futures::channel),拿来跑 tokio::net::TcpStream 会直接挂死。 生产代码用 Tokio,不用 futures 的 executor。

Tokio 入门

Tokio 是生态事实标准:成熟、文档齐、几乎所有 Web/数据库 crate 都以它为前提。

toml
# Cargo.toml
[package]
name = "async-demo"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1", features = ["full"] }
rust
// 依赖:tokio = { version = "1", features = ["full"] }
// #[tokio::main] 把 async main 包成「建运行时 + block_on」
#[tokio::main]
async fn main() {
    println!("hello from tokio");
}

#[tokio::main] 是属性宏,展开后大致是:

rust
fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .expect("failed to build Tokio runtime")
        .block_on(async { /* 你的 async main 函数体 */ })
}

三种常用形态:

rust
// 依赖:tokio = { version = "1", features = ["full"] }
#[tokio::main]                    // 默认 multi-thread 运行时
async fn main() {}

#[tokio::main(flavor = "current_thread")]   // 单线程运行时,适合测试与轻量 CLI
async fn main_single() {}

#[tokio::test]                    // 异步测试:默认 current_thread
async fn works() {
    assert_eq!(2 + 2, 4);
}

multi-thread vs current_thread

维度multi_thread(默认)current_thread
worker 线程数默认 = CPU 核数1
并行执行任务是(真并行,需要 Send否(同一时刻只跑一个任务)
需要 Send 的 future是,tokio::spawn 要求 'static + Send否(可以 spawn !Send
适用服务器、高吞吐测试、单线程工具、需要访问线程局部数据

tokio::spawnJoinHandle

rust
// 依赖:tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
    // spawn 把任务立刻交给运行时(与 future 的惰性相反:spawn 是"提交即开始")
    let handle: tokio::task::JoinHandle<u32> = tokio::spawn(async {
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        7 * 6
    });

    // handle 自己也是一个 Future,await 它拿到 JoinResult
    let value = handle.await.expect("task panicked");
    println!("{value}");
}

输出:42

JoinHandle<T> 的规则:

  • spawn 要求 F: Future + Send + 'staticF::Output: Send + 'static
    • 'static:任务可能在任何 worker 线程上跑,也可能在函数返回后才跑完, 因此不能借用栈上数据。
    • Send:任务可能被调度到另一个线程。
  • handle.await 返回 Result<T, JoinError>:任务是 panic 了、还是被取消了,都在 JoinError 里。
  • drop 掉 JoinHandle 不会取消任务——任务照跑到结束(除非运行时关闭)。

💡 对照:Go 的 go f() 没有返回值;Tokio 的 spawn 给你一个 JoinHandle, 语义上更接近 Java 的 Future + 线程池 submit

join! / try_join! / select!

rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::time::Duration;

async fn task(name: &str, ms: u64) -> String {
    tokio::time::sleep(Duration::from_millis(ms)).await;
    format!("{name} done")
}

#[tokio::main]
async fn main() {
    // join!:并发等待,全部完成才返回;返回元组
    let (a, b) = tokio::join!(task("A", 100), task("B", 50));
    println!("{a} / {b}");

    // try_join!:任意一个返回 Err 就立刻返回 Err,其余 future 被 drop
    let both: Result<(String, String), std::io::Error> =
        tokio::try_join!(fallible(false), fallible(false));
    println!("{both:?}");

    // select!:谁先完成就用谁,其余分支的 future 被 drop
    tokio::select! {
        v = task("slow", 200) => println!("first: {v}"),
        v = task("fast", 20) => println!("first: {v}"),
    }
}

async fn fallible(fail: bool) -> Result<String, std::io::Error> {
    if fail {
        Err(std::io::Error::other("boom"))
    } else {
        Ok(String::from("ok"))
    }
}

输出:A done / B doneOk(("ok", "ok"))first: fast done

三者的关键区别:

是否并发借用局部变量需要 'static + Send错误处理
join!是(同一个任务内)可以全部完成,逐个看 Result
try_join!是(同一个任务内)可以首个 Err 立刻短路
spawn是(不同任务,可跨线程)不可以JoinHandle 返回 JoinError
select!是(竞速)可以只取第一个完成的分支

其他运行时与「不能混用」

  • async-std:API 模仿 stdasync_std::fsasync_std::net),一度很流行, 但近年维护放缓;新项目基本选 Tokio。
  • smol:极小的运行时,可拆解、可组合,适合库作者或资源受限场景。它与 async-executor 配套,smol::block_on 同样不能驱动 Tokio 的 IO 资源。

⚠️ 陷阱在 Tokio 的 worker 线程里调用另一个运行时的 block_on 会 panic。 典型错误写法与真实 panic:

rust
// 依赖:tokio = { version = "1", features = ["full"] }, futures = "0.3"
#[tokio::main]
async fn main() {
    // 错:在 Tokio 运行时里再开一个 futures executor
    futures::executor::block_on(async {});
}
text
thread 'main' panicked at ...:
Cannot start a runtime from within a runtime. This happens because a function
(like `block_on`) attempted to block the current thread while the thread is
being used to drive asynchronous tasks.

同理,Tokio 自己的 Runtime::block_on 也不能嵌套调用:

text
thread 'main' panicked at ...:
Cannot block the current thread from within a runtime. This happens because a
function attempted to block the current thread while the thread is being used
to drive asynchronous tasks.

正确做法:需要阻塞时用 tokio::task::spawn_blocking,或者把整个 block_on 挪到 main 最外层(只留一个运行时)。


延伸阅读


内容以 rustc 1.98.1 · Rust 2024 edition 为基准