尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Comprehensive Rust 教程:Rust 异步编程的四大陷阱(阻塞执行器、Pin、Async Traits 与取消安全)

Comprehensive Rust 教程:Rust 异步编程的四大陷阱(阻塞执行器、Pin、Async Traits 与取消安全) 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: Pinmut Self, cx: mut Context_) - PollSelf::Output; } pub enum PollT { 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.mduse 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接收Pinmut Self这正是 Futures 章节 中Futuretrait 签名里self: Pinmut Self的原因。普通包含指向自身数据的类型会被借用检查器阻止移动但 async 块的代码变换不受借用检查器验证所以需要Pin在类型层面强制不可移动。要点数据包含指向自身的指针称为自引用self-referential。正常情况下借用检查器会阻止自引用数据被移动引用不能活得比指向的数据更久但 async 变换绕过了这一检查。Pin是引用的包装。不能通过固定指针移动对象但仍然可以通过未固定unpinned的指针移动它。由于poll使用Pinmut SelfFuture 只能通过固定指针被调用。实战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::pinlet mut timeout_fut Box::pin(sleep(Duration::from_millis(100))); loop { select! { .., _ mut timeout_fut { println!(..); }, } }这能编译但超时到期后该 future 在每次迭代都返回Poll::Ready熔断式 futurefused 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::pinBox把 future 分配在堆上堆地址稳定PinBoxF既固定了地址又便于在循环中重新赋值。对会被重新赋值的 futureBox::pin是最合适的选择。std::pin::pin!近期才稳定旧代码常用tokio::pin!是另一种选择但对需要重新赋值的场景难以使用。另一个完全不使用Pin的思路再 spawn 一个任务每 100ms 通过oneshot通道发送消息。陷阱三Async Traits 与对象安全核心问题trait 中的 async 方法受限于返回位置impl TraitRPIT的约束原生不支持dyn Trait对象。Trait 中的异步方法在Rust 1.75稳定官方公告。这要求 trait 支持返回位置impl TraitRPIT因为async fn的解糖desugaring包含- impl FutureOutput ...。即便如此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并用VecBoxdyn 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: VecBoxdyn 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: VecBoxdyn 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 对象与Boxdyn 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::ResultOptionString { 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: Vecu8, buf: [u8; 1], } impl LinesReader { fn new(stream: DuplexStream) - Self { Self { stream, bytes: Vec::new(), buf: [0] } } async fn next(mut self) - io::ResultOptionString { // 把 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 是否已交付deliveredAsyncReadExt::read✅ 安全要么返回数据要么完全不读AsyncBufReadExt::read_line❌ 不安全与上面示例类似可能读取部分数据后被打断请查阅其文档了解替代方案小结四条规避纪律把四个陷阱浓缩为可执行的编码纪律阻塞纪律执行器中杜绝同步阻塞与同步锁跨.awaitCPU 密集/同步代码走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),仅供参考
返回列表