Rust 异步:从 Future 到 Runtime

From Future to Runtime

3,227 words 25 min read
目录 17 节
  1. 调用 async fn 得到了什么
  2. Future 是一台状态机
  3. 谁在推动状态机
  4. Pending 之后谁来通知
  5. .await 做了什么
  6. Runtime 里有什么
  7. join!、select! 和 spawn
  8. join!:在当前任务里一起推进
  9. select!:等待先发生的事情
  10. spawn:创建独立任务
  11. 为什么 spawn 要求 Send + 'static
  12. Pin 在防止什么
  13. async 里混入阻塞代码
  14. 取消是丢弃 Future
  15. 任务很轻,资源并没有变多
  16. 回到一次完整请求
  17. 从 Future 到 Runtime

先从一个简单的异步函数开始:

async fn hello() {
    println!("hello");
}

fn main() {
    let future = hello();
    println!("future created");
    drop(future);
}

运行以后,只会看到:

future created

hello 明明已经被调用,函数体为什么没有执行?

这就是整个 Rust 异步模型的入口。调用 async fn 不会创建线程,也不会把任务自动交给某个后台调度器。它只返回一个 Future。只要没人继续轮询这个 Future,里面的代码就一步也不会向前走。

接下来问题会一个接一个出现:

Future 里保存了什么?
谁来轮询 Future?
它返回 Pending 以后,谁知道什么时候再试一次?
.await 为什么能在原来的位置继续?
tokio::spawn 为什么要求 Send + 'static?
Pin 又是在防止什么?

这其实是在问:一段代码暂停以后,它的状态放在哪里,又由谁负责让它继续运行。

调用 async fn 得到了什么

普通函数在调用时立刻进入函数体:

fn hello() {
    println!("hello");
}

let value = hello();

异步函数则不同:

async fn load_user(id: u64) -> Result<User, Error> {
    // ...
}

let future = load_user(42);

虽然函数签名里写的是 Result<User, Error>,调用表达式得到的并不是 Result,而是一个实现了 Future<Output = Result<User, Error>> 的匿名类型。

可以粗略地把它理解成:

fn load_user(id: u64) -> impl Future<Output = Result<User, Error>> {
    async move {
        // ...
    }
}

Future 只是对“一项将来可能完成的计算”的描述。创建它和执行它是两件事。

下面的 Tokio 程序之所以会打印 hello,不是因为 hello() 自己启动了,而是因为 main 返回的 Future 被 Tokio Runtime 驱动,执行到 .await 时又开始驱动 hello() 返回的 Future:

#[tokio::main]
async fn main() {
    hello().await;
}

Rust 选择这种惰性模型有一个直接结果:Future 可以在执行前被组合、包装或丢弃,单纯创建它不会悄悄产生网络请求和其他副作用。不过,这也意味着我们不能只凭一行 async fn 判断任务何时开始。还要继续看它在哪里被 .awaitspawn,或者交给了其他组合器。

Future 是一台状态机

看一个更接近生产代码的例子。创建订单时,服务需要读取请求体、验证用户、向计价服务询价、写入订单,最后记录一份审计日志:

async fn create_order(
    &self,
    request: Request,
) -> Result<OrderId, Error> {
    let request_id = request_id(&request);

    let raw_body = read_body(request).await?;
    let command: CreateOrder = serde_json::from_slice(&raw_body)?;

    let user = self
        .auth_client
        .verify(&command.access_token)
        .await?;

    let quote = self
        .pricing_client
        .quote(&command.items, user.member_level)
        .await?;

    let order = Order::new(
        request_id.clone(),
        user.id,
        command.items,
        quote,
    );

    self.order_repo.insert(&order).await?;
    self.audit_log.write(&request_id, &raw_body).await?;

    Ok(order.id)
}

普通同步函数把执行位置和局部变量保存在调用栈里。线程被操作系统挂起后,调用栈还在,因此恢复时知道该从哪里继续。

