Comprehensive Rust 异步运行时指南:Executor 与 Reactor 的职责、选型与实战
2026/9/10 9:09:16 网站建设 项目流程

Comprehensive Rust 异步运行时指南:Executor 与 Reactor 的职责、选型与实战

【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust

Rust 的异步编程模型与大多数语言不同:语言本身并不内置任何异步运行时,async/await只是一套语法与类型系统约定,真正让 Future 运转起来的"动力源"需要由运行时(runtime)提供。本文以 Google Android 团队维护的《Comprehensive Rust》课程中 Runtimes 一节为核心,系统讲解运行时的 reactor/executor 组成、主流运行时选型(Tokio 与 smol)、Tokio 的实际用法,以及"Future 惰性""任务取消""阻塞执行器"等高频陷阱,帮助你从原理到实战完整掌握 Rust 异步运行时。

一、什么是异步运行时:Reactor 与 Executor

在 Rust 中,一个**运行时(runtime)**承担两项核心职责:

  • Reactor(反应器):为异步执行操作提供底层支持。它通常与操作系统的事件通知机制(如 epoll、io_uring、kqueue)绑定,负责监视 I/O 事件、定时器等"信号",并在事件就绪时唤醒对应的 Future。
  • Executor(执行器):负责轮询(poll)Future 并驱动其推进。它维护一个任务队列,不断取出待执行的任务并调用其poll方法,直到任务完成或暂时让出(返回Pending)。

这两者是异步程序能够"同时等待多个操作"的基础:Reactor 负责感知"外部世界"何时就绪,Executor 负责分配 CPU 时间去推进那些已经就绪的任务。

原文档明确指出一个关键事实:Rust 没有"内置"运行时。标准库只提供Futuretrait 与async/await语法,却不提供调度器。这与 JavaScript 不同——JS 引擎自带事件循环,而 Rust 需要你显式引入或自己实现运行时。这一设计让 Rust 非常适合嵌入到其他大型系统中,代价是初学者必须理解"谁来驱动 Future"这一概念。

二、运行时选型:Tokio 与 smol,以及"自带运行时"的大型项目

原文档列出两个主要选项:

  • Tokio:高性能,拥有成熟丰富的生态,例如基于它的 Hyper(HTTP 库)与 Tonic(gRPC 库)等。
  • smol:简单、轻量,适合对运行时体积和复杂度敏感的场景。

此外,一些大型应用会自己实现运行时。原文档以 Fuchsia 操作系统为例——它在src/lib/fuchsia-async中内置了自己的异步运行时。这一点说明:运行时并非"非 Tokio 不可",当项目对调度策略、线程模型、系统接口有特殊要求时,完全可以自研或基于生态组件定制。

课程在 SUMMARY.md 中将该节安排在 "Async Basics" 一章中,位于 Futures 与 Tasks 之间,是读者在掌握async/await语法与Futuretrait 之后,理解"异步代码究竟在哪里运行"的关键一环。

三、Future 的惰性:没有 Executor,一切都不会发生

在深入运行时之前,必须先理解一个经常被忽视的性质:Future 是惰性的(inert)

原文档强调:Future 本身不会做任何事情——甚至不会启动一个 I/O 操作——除非有 executor 在轮询它。这与 JavaScript 的 Promise 形成鲜明对比:JS Promise 即使从未被使用,也会自动运行到完成;而 Rust 的 Future 只有在被poll时才会推进。

