Comprehensive Rust 教程:Rust 异步编程的四大陷阱(阻塞执行器、Pin、Async Traits 与取消安全)
【免费下载链接】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
异步编程(async/await)为并发编程提供了便捷而高效的抽象,但 Rust 的 async/await 模型也自带一系列陷阱与"脚枪"(footguns)。本指南以 Google Android 团队维护的 Rust 课程 Comprehensive Rust 中 async-pitfalls 章节 为骨架,系统讲解四大高频坑:阻塞执行器(Blocking the Executor)、Future 的内存固定(Pin)、Async Traits 的对象安全限制、以及取消安全(Cancellation Safety)。读完本文,你将理解每个陷阱的成因、可运行的复现示例、修复方案,以及如何在日常代码中避免踩坑。
背景:async 模型为什么容易踩坑
在深入陷阱之前,先回顾 Rust async 模型的两个基础事实,它们是理解下述坑点的前提。
第一,Future 是惰性的。在 Futures 章节 中可以看到,Futuretrait 的核心只有poll方法:
pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } pub enum Poll<T> { Ready(T), Pending, }Future 必须被某个执行器(executor)轮询才会推进,没有 executor 时它什么也不做(连 I/O 都不会启动)——这与 JavaScript Promise 完全不同。
第二,async 函数是状态机。正如 State Machine 章节 所示,编译器会把 async 函数变换成一个隐藏的、实现Future的类型,用枚举记录执行到哪个.await被挂起,并把所有局部变量存入该 Future 中:
enum TwoD10 { Init { modifier: u32 }, FirstRoll { modifier: u32, fut: RollD10Future }, SecondRoll { modifier: u32, first_roll: u32, fut: RollD10Future }, }这一机制意味着:局部变量(包括引用)就存放在 Future 内部,因此 Future 一旦在内存中被移动,指向其内部数据的指针就可能失效——这正是陷阱二(Pin)的根源。同时也意味着 Future 类型大小不可预知、递归 async 需要Box::pin打破无限递归类型。
Rust 没有内置运行时,常用的是 Tokio)。下面四个陷阱均以 Tokio 为运行环境展开。
陷阱一:阻塞执行器(Blocking the Executor)
核心问题:CPU 阻塞操作会独占执行器,导致其他任务无法推进。
多数异步运行时只允许I/O 任务并发。这意味着 CPU 密集的阻塞任务会阻塞执行器,阻止其他任务执行。最直观的复现代码如下(原文示例,位于 blocking-executor.md):
use futures::future::join_all; use std::time::Instant; async fn sleep_ms(start: &Instant, id: u64, duration_ms: u64) { std::thread::sleep(std::time::Duration::from_millis(duration_ms)); println!( "future {id} slept for {duration_ms}ms, finished after {}ms", start.elapsed().as_millis() ); } #[tokio::main(flavor = "current_thread")] async fn main() { let start = Instant::now(); let sleep_futures = (1..=10).map(|t| sleep_ms(&start, t, t * 10)); join_all(sleep_futures).await; }注意代码中用的是std::thread::sleep——同步阻塞函数。运行后你会发现 10 个"并发"future 实际上是串行完成的,而不是并发。
修复方案
优先使用异步等价方法:把
std::thread::sleep换成tokio::time::sleep并.await其结果。这是最简单直接的修复。使用
tokio::task::spawn_blocking:当确实需要运行 CPU 密集或同步阻塞代码(如解析、加解密、数据库驱动等)时,spawn_blocking会启动一个真实线程,并把线程句柄转换为 Future,从而不阻塞执行器。不要用同步锁跨
.await:在.await期间持有同步互斥锁(如std::sync::Mutex)可能让另一个任务阻塞——而那个任务可能恰好运行在同一个线程上,导致死锁。
深入理解:任务 ≠ OS 线程
不要将任务(task)当作操作系统线程。它们不是 1:1 映射的,执行器允许多个任务运行在单个 OS 线程上。这在与 FFI 库交互时尤其危险:如果被调用的库依赖线程局部存储(thread-local storage)或绑定到特定 OS 线程(例如 CUDA),阻塞就会造成难以排查的问题,此时应优先使用spawn_blocking。
#[tokio::main(flavor = "current_thread")]会把所有任务放在单个线程上,让阻塞效果更明显;但即便换成默认的多线程 flavor,这个 bug 依然存在,只是不那么直观。
陷阱二:Pin 与自引用 Future
核心问题:Future 内部可能存在指向自身的指针,移动它会导致悬垂引用,因此 Future 只能通过固定的指针(pinned pointer)被轮询。
回忆状态机模型:async 函数或 async 块创建的类型实现了Future,并包含所有局部变量。其中一些变量可能持有指向其他局部变量的引用(指针)。为了保证这些引用始终有效,Future 绝不能移动到另一个内存位置。
Pin就是对引用的包装,它禁止一切会把被指向实例移到新内存位置的操作。
为什么Future::poll接收Pin<&mut Self>
这正是 Futures 章节 中Futuretrait 签名里self: Pin<&mut Self>的原因。普通包含指向自身数据的类型会被借用检查器阻止移动;但 async 块的代码变换不受借用检查器验证,所以需要Pin在类型层面强制"不可移动"。
要点:
- 数据包含指向自身的指针称为自引用(self-referential)。正常情况下借用检查器会阻止自引用数据被移动(引用不能活得比指向的数据更久),但 async 变换绕过了这一检查。
Pin是引用的包装。不能通过固定指针移动对象,但仍然可以通过未固定(unpinned)的指针移动它。- 由于
poll使用Pin<&mut Self>,Future 只能通过固定指针被调用。
实战:actor 模式中的select!与超时
pin.md 提供了一个 worker/requester 的经典actor 模式示例:worker 循环从 mpsc 通道接收Work,模拟处理 10ms 后通过 oneshot 通道回传结果;requester 发送请求并等待响应。完整的#[tokio::main]可运行示例请见原文。下面聚焦它暴露的 Pin 问题。
需求是在select!循环中每 100ms 报告一次迭代次数。直接添加:
_ = sleep(Duration::from_millis(100)) => { println!(..) }永远不会执行。原因:每次循环迭代都会重新创建一个新的sleepfuture,它在 100ms 到期前就被丢弃,永远不会Ready。
正确做法是把超时 future 提到循环外:
let timeout_fut = sleep(Duration::from_millis(100)); loop { select! { .., _ = timeout_fut => { println!(..); }, } }这仍然无法编译。跟着编译器的错误提示走:先给select!里的timeout_fut加&mut绕过 move,再使用Box::pin:
let mut timeout_fut = Box::pin(sleep(Duration::from_millis(100))); loop { select! { .., _ = &mut timeout_fut => { println!(..); }, } }这能编译,但超时到期后,该 future 在每次迭代都返回Poll::Ready(熔断式 future,fused future,可以解决这个问题)。因此要在超时触发后重置它:
let mut timeout_fut = Box::pin(sleep(Duration::from_millis(100))); loop { select! { _ = &mut timeout_fut => { println!(..); timeout_fut = Box::pin(sleep(Duration::from_millis(100))); }, } }为什么必须Box::pin
Box把 future 分配在堆上,堆地址稳定,Pin<Box<F>>既固定了地址又便于在循环中重新赋值。- 对会被重新赋值的 future,
Box::pin是最合适的选择。 std::pin::pin!(近期才稳定;旧代码常用tokio::pin!)是另一种选择,但对需要重新赋值的场景难以使用。- 另一个完全不使用
Pin的思路:再 spawn 一个任务,每 100ms 通过oneshot通道发送消息。
陷阱三:Async Traits 与对象安全
核心问题:trait 中的 async 方法受限于返回位置impl Trait(RPIT)的约束,原生不支持dyn Trait对象。
Trait 中的异步方法在Rust 1.75稳定(官方公告)。这要求 trait 支持返回位置impl Trait(RPIT),因为async fn的解糖(desugaring)包含-> impl Future<Output = ...>。
即便如此,async fn仍有两大具体限制(详见 async-traits.md):
- 返回位置
impl Trait会捕获所有在作用域内的生命周期——因此某些借用模式无法表达。 - 异步 trait 不能与 trait 对象(
dyn Trait)一起使用。
关于 RPIT/RPITIT 的进一步背景,可参考 impl Trait 章节:参数位置的impl Trait是匿名泛型,返回位置则意味着"某个实现了该 trait 的具体类型"。
async_traitcrate 的规避方案
async_trait crate 通过宏提供了dyn支持的变通方案,但有特定注意事项。原文示例定义了一个Sleepertrait,并用Vec<Box<dyn Sleeper>>做动态分发:
use async_trait::async_trait; use std::time::Instant; use tokio::time::{Duration, sleep}; #[async_trait] trait Sleeper { async fn sleep(&self); } struct FixedSleeper { sleep_ms: u64, } #[async_trait] impl Sleeper for FixedSleeper { async fn sleep(&self) { sleep(Duration::from_millis(self.sleep_ms)).await; } } async fn run_all_sleepers_multiple_times( sleepers: Vec<Box<dyn Sleeper>>, n_times: usize, ) { for _ in 0..n_times { println!("Running all sleepers..."); for sleeper in &sleepers { let start = Instant::now(); sleeper.sleep().await; println!("Slept for {} ms", start.elapsed().as_millis()); } } } #[tokio::main] async fn main() { let sleepers: Vec<Box<dyn Sleeper>> = vec![ Box::new(FixedSleeper { sleep_ms: 50 }), Box::new(FixedSleeper { sleep_ms: 100 }), ]; run_all_sleepers_multiple_times(sleepers, 5).await; }注意 trait 和 impl 上都必须标注#[async_trait]。trait 对象与Box<dyn Trait>的分发细节可参见 Trait Objects 章节。
性能代价与扩展练习
async_trait使用方便,但它是通过堆分配实现动态分发的,每个调用都有性能开销。语言层面对 async trait 的支持挑战很深,Niko Matsakis 的 博客文章 有深入探讨。
练习建议:尝试创建一个随机睡眠时长的RandomSleeper,把它加入Vec中,观察动态分发下所有 sleep 是否如预期运行。
陷阱四:取消安全(Cancellation Safety)
核心问题:丢弃(drop)一个 future 意味着它永远不会再被轮询,这可能发生在任意.await点。系统必须保证在 future 被取消时依然正确——不死锁、不丢数据。
取消(cancellation)指 future 被丢弃后永远不会再被 poll。例如在tokio::select!中,当一个分支先完成时,另一个分支的 future 会被 drop——如果它中途已经读取了部分数据且状态只存在局部变量中,这些数据就丢失了。
原文(cancellation.md)给出了一个数据丢失的完整示例:一个LinesReader逐字节从DuplexStream读取行,slow_copy以每字节 10ms 的速度写入"hi\nthere\n",主循环用select!同时轮询定时器和lines.next():
async fn next(&mut self) -> io::Result<Option<String>> { let mut bytes = Vec::new(); let mut buf = [0]; while self.stream.read(&mut buf[..]).await? != 0 { bytes.push(buf[0]); if buf[0] == b'\n' { break; } } if bytes.is_empty() { return Ok(None); } let s = String::from_utf8(bytes) .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "not UTF-8"))?; Ok(Some(s)) }问题所在:每当interval.tick()分支先完成时,next()及其内部的bytes、buf就被整个 drop——已读到的部分字符串随之丢失。
修复:把状态移入结构体
让LinesReader变为取消安全(cancellation-safe)的方法是把缓冲状态从局部变量移入结构体,使部分读取的数据不随 future 丢弃而丢失:
struct LinesReader { stream: DuplexStream, bytes: Vec<u8>, buf: [u8; 1], } impl LinesReader { fn new(stream: DuplexStream) -> Self { Self { stream, bytes: Vec::new(), buf: [0] } } async fn next(&mut self) -> io::Result<Option<String>> { // 把 buf 和 bytes 前缀改为 self. 访问 // ... let raw = std::mem::take(&mut self.bytes); let s = String::from_utf8(raw) .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "not UTF-8"))?; // ... } }编译器不会帮你
- 编译器对取消安全没有任何帮助。你需要阅读 API 文档,并思考你的
async fn持有哪些状态。 - 与
panic和?不同,取消属于正常控制流的一部分(而非错误处理),因此更容易被忽视。
Tokio 标准 API 的取消安全参考
原文给出了 Tokio 中常见 API 的取消安全对照,可直接作为开发时的检查清单:
| API | 是否取消安全 | 原因 |
|---|---|---|
Interval::tick | ✅ 安全 | 会记录 tick 是否已"交付"(delivered) |
AsyncReadExt::read | ✅ 安全 | 要么返回数据,要么完全不读 |
AsyncBufReadExt::read_line | ❌ 不安全 | 与上面示例类似,可能读取部分数据后被打断;请查阅其文档了解替代方案 |
小结:四条规避纪律
把四个陷阱浓缩为可执行的编码纪律:
- 阻塞纪律:执行器中杜绝同步阻塞与同步锁跨
.await;CPU 密集/同步代码走tokio::task::spawn_blocking,能用tokio::time::sleep就不用std::thread::sleep。 - 固定纪律:跨
select!循环复用 future 时用Box::pin(或pin!),触发后记得重置;理解Pin只禁止通过固定指针移动,不禁止通过普通指针移动。 - 对象安全纪律:trait 内
async fn不能用dyn Trait,需要动态分发时用async_trait宏并接受其堆分配开销;Rust 1.75 起原生支持 trait 内 async 方法,但 RPIT 生命周期捕获等限制依然存在。 - 取消纪律:用
select!/超时/竞速时,把"读到一半"的累积状态放入结构体而不是 future 局部变量;在依赖第三方异步 API 前先查文档确认其取消安全特性。
以上全部示例与讲解均来自 async-pitfalls 章节 及其子页面 blocking-executor.md、pin.md、async-traits.md、cancellation.md,代码均为可编辑、可复现的完整示例(原文标记为compile_fail是为了配合课堂逐步修改的教学流程,你可以在本地 Cargo 项目中运行验证)。若想补齐 async 基础,可顺序阅读 Async 总览 下的 Futures、State Machine、Tasks 与 Runtimes 各节。
【免费下载链接】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),仅供参考