异步函数不能一直占着一条线程。它等待请求体、鉴权服务或计价服务时,需要把工作线程让给其他任务。可代码恢复以后还要用到的局部变量,总得有地方保存。

编译器会把异步函数转换为一个状态机。上面的代码可以大致想象成:

ReadingBody {
    self,
    request_id,
    body_future
}

Authenticating {
    self,
    request_id,
    raw_body,
    command,
    auth_future
}

Quoting {
    self,
    request_id,
    raw_body,
    command,
    user,
    quote_future
}

SavingOrder {
    self,
    request_id,
    raw_body,
    order,
    insert_future
}

WritingAuditLog {
    self,
    request_id,
    raw_body,
    order,
    audit_future
}

Done

这不是编译器实际生成的 Rust 代码,只是一个方便理解的模型。每遇到一个可能暂停的 .await,状态机就多了一个需要恢复的位置。一个变量是否进入某个状态,取决于它在这次 .await 之后还会不会被使用。

例如 user 在计价时还要提供会员等级,所以它必须跨过鉴权之后的 .await。计价完成并取出 user.id 后,后面的状态就不再需要保存整个 user

order 则要跨过两次等待:订单落库时需要借用它,审计完成以后还要读取 order.id 作为返回值。任务暂停期间,order 不能留在已经让出去的线程栈上,只能成为 Future 状态的一部分。

这里还有一个更隐蔽的问题。raw_body 只在解析命令和最后写审计日志时使用,但因为最后一行还需要它,它会跨过鉴权、计价和订单落库三个暂停点。假设一次批量下单的请求体有 2 MB,这 2 MB 数据就会在几个远程调用期间一直被任务持有。

严格来说,Vec<u8>Bytes 的内容通常位于堆上,Future 本体保存的是指针、长度等字段;但只要这些字段仍属于 Future,那块堆内存就不能释放。大量请求同时停在计价服务上时,这种生命周期差异会直接反映在进程内存里。

如果审计只需要验证请求内容,而不需要保存完整原文,可以提前计算摘要,让大块请求数据尽早离开作用域:

let (command, body_hash) = {
    let raw_body = read_body(request).await?;
    let command = serde_json::from_slice::<CreateOrder>(&raw_body)?;
    let body_hash = sha256(&raw_body);

    (command, body_hash)
}; // raw_body 在这里释放

let user = self
    .auth_client
    .verify(&command.access_token)
    .await?;

// 后续状态只需要保存 command 和一个很小的 body_hash

生成的 Future 可以看作一个枚举,它自身的大小大致由占用空间最大的状态决定,而不是把所有状态简单相加。计算一个任务的实际内存成本时,还要算上这些状态所持有的 StringVecBytes 等堆内存。

这也是为什么 .await 不只是调度标记。它还是一条变量生命周期的边界:哪些值要带到下一个阶段,编译器会把它们写进 Future,直到后续代码不再需要为止。

谁在推动状态机

Future 的核心接口不长:

pub trait Future {
    type Output;

    fn poll(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Self::Output>;
}

一次 poll 只回答当前这一刻的状态:

enum Poll<T> {
    Ready(T),
    Pending,
}

Ready(value) 表示计算已经结束,可以取走结果。Pending 表示现在还无法继续,例如:

socket 还没有收到数据
定时器还没有到期
channel 里暂时没有消息
Semaphore 还没有空闲许可

调用 poll 的一方叫作 Executor。它拿到 Ready 后收集结果,拿到 Pending 后则先去执行其他任务。

poll 有一条很重要的约定:不要在里面长时间阻塞。

如果数据尚未就绪,Future 应该尽快返回 Pending。它不能在 poll 里写一个循环,不停检查 socket 是否有数据:

// 错误的思路
while !socket.is_ready() {
    // 一直占用 Executor 的工作线程
}

否则看起来使用了 async,工作线程实际上仍然被当前任务占住了。

Pending 之后谁来通知

如果 Executor 每隔几毫秒重新检查所有 Future,会造成大量没有意义的轮询:

Task A 有结果了吗?没有
Task B 到时间了吗?没有
Task C 收到消息了吗?没有
再从 Task A 问一遍……

Rust 使用 Waker 避免这种忙等。

Future 在发现当前无法继续时,会把 Context 里的 Waker 登记到对应资源上。例如,一个网络 Future 会让 I/O Driver 知道:这个 socket 就绪以后,请唤醒当前任务。

完整过程大致如下:

Executor poll Future