这一差异的实践含义非常直接:

  • 仅仅调用一个async fn不会执行任何代码,你得到的只是一个"待驱动的状态机";
  • 如果没有 executor(例如没有#[tokio::main],也没有block_on),Future 中的代码一行都不会运行;
  • 掉期(drop)一个 Future 意味着取消它——它永远不会再被轮询。

关于"惰性"在 playground 中的体现,原文档的讲师备注提到:所列运行时中只有 Tokio 受 Rust Playground 支持,且 Playground 不允许任何 I/O 操作,因此大多数有趣的异步示例无法在 Playground 中运行。

四、Tokio 深入:三件套能力

Tokio 一节总结了它提供的三大能力:

  1. 多线程运行时:用于执行异步代码。默认的#[tokio::main]宏会启动一个多线程 executor,任务可以并行地分布在多个工作线程上。
  2. 标准库的异步版本:如tokio::time(定时器)、tokio::io(异步读写)、tokio::net(异步网络)等,提供async化的对应 API。
  3. 庞大的库生态:围绕 Tokio 构建的 HTTP、gRPC、WebSocket、数据库驱动等第三方库,使大部分网络应用无需自己造轮子。

4.1 核心示例:spawn 与并发任务

原文档给出的示例完整展示了 Tokio 最基础的使用方式:

# // Copyright 2024 Google LLC # // SPDX-License-Identifier: Apache-2.0 # use tokio::time; async fn count_to(count: i32) { for i in 0..count { println!("Count in task: {i}!"); time::sleep(time::Duration::from_millis(5)).await; } } #[tokio::main] async fn main() { tokio::spawn(count_to(10)); for i in 0..5 { println!("Main task: {i}"); time::sleep(time::Duration::from_millis(5)).await; } }

这段代码值得逐点拆解:

  • #[tokio::main]:这是 Tokio 提供的入口宏,它把普通的fn main展开为一个初始化运行时并调用异步main的版本。正是它解决了"Rust 不允许main直接是async"的问题——这一点在 async/await 一节中有明确说明:你需要额外的机制告诉编译器如何处理返回的 Future。
  • tokio::spawn(count_to(10)):创建一个新的并发"任务(task)"。注意spawn接收的是一个Future——你不能对count_to(10)调用.await之后再传入,而是直接把整个async fn调用产生的 Future 交给运行时去调度。
  • 两段循环交替执行:主任务循环 5 次、被 spawn 的任务循环 10 次,每次time::sleep(...).await会让出 CPU。executor 借此在两个任务之间切换,产生真正的并发交错输出。

4.2 深入思考题:count_to 为什么到不了 10?

原文档留下一个经典的思考题:"为什么count_to数不到 10?"

答案指向异步取消(cancellation)的概念。当main函数执行完自己的 5 次循环并返回时,#[tokio::main]包装后的运行时认为程序结束,整个进程退出;被spawn出去但尚未完成的任务也随之被丢弃——Future 被 drop,任务被取消。这并非错误,而是 Rust 异步模型中"drop 即取消"的常规控制流行为,详细讨论见 Cancellation 一节。

原文档给出了三个探索方向:

  1. 等待任务完成tokio::spawn返回一个JoinHandle,对它.await可以一直等到任务结束。这是"join"语义——相当于把任务的生命周期纳入当前流程。
  2. 改成count_to(10).await:不 spawn,直接在main中顺序等待,此时两个循环变成完全串行,失去并发。
  3. await spawn 返回的 handle:任务执行完毕后程序才退出,count_to能完整数到 10。

通过对比这三种写法,可以直观体会到"并发任务何时被允许结束"取决于任务是否被 await 纳入主流程,这正是运行时调度的核心语义之一。

五、任务的本质:轻量线程与单层顶层 Future

在运行时之上,Rust 的任务(task)系统是一种轻量线程。原文档 Tasks 一节给出了精确定义:

  • 一个任务有一个顶层 Future,executor 轮询它来推进任务;
  • 这个顶层 Future 内部可能嵌套多个子 Future(由poll层层调用),结构上近似于调用栈;
  • 任务内部的并发,是通过同时轮询多个子 Future 实现的,例如"赛跑"一个定时器与一次 I/O 操作。

课程同时给出一个完整的 TCP echo 服务器示例(位于 tasks.md):

# // Copyright 2024 Google LLC # // SPDX-License-Identifier: Apache-2.0 # use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; #[tokio::main] async fn main() -> io::Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; println!("listening on port {}", listener.local_addr()?.port()); loop { let (mut socket, addr) = listener.accept().await?; println!("connection from {addr:?}"); tokio::spawn(async move { socket.write_all(b"Who are you?\n").await.expect("socket error"); let mut buf = vec![0; 1024]; let name_size = socket.read(&mut buf).await.expect("socket error"); let name = std::str::from_utf8(&buf[..name_size]).unwrap().trim(); let reply = format!("Thanks for dialing in, {name}!\n"); socket.write_all(reply.as_bytes()).await.expect("socket error"); }); } }

这个示例展示了运行时与任务的完整协作链条:executor 运行main这个顶层 Future → 主循环accept().await让出线程 → reactor 在有新连接时唤醒 → 每个连接通过tokio::spawn派生出独立任务,各自拥有独立的顶层 Future。这正是"一个连接一个任务"的经典服务器模型。这里也首次出现async move块——它类似于不带参数的闭包,返回值同样是一个 Future。

六、Future 与 Poll:Executor 轮询的对象到底是什么

要真正理解运行时,需要知道 executor 轮询的是什么。原文档 Futures 一节给出了标准库中Futuretrait 与Poll枚举的定义(课程注明与标准库实现完全一致):

# // Copyright 2024 Google LLC # // SPDX-License-Identifier: Apache-2.0 # use std::pin::Pin; use std::task::Context; pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } pub enum Poll<T> { Ready(T), Pending, }

要点:

  • async fn的返回值是impl Future,即一个编译器生成的状态机;
  • poll返回Ready(T)表示完成,返回Pending表示"尚未就绪,请稍后再来";
  • Context让 Future 可以在超时等事件发生时调度自己再次被轮询
  • Pin保证 Future 在内存中不被移动,使.await之后跨点引用仍然有效——这是 Pin 一节深入讨论的主题。

结合这些定义,运行时的角色就非常清晰了:executor 持有任务队列,反复调用各任务的pollPending时任务进入休眠,reactor 在事件就绪后通过Context的 waker 唤醒任务,使其重新进入就绪队列。这就是"运行时驱动异步代码"的完整闭环。

七、两个高频陷阱:阻塞执行器与取消安全

7.1 阻塞执行器(Blocking the executor)

原文档 Blocking the executor 指出:大多数异步运行时只允许 I/O 任务并发执行,CPU 密集的阻塞操作会卡死 executor,阻止其他任务推进。

课程给出了一个演示性示例:使用std::thread::sleep而非tokio::time::sleep,并配合#[tokio::main(flavor = "current_thread")]让所有任务跑在单线程上,可以直观看到 10 个 sleep Future 是串行完成而非并发完成的。需要强调的是:这个 bug 在多线程运行时同样存在,只是单线程使问题更明显。

规避手段按优先级排列:

  1. 优先使用异步等价方法(tokio::time::sleep替代std::thread::sleep.await其返回值);
  2. 必须执行真正的阻塞工作时,使用tokio::task::spawn_blocking——它把工作放到真实线程上,并返回一个可 await 的 handle,从而不阻塞 executor;
  3. 与依赖线程局部存储或绑定特定 OS 线程的库(如 CUDA)做 FFI 交互时尤其要注意,不要把任务等同于 OS 线程,executor 会把多个任务复用到同一线程上;
  4. 同步互斥锁要谨慎使用——持锁跨越.await可能让同一线程上的其他任务陷入阻塞。

7.2 取消安全(Cancellation safety)

如前所述,drop 一个 Future 就意味着它永远不会再被轮询,这被称为"取消",且可能发生在任意一个.await点。课程在 Cancellation 中用一个LinesReader示例演示了取消导致的数据丢失:当tokio::select!中定时器分支先完成时,正在读取数据的next()Future 连同其局部缓冲区一起被 drop,部分字符串因此丢失。

关键结论:

  • 编译器不会帮你检查取消安全,你需要阅读 API 文档并结合自己async fn持有的状态来判断;
  • 取消与panic?不同,它属于正常控制流(而非错误处理)的一部分;
  • 修复思路是把易失状态移入结构体(如将buf从局部变量提升为LinesReader的字段),使取消时数据不丢失;
  • 不同 API 的取消安全属性不同:Interval::tickAsyncReadExt::read是取消安全的(它们要么完成要么不读数据),而AsyncBufReadExt::read_line与示例中的手写逻辑类似,不是取消安全的。

这正是 Runtimes 中"为什么count_to数不到 10"这个问题的深层理论来源:main结束即 drop 了未完成的任务 Future,属于隐式的取消。

八、小结:把运行时放进完整知识链

综合课程脉络(见 SUMMARY.md 的 "Async Basics" 章节),Rust 异步编程的心智模型可以串成一条链:

  1. 语法层async/await是语法糖,编译器把async fn的返回类型替换为 Future(见 async/await);
  2. 类型层Future是惰性的可轮询对象,.await让当前函数暂停直至其就绪(见 Futures);
  3. 动力层运行时(runtime)由 reactor + executor 组成,负责感知事件并轮询 Future——本文主题;
  4. 调度层:任务(task)是轻量线程,tokio::spawn派生并发任务(见 Tasks);
  5. 陷阱层:阻塞 executor、取消、Pin 等(见 Pitfalls)。

对《Comprehensive Rust》课程学习者而言,掌握运行时一节意味着打通三个关键认知:为什么 async 代码必须配运行时才能跑起来(惰性)、运行时如何通过 poll 驱动 Future(reactor/executor 分工)、以及任务的生命周期由谁决定(drop 即取消)。带着这三个认知去阅读 Tokio 文档与源码,便能快速上手 Rust 异步生态。

【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询