
15 异步
首先异步的概念是通用的 异步编程
并发模型
- OS 线程, 它最简单,也无需改变任何编程模型(业务/代码逻辑),因此非常适合作为语言的原生并发模型,我们在多线程章节也提到过,Rust 就选择了原生支持线程级的并发编程。但是,这种模型也有缺点,例如线程间的同步将变得更加困难,线程间的上下文切换损耗较大。使用线程池在一定程度上可以提升性能,但是对于 IO 密集的场景来说,线程池还是不够。
- 事件驱动(Event driven), 这个名词你可能比较陌生,如果说事件驱动常常跟回调( Callback )一起使用,相信大家就恍然大悟了。这种模型性能相当的好,但最大的问题就是存在回调地狱的风险:非线性的控制流和结果处理导致了数据流向和错误传播变得难以掌控,还会导致代码可维护性和可读性的大幅降低,大名鼎鼎的
JavaScript曾经就存在回调地狱。 - 协程(Coroutines) 可能是目前最火的并发模型,
Go语言的协程设计就非常优秀,这也是Go语言能够迅速火遍全球的杀手锏之一。协程跟线程类似,无需改变编程模型,同时,它也跟async类似,可以支持大量的任务并发运行。但协程抽象层次过高,导致用户无法接触到底层的细节,这对于系统编程语言和自定义异步运行时是难以接受的 - actor 模型是 erlang 的杀手锏之一,它将所有并发计算分割成一个一个单元,这些单元被称为
actor,单元之间通过消息传递的方式进行通信和数据传递,跟分布式系统的设计理念非常相像。由于actor模型跟现实很贴近,因此它相对来说更容易实现,但是一旦遇到流控制、失败重试等场景时,就会变得不太好用 - async/await, 该模型性能高,还能支持底层编程,同时又像线程和协程那样无需过多的改变编程模型,但有得必有失,
async模型的问题就是内部实现机制过于复杂,对于用户来说,理解和使用起来也没有线程和协程简单,好在前者的复杂性开发者们已经帮我们封装好,而理解和使用起来不够简单,正是本章试图解决的问题。
基础元素
Future
Future就像外包,我们可以poll启动外包,也可以poll查看外包的进度,也能在完成的时候获取到外包的结果。
注意:直接调用 poll 的场景很少,但如果你真的需要这么做,请记住:对于大多数
future 来说,一旦它已经返回过 Ready ,调用方就不应再对它调用 poll 。很多
future 在 ready 之后再次被轮询时会 panic。那些可以安全重复轮询的 future,会在
文档里明确说明。这和 Iterator::next 的行为有些相似。
而在Rust中Future是一个trait,提供了方法:
- poll 调用此方法,查看进度,如果是第一次调用会启动异步任务。(也就是说不调用就不启动)
async 异步关键字
当 Rust 遇到一个 async 关键字标记的代码块时,会将其编译为一个实现了 Future trait 的唯一的、匿名的数据类型。
当 Rust 遇到一个被标记为 async 的函数时,会将其编译成一个函数体是异步代码块的非异步函数。异步函数的返回值类型是编译器为异步代码块所创建的匿名数据类型。
这个结构体要满足 1 异步执行功能 2 future结果存储功能
用于恢复以及查询当前的进度。
用于存储要执行的任务、以及查询进度。
impl Future for FooFuture {
type Output = u32;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) ->
Poll<Self::Output> {
match self.state {
State::Start => {
// 调用 bar(),得到一个新的 future
let bar_fut = bar();
// 保存这个 future 到结构体中(通常通过嵌套 future 的方式)
// 设置状态为 WaitingForBar,然后尝试 poll bar_fut
// 如果 bar_fut 返回 Pending,则自己也返回 Pending
// 如果返回 Ready(v),则保存到 self.x,状态切换到下一步
}
State::WaitingForBar => {
// 再次 poll bar_fut,如果还是 Pending 则返回 Pending,
// 如果 Ready(v) 则保存到 self.x 并进入下一状态
}
// 更多状态...
}
}
}
await关键字
可以在异步代码中使用await关键字,让异步任务阻塞,直到await的任务完成。就像并发编程中的thread的join一样。
每个await在future中对应一个state,可以从这里备份、然后恢复异步任务。
Rust 的 await 关键字放在要等待的表达式后面,而不是前面。也就是说,它是一个postfix keyword(后缀关键字)。如果你在其他语言里用过 async,这一点可能和你的习惯不同;但在 Rust 中,这种设计会让链式方法调用更易读。
await 做两件事:
1 驱动:对 Future 调用 poll,推动它前进。
2 挂起:如果 Future 返回 Pending,当前任务让出执行权,把控制权交回运行时,让运行时去执行其他任务。当 Future 注册的 waker 被触发时,运行时再回来 poll 它。
.
所以 await 不是“等待”那么简单,它同时也是驱动执行的机制。对于没有被 spawn 的 Future,await 是它唯一的执行途径。
.
await 的本质是:让当前Future的执行让给await的Future执行。反复 poll 这个 Future,直到它返回 Ready。在 poll 之前,Future 内部的状态机处于“未启动”状态。
异步运行时
futures crate 是 Rust 异步代码实验的官方阵地, Future trait 最初就是在那里设计出来的。
大多数支持 async 的语言都会自带运行时,但 Rust 不会。相反,Rust 有很多不同的异步运行时可供选择,每一种都针对自己的目标用例做了不同权衡。
比如,一个拥有许多 CPU 核心和大量 RAM 的高吞吐 Web 服务器,和一个单核、RAM 很小、甚至不能进行堆分配的微控制器,需求就截然不同。
提供这些运行时的 crate 往往也会一并提供文件或网络 I/O 等常见功能的异步版本。
Tokio 则是目前 Rust 中使用最广泛的异步运行时(async runtime),尤其常见于 Web 应用。
执行器(Executor) 是广义的:它负责驱动 Future 前进。
通常包含:
-
任务队列:存放等待执行的 future(包装成 Task)。
-
调度器:决定何时运行哪个任务。
-
反应器(Reactor):监听 I/O 事件,当事件发生时唤醒对应的任务
(这通常与执行器紧密集成,比如 Tokio 的运行时)
异步常用方法
并发变成中的 spawn 函数会生成线程,但是当主线程结束时会强制停止执行。
而这里的 spawn_task 同样有这样的性质,主异步任务结束时,spawn的task会停止执行。
await关键字,让异步任务阻塞等待某异步任务执行完毕。
在线程模型下,由操作系统决定先检查哪个线程、让它运行多久。对于 asyncRust,则由运行时决定先检查哪个任务。
join! 宏可以在 future数量已知于编译期的情况下,等待任意数量的 future。join! 的“公平”是指“轮询公平”,而不是“完成公平”。简单的嵌套调用不会破坏这种轮询公平性,但会改变任务的捆绑逻辑**。
(也就是会将future中所有子future轮询而不是分级)
select 的实现并不是公平的:它总是按参数传入的顺序进行轮询(其他一些 select 实
现会随机选择先轮询哪个参数)。
消息传递
使用一个可变的而不是不可变的 rx ,并且它的 recv 方法产生一个需要 await 的 future 而不是直接阻塞线程。
- 为什么要返回future?
原本的多线程版本,recv是阻塞的,需要等待 信道 上游发送数据。而这里同样需要阻塞等待数据。那么异步任务如何阻塞呢?对,需要使用await关键字,所以recv返回一个future来让我们通过await阻塞等待。
- 为什么要mut rx?
Tokio 官方认为,使用 &mut self 是比标准库&self + !Sync 组合更合理的设计,能更清晰地表达接收者需要独占访问其内部队列的事实。
强制独占:&mut self 在编译期强制确保了在同一时刻,只有一个任务可以调用 recv,消除了数据竞争的可能。
代码块从 async 改为 async move
高响应性保证
Rust 异步模型最核心的设计原则:协作式调度(Cooperative Scheduling),而不是抢占式调度(Preemptive Scheduling)。:任务必须主动“让出”CPU,运行时(Runtime)才能去执行别的任务。await 就是那个“让出点”(Yield Point)。
被霸占的那个线程上的所有其他排队的任务都会被阻塞。
- 方案一:主动让出(Yield)。在计算循环中,每隔几百次迭代调用一次
tokio::task::yield_now().await;。这会让当前任务暂时回到队列尾部,给其他任务执行的机会。 - 方案二(推荐):扔到阻塞线程池。
使用tokio::task::spawn_blocking(|| { /* 大量计算 */ }).await;。这会将计算任务移出异步线程池,交给专门的阻塞线程池(由 OS 管理的普通线程)去执行,从而保证异步运行时线程的“高响应性”。
使用的是 std::thread::sleep ,而不是 trpl::sleep ,因此调用 thread::sleep 会让当前线程阻塞若干毫秒。
流Stream
并非数据量未知,而是只能挨个处理,那么这种就叫流。我们二数组也可以转成流。
有非常多的流,网络流,文件流,字节流等等
迭代器与流非常相似,都是只能挨个获取,还记得迭代器的next方法吗?
迭代器可以直接转换成流。stream_for_iter(iter_object)
迭代器next如果没有会直接None或者阻塞线程(如果这么实现),Stream的next没有元素会await交还控制权给运行时。
通常我们直接导入StreamExt即可包含所有。
Pin与Unpin trait
要解决的就是在future异步任务中,await阻塞后,将任务内的数据从内存中移出,并且有指针需要等到await后再使用。等到await后指针中的地址时候能指向之前所指向的数据的新地址?两种思路,一个更新指针。一个保持数据到原地址。看看rust是怎么解决的。
这种类型默认自动实现了Unpin可移动 trait。
典型就是自引用类型,也就是内部字段指针引用了内部的某自段,所以当自身move会导致之前的地址数据过时。
它包裹一个指针,并且能确保该指针指向的数据不会被移动,例如 Pin<&mut T> , Pin<&T> , Pin<Box<T>> ,都能确保 T 不会被移动。
只有!Unpin配合Pin结构体才能保证T不被移动。
要解决的问题就是原始指针、直接地址需要还原到内存的问题。对于有所有权的指针,指针中的地址可以在还原到内存的时候自动更新。所以可以自由move。
**匿名 Future 是不是!Unpin,完全取决于它内部捕获了什么数据,而不是固定的。**如果你在 async 块中,跨过一个 .await 点持有了一个借用(Reference) 此时future挂起,future需要保证在下次恢复的时候引用有效,所以就需要!Unpin+Pin
Box::pin
Box::pin:在堆(Heap)上分配内存,然后将指针指向的数据固定
pin!:在栈(Stack)或当前作用域(如 async 块的状态机) 中固定值。因为不涉及堆分配,性能更好。被固定的值必须在当前作用域内有效,不能将其返回或转义出去。如果需要返回一个被固定的数据,仍然要使用 Box::pin
还有一个方法是直接用Pin::new函数
[!warning] 普通的 Box<T> 只在 T 实现了 Unpin 时,才自动实现 Future
因为Box是智能指针,本身是需要满足unpin可自由移动的,如果指向的是!unpin不能自由移动的。实现了Unpin说明有所有权可以随便移动。
注意,匿名future trait的实际类型是不同的所以需要一个统一的容器,可以是Pin,或者指针。但是为了防止有那种自引用的future我们最好使用Pin!来生成pin的容器包裹future然后作为vec的元素,传递给join_all。