       Pending


Future 登记当前任务的 Waker


I/O Driver 等待 socket / timer / event


资源就绪,调用 Waker


任务重新进入 Executor 的可运行队列


Executor 再次 poll Future

Waker 不是“继续执行”按钮。它通常只负责告诉 Executor:这个任务现在值得再 poll 一次。至于任务什么时候真正获得工作线程,仍由调度器决定。

Future 自己也没有能力监听操作系统事件。网络 socket、定时器和操作系统事件之间的联系,需要 Runtime 的 I/O Driver 维护。Linux 上常见的底层机制是 epoll,macOS 是 kqueue,Windows 则有 IOCP。应用通常不会直接接触它们,Tokio 等 Runtime 已经完成了适配。

.await 做了什么

现在可以回头看这一行:

let profile = fetch_profile(user_id).await?;

.await 会尝试推进它左边的 Future。

如果子 Future 返回 Ready(profile),当前异步函数取到结果,继续执行下一行。如果子 Future 返回 Pending,当前函数也无法继续,于是把自己的执行状态保存下来,并向外层返回 Pending

可以用一段不严格的伪代码表示:

loop {
    match poll(child_future) {
        Ready(value) => break value,
        Pending => suspend current future,
    }
}

这里的 suspend 不是让线程睡眠。当前 Future 已经把恢复所需的数据保存在自身状态里,工作线程可以马上去运行别的任务。

等子 Future 的 Waker 被触发以后,外层 Future 会再次被 poll。状态机根据之前保存的状态,跳回相应位置继续执行。

所以 .await 同时做了两件事:

向内:轮询正在等待的子 Future
向外:必要时暂停当前 Future,把 Pending 继续传出去

一层 Future 等待另一层 Future,最终形成一棵嵌套的状态机。最外层 Future 被 Runtime 驱动,唤醒信号则沿着组合关系让相应任务重新进入调度队列。

Runtime 里有什么

Rust 标准库定义了 FuturePollWaker 等基础协议,却没有附带一个完整的异步 Runtime。

这是因为“异步计算如何表达”和“应用如何调度任务、监听 I/O”是两个不同层次的问题。前者可以放在标准库里保持稳定,后者则会面对工作线程数量、调度策略、网络驱动、计时器和平台差异。

以 Tokio 为例,一个 Runtime 大致包含:

Tokio Runtime

├── Scheduler / Executor
│     管理任务队列,把可运行任务交给工作线程

├── I/O Driver
│     监听 socket 等系统资源

├── Timer Driver
│     管理 sleep、interval 和 timeout

└── Blocking Thread Pool
      隔离无法异步化的阻塞调用

#[tokio::main] 是一个方便的入口宏。它创建 Runtime,再用这个 Runtime 执行 main 返回的 Future。可以把它粗略理解为:

fn main() {
    let runtime = tokio::runtime::Runtime::new().unwrap();

    runtime.block_on(async {
        // 原来 async main 中的代码
    });
}

实际展开方式会受 Runtime 配置和宏参数影响,但职责差不多:总得先有一个同步入口创建执行环境,最外层 Future 才有人负责第一次 poll。

这也解释了为什么在普通测试函数或者同步 main 中直接写 .await 不行。.await 只能出现在异步上下文里,而整个异步调用链最外面还需要 Runtime 接住。

join!select!spawn

理解了谁在 poll Future,几个常用并发工具的差别就容易看清了。

join!:在当前任务里一起推进

回到用户首页接口。如果依次等待两个下游:

let profile = fetch_profile(user_id).await?;
let orders = fetch_orders(user_id).await?;

假设两次请求分别需要 80 ms 和 120 ms,总时间接近 200 ms。它们互不依赖时,可以同时开始:

let (profile, orders) = tokio::try_join!(
    fetch_profile(user_id),
    fetch_orders(user_id),
)?;

两个 Future 仍然属于当前任务。try_join! 在当前任务每次被 poll 时,轮流推进各个分支;只要有一个分支返回错误,就返回这个错误。

它带来的是并发等待,不是把两个分支分别放到两条线程上并行计算。分支里的同步代码仍然由当前任务所在的工作线程执行。

这种方式有一个很实用的优点:分支没有脱离当前作用域,因此可以借用当前栈上的数据。

let request_id = String::from("req-42");

let (profile, orders) = tokio::join!(
    fetch_profile(&request_id),
    fetch_orders(&request_id),
);

select!:等待先发生的事情

有时我们不想等待所有分支,只关心哪个先完成:

use tokio::time::{sleep, Duration};

tokio::select! {
    result = load_home(user_id) => {
        result
    }
    _ = sleep(Duration::from_millis(300)) => {
        Err(Error::Timeout)
    }
}

select! 同样在当前任务中轮询多个 Future。某个分支完成后,执行对应代码,其他尚未完成的分支会被丢弃。

这正是它和 join! 的区别:

join!      等待全部分支
select!    选择先完成的分支

“丢弃未完成分支”不是无关紧要的实现细节。它意味着 select! 与超时、取消安全紧密相关,后面还会回来讨论。

spawn:创建独立任务

如果一项工作需要独立于当前调用继续运行,可以把 Future 提交给 Runtime:

let handle = tokio::spawn(async move {
    refresh_cache(user_id).await
});

do_something_else().await?;

handle.await??;

tokio::spawn 会创建一个可独立调度的任务。当前任务不需要先 poll 到某个 .await,新任务就有机会开始执行。

代价是,两边的生命周期已经分开。新任务可能在当前函数返回以后仍然存在,因此不能随意借用当前栈上的数据。它还可能在 Tokio 的多线程 Runtime 中被不同工作线程轮流执行,因此会引出 Send + 'static 的要求。

JoinHandle 用来观察任务结果。这里出现两个 ?,是因为第一层处理任务是否被取消或发生 panic,第二层才是 refresh_cache 自己返回的业务错误。

需要特别注意:直接丢弃 JoinHandle 不会取消任务,而是让它在后台继续运行。需要停止任务时,应当显式调用 abort,或者建立一套协作式取消机制。

为什么 spawn 要求 Send + 'static

下面的代码无法通过编译:

async fn run() {
    let user_id = String::from("42");

    tokio::spawn(async {
        fetch_user(&user_id).await;
    });
}

新任务借用了 run 的局部变量,但它可能比 run 活得更久:

run 返回,user_id 被释放

             └── spawned task 可能还在等待网络

最常见的修改是把所有权移入任务:

async fn run() {
    let user_id = String::from("42");

    let handle = tokio::spawn(async move {
        fetch_user(&user_id).await
    });

    handle.await.unwrap();
}

'static 在这里并不表示任务必须运行到程序结束。它表示任务持有的数据不能依赖一个更短命的借用。拥有自己的 String 可以满足这个条件,即使任务只运行十毫秒。

Send 解决的是另一个问题。多线程 Runtime 可能这样调度任务:

Worker 1: poll → Pending

                    │ I/O ready

Worker 3: poll → Ready

任务在两次 .await 之间可能换一条工作线程继续被 poll。这里移动的是任务的调度权和持有它的句柄,被 Pin 的 Future 本体仍然可以保持稳定地址;但 Future 内部的数据会先后由不同线程访问,所以整个 Future 必须能够安全地在线程间转移。

Rc 就是一个常见反例:

use std::rc::Rc;

tokio::spawn(async {
    let value = Rc::new("hello");

    tokio::task::yield_now().await;
    println!("{value}");
});

value.await 之后还会使用,因此它成为 Future 状态的一部分。Rc 不是 Send,整个 Future 也就不能被提交到可能跨线程调度的 tokio::spawn

如果某个非 Send 值只在 .await 之前使用,并且确定已经离开作用域,它就不需要跟着任务跨越暂停点:

tokio::spawn(async {
    {
        let value = Rc::new("hello");
        println!("{value}");
    }

    tokio::task::yield_now().await;
});

确实需要运行非 Send Future 时,Tokio 还提供 LocalSetspawn_local,把这些任务限制在同一条线程上。它不是绕过线程安全,而是明确承诺任务不会被移动到其他线程。

Pin 在防止什么

现在再看 Future::poll 的接收者:

self: Pin<&mut Self>

为什么不是普通的 &mut self

考虑一个跨越 .await 的借用:

async fn inspect() {
    let text = String::from("hello");
    let first = &text[..1];

    tokio::task::yield_now().await;
    println!("{first}");
}

从源码看,first 借用了局部变量 text。编译成状态机以后,二者都需要保存在同一个 Future 里。可以把它想象成某个状态同时保存了数据和指向这份数据的引用。

如果 Future 在开始执行以后还能被随意移动到另一个内存位置,其中依赖原位置的内部关系就可能失效。Rust 需要一种方式向 poll 保证:对于不允许移动的 Future,从开始轮询以后,不会再通过这个引用把它挪走。

这就是 Pin 提供的约束。

Future 创建以后
    可以先移动、组合、放入容器

Future 被 Pin 以后
    对于 !Unpin 的值,不能再安全地移动其本体

Pin 不等于“这个值一定在堆上”。值可以被固定在栈上,也可以通过 Box::pin 固定在堆上。它限制的是移动行为,不是指定某种内存分配方式。

多数业务代码不需要手动实现 Future,也很少直接操作 Pin<&mut T>。编译器生成的 Future、.await 和 Tokio 宏已经处理了大部分细节。通常在以下场景里才会明显遇到它:

手动实现 Future 或 Stream
在 select! 里重复借用同一个 Future
保存不同类型的 Future
处理 !Unpin 类型

因此,理解 Pin 的目标比记住一组调用方式更重要:它在保护已经开始运行的状态机,避免状态机保存的内部关系因移动而失效。

async 里混入阻塞代码

异步任务很轻,但执行任务的工作线程并不多。假设 Runtime 有四条 Worker,每条都被一个阻塞调用占住:

Worker 1 ── blocking read ─────────────
Worker 2 ── thread::sleep ─────────────
Worker 3 ── legacy SDK call ───────────
Worker 4 ── heavy calculation ─────────

其他 async tasks:全部等待 Worker

问题不在于这四个任务耗时,而在于它们没有在等待时返回 Pending。Executor 没有机会把工作线程交给别的任务。

最直观的例子是休眠:

// 会阻塞当前工作线程
std::thread::sleep(Duration::from_secs(1));

// 注册定时器并让出当前任务
tokio::time::sleep(Duration::from_secs(1)).await;

无法改造成异步接口的同步库,可以放进专门的阻塞线程池:

let bytes = tokio::task::spawn_blocking(move || {
    legacy_client.read(path)
})
.await??;

spawn_blocking 适合文件操作、同步 SDK 和其他不可避免的阻塞调用。它不会把同步代码变成非阻塞 I/O,只是把它从 async Worker 上隔离出去。

大量 CPU 计算则更适合 Rayon 或单独控制规模的计算线程池。CPU 任务即使写进 async fn,也必须真实占用核心:

异步 I/O        等待时让出线程
阻塞 I/O        放入 blocking pool 隔离
CPU 密集计算    使用受控的计算线程池

锁也要看它跨不跨 .await。短时间修改内存数据,普通 std::sync::Mutex 完全可能是合适的:

{
    let mut state = state.lock().unwrap();
    state.requests += 1;
} // guard 在 await 前释放

send_request().await?;

把 Guard 一直留到网络请求完成则通常有问题:

let mut state = state.lock().unwrap();
state.requests += 1;

send_request().await?; // 等待期间仍然占着锁

这不只是可能让 Future 失去 Send,还会让其他任务在一段不可预测的网络等待期间都拿不到锁。tokio::sync::Mutex 允许跨 .await 等锁,但并不意味着应该把远程调用放进临界区。多数时候,更好的做法仍然是缩短锁的持有范围。

取消是丢弃 Future

Rust Future 没有一个通用的 cancel() 方法。对于还没有独立 spawn 的 Future,取消通常表现为:不再 poll,并将它丢弃。

select! 超时就是一个例子:

tokio::select! {
    result = reserve_inventory(order_id) => result,
    _ = tokio::time::sleep(Duration::from_millis(300)) => {
        Err(Error::Timeout)
    }
}

如果定时器先完成,reserve_inventory 对应的 Future 会被丢弃,后面的代码不再执行。

但“Future 不再执行”和“外部世界恢复原状”完全是两回事。假设库存服务已经完成预留,只是响应还没有传回来:

发送预留请求


库存服务已经落库

      ├── 响应仍在网络中

      └── 本地 timeout,Future 被丢弃

本地取消无法撤销对方已经提交的数据库事务。调用方甚至无法确定请求究竟没有到达,还是已经成功但响应丢失。

因此,异步取消往往还要配合:

幂等键
可查询的操作状态
补偿操作
明确的超时边界
下游可以识别的 Cancellation Token

还有一些 Future 在被取消后重新创建,可能丢失内部已经读取但尚未交付的数据。Tokio 会在 API 文档里标注某个异步操作是否 cancellation safe。使用 select! 编写循环时,这类说明尤其重要。

独立任务的取消又有所不同:

let handle = tokio::spawn(run_job());

// 丢弃 handle:任务继续运行
drop(handle);

如果要停止它,需要保留 Handle 并调用 abort,或者让任务主动监听取消信号。取消仍然主要发生在任务下一次能够让出执行权的位置;一段不返回的同步代码不会因为外部发出取消信号就立刻消失。

任务很轻,资源并没有变多

Future 通常比操作系统线程便宜得多,于是很容易写出这样的程序:

for user_id in user_ids {
    tokio::spawn(refresh_user(user_id));
}

如果 user_ids 有一百万个,程序就会迅速创建一百万个任务。它们不会各占一条线程,却仍然会持有请求参数、Future 状态、计时器、日志上下文和调度节点。

更现实的瓶颈通常在下游:

1,000,000 tasks


100 HTTP connections


20 database connections

异步让等待变得便宜,没有让连接池和数据库容量增长。

一个常见做法是用 Semaphore 限制同时访问某个下游的任务数:

use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;

async fn refresh_all(user_ids: Vec<u64>) -> Result<(), Error> {
    let limit = Arc::new(Semaphore::new(64));
    let mut tasks = JoinSet::new();

    for user_id in user_ids {
        let permit = limit.clone().acquire_owned().await.unwrap();

        tasks.spawn(async move {
            let _permit = permit;
            refresh_user(user_id).await
        });
    }

    while let Some(result) = tasks.join_next().await {
        result??;
    }

    Ok(())
}

许可跟随任务一起释放,因此最多只有 64 个刷新操作同时进行。JoinSet 则把一组动态创建的任务集中管理,避免创建后完全失去踪迹。

如果任务由生产者持续产生,有界 mpsc 往往比无界队列更重要。当消费者跟不上时,生产者在发送处等待,压力才不会无限转化成内存占用。

Semaphore       限制同时执行的操作
bounded channel 限制等待处理的任务
connection pool 限制下游连接
timeout         限制一次等待持续多久

这些限制不应该随手写成同一个数字。每一层保护的资源不同:HTTP 服务也许能并发 200 个请求,数据库连接池只有 30 条,第三方接口还可能规定每秒最多调用 50 次。

回到一次完整请求

现在把前面的概念放进一个接口。用户首页需要并发查询资料和订单,整个下游调用不能超过 300 ms,同时最多允许 64 个请求占用这组后端资源:

use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::time::{timeout, Duration};

struct HomeService {
    downstream_limit: Arc<Semaphore>,
}

impl HomeService {
    async fn load_home(&self, user_id: u64) -> Result<Home, Error> {
        let permit = self
            .downstream_limit
            .acquire()
            .await
            .map_err(|_| Error::ShuttingDown)?;

        let request = async {
            let (profile, orders) = tokio::try_join!(
                fetch_profile(user_id),
                fetch_orders(user_id),
            )?;

            Ok(Home { profile, orders })
        };

        let result = timeout(Duration::from_millis(300), request)
            .await
            .map_err(|_| Error::Timeout)?;

        drop(permit);
        result
    }
}

这段代码里没有显式操作 Waker,也没有手写 poll,但那条执行链一直存在:

load_home 被调用


返回 Future


外层任务 poll 它

      ├── Semaphore 有许可:继续

      └── 没有许可:登记 Waker,返回 Pending


try_join! 推进两个网络 Future

      ├── socket 未就绪:返回 Pending

      └── I/O Driver 收到事件后唤醒任务


两个结果都 Ready,组装 Home

timeout 也不是另一套机制。它只是同时等待业务 Future 和计时器 Future;计时器先完成时,业务 Future 被丢弃。

这里还可以继续追问工程细节:等待 Semaphore 的时间是否应该计入 300 ms?下游已经成功但本地超时时怎么办?订单接口是否支持幂等?服务关闭时,排队中的请求如何退出?

Rust async 负责让代码能够暂停和恢复,却不会替应用回答这些问题。理解执行模型的意义,正是知道 Runtime 已经保证了什么,剩下的边界又在哪里。

从 Future 到 Runtime

回到开头:

let future = hello();

它没有执行,因为 Rust 的异步函数首先是一份可以被驱动的计算。Future 保存执行状态,Executor 调用 poll,I/O Driver 监听资源,Waker 把就绪任务送回调度队列,.await 则把这一套机制嵌进了接近顺序执行的代码里。

沿着这条路径再看那些常见约束,就不再显得突然:

Send
    Future 可能在不同工作线程之间移动

'static
    独立任务不能借用已经离开的调用栈

Pin
    已开始轮询的状态机不能被随意移动

spawn_blocking
    阻塞代码不能长期占用 async Worker

cancellation safety
    丢弃 Future 不等于撤销已经发生的副作用

backpressure
    任务调度变便宜,不代表下游资源无限

Rust 异步最初让人费解,通常不是因为 .await 的语法,而是同步代码里由线程调用栈默默承担的工作,在这里被拆开摆到了面前。

任务停下来以后,Future 保存它;条件满足以后,Waker 通知它;Runtime 再次调度它。看清这三步,async 代码才真正从语法变成了执行过程。

References

  1. Rust Standard Library: async Keyword
  2. Rust Standard Library: Future
  3. The Rust Reference: Await Expressions
  4. The Rust Programming Language: Fundamentals of Asynchronous Programming
  5. Rust Standard Library: Pin
  6. Tokio Tutorial: Spawning
  7. Tokio Tutorial: select!
  8. Tokio Tutorial: Shared State
  9. Tokio Documentation: CPU-bound Tasks and Blocking Code
  10. Tokio Documentation: JoinHandle
  11. Tokio Documentation: JoinSet
  12. Tokio Documentation: Semaphore