#rust - 原文:[https://without.boats/blog/why-async-rust/](https://without.boats/blog/why-async-rust/) # 1. Some background on terminology - 操作系统 threads 的开销: - 内核和用户态的上下文切换,带来 CPU cycles 的开销 - pre-allocated stack 很大,带来 per-thread 的内存开销 - 第一个设计选择:**cooperative** 和 **preemptive** scheduling - cooperative: task 必须主动让出控制权给 scheduling subsystem - preemptively:task 可以在运行时的某个时刻被动停止,task 不需要感知到 - Goroutines: - preemptively scheduled tasks - 严格意义,应该称为 virtual threads 或者 green threads,而非 corountines - 第二个设计选择:**stackful** 和 **stackless** coroutine - stackful: 拥有 program stack,coroutine yields 后保存 stack 的 state,后续可以再从相同位置恢复 - stackless:以不同的方式存储恢复需要的 state,如 contiunation 或者 state machine。当 yield 后,它所使用的 stack 会被接管它的操作所使用,恢复后收回对 stack 的控制,并使用 continuation 或者 state machine 来恢复 coroutine。 - function coloring problem:为了获取异步函数的结果,需要使用一个不同的操作(await)而不是直接调用它。 - Rust 的 async/await 语法属于 **stackless coroutine** 机制。 - 一个 async 函数被编译成一个返回 `Future` 的函数。 - 当让出控制时,future 用于存储 coroutine 的状态。 --------- # 2. The development of async Rust ## 2.1 Green threads - Rust 最初有一个 stackful coroutine 机制(green threads),在 2014 年底被移除。 - green thread system 的一个大问题就是如果处理这些线程的 program stack - 用户态线程的一个优势就是没有 OS 线程巨大且是预分配的 stack 带来的内存开销 - 因此,green thread 的 stack 应该更小,且按需增长。 ### segmented stacks - stack 问题的解决方式之一 - stack 由 small stack segments 链表组成 - 增长时追加新 segment 到链表 - 缩容时从链表中移除 segment。 - 问题: - push stack frame 的 cost 变动很大(需要分配新 segment 时开销就大,不需要则比较小) - 极端场景:一个循环中的函数调用触发分配新 segmenet,每次循环都需要分配,对性能影响很大 - 并且该行为对用户是透明的。 - Go 和 Rust 开始都使用这种方式,后来都废弃了。 ### stack copying - stack 问题的另一种解决方式 - stack 更像是一个 Vec 而非链表,空间不够时时重分配一块更大的 - 问题:重分配(reallocate)时需要进行拷贝,stack 的内存地址变动,指向 stack 中内存的**地址都会失效**,需要有其他机制来进行更新。 - Go 使用了 **stack copying** - Go的好处:指针和它指向的内存都在同一个栈中,所以更新指针只需要扫描它所在的栈。 - Rust:stack 中的指针可以执行另外一个 stack 中的内容(比如另外一个线程中的栈),跟踪指针就变成跟 GC 一样的问题,而 Rust 没有 Gc 所以不能使用该方式。 - 另外的问题:stack resizing 会导致跟其他语言集成时有困难。 - 将代码从在 green thread 上执行切换到在 OS thread 堆栈上运行,对于 FFI 来说可能代价过高。 - Go 接收了这个 FFI 开销,C# 因为这个原因[废弃](https://github.com/dotnet/runtimelab/issues/2398)了 green thread - Rust 需要运行在嵌入式系统,不能携带 virtual threading runtime > [@rogeralsing](https://github.com/rogeralsing) If I understand correctly, the main cost of increased foreign function call overhead comes from stack switching. Green thread is initialized with a small stack and grow by-demand, to reduce memory overhead of having many green threads. The called native code does not have stack growing functionality, so not switching stack could cause stack overflow. > Golang does stack switching when calling FFI which is also slow. - green thread 在 [RFC 230](https://github.com/rust-lang/rfcs/pull/230) 被移出 Rust。 ## 2.2 Iterators - Rust 转向 [external iterators](https://web.archive.org/web/20140716172928/https://mail.mozilla.org/pipermail/rust-dev/2013-June/004599.html) 的决定,以及它与 Rust ownership 和 borrowing model 结合后的高效性,最终导致 Rust 转向了 async/await。 - external iterators:由 end user 驱动迭代器(pull-based) - **Aliasing XOR Mutability** - aliasing 和 mutability 只能有一个,不能同时存在 - aliasing:相同的内存位置有两个或多个 references - mutability:有资格修改某个内存位置的 value - 即要求只能有一个 mutable reference(一个定义为 `&mut` 的变量),或者两个到多个 reference(定义为 `&` 的变量) ```rust fn main() { let a = String::from("a"); let b = &a; let c = &a; // So far we have two additional references to `a`. // This is aliasing, which is fine, as long as // those references don't have mutability. println!("a is {}", a); println!("b is {}", b); println!("c is {}", c); } ``` - 早期 mutable XOR aliased 机制没有 lifetime analysis, references 只是 argument modifiers. - 2012年 Rust 实现了第一版的 lifetime analysis,将 references 提升为 real types,并可以嵌入到 strcuts 中。 - external iterator 之前,Rust 使用了 callback 机制来定义迭代器 ```rust enum ControlFlow { Break, Continue, } trait Iterator { type Item; fn iterate(self, f: impl FnMut(Self::Item) -> ControlFlow) -> ControlFlow; } ``` - External iterators 可以完美地跟 Rust 的 ownership 和 borrowing system 结合, - 迭代器本质上被编译成了一个结构体,该结构体保存了迭代的状态,可以包含对其他数据结构(被迭代的数据结构)的引用 - 问题:实现一个迭代器难以编写,需要定义 state machine - 改进:未来可以支持类似 C# 一样使用 `yield` 来生成 generators。 - [# Implement `gen` blocks in the 2024 edition #116447](https://github.com/rust-lang/rust/pull/116447) ## 2.3 Futures ### continuation passing style - futures/promises API 被称为 "continuation passing style." - callback-based - 即向 Future 对象传递一个 contiunation 参数,当 future 完成时调用该参数作为最后一个操作。 ```rust trait Future { type Output; fn schedule(self, continuation: impl FnOnce(Self::Output)); } ``` - continuation passing style 在 Rust 中遇到的问题: - `join`:需要接受两个 futures,并行地运行它们 - 因此 join 的 continuation 需要被两个 child futures 所拥有(任意一个 future 都可能先结束) - 最终 continuation 的分配需要有引用技术,这对 Rust 来说无法接受。 ### readiness-based - 参考了 C 实现异步编程的方式,他们想要的是一个Future的定义,它可以被编译成状态机。 ```rust enum Poll<T> { Ready(T), Pending, } trait Future { type Output; fn poll(&mut self) -> Poll<Self::Output>; } ``` - future 由 external executor 进行 poll。 - 当 future 变成 pending 后,它存储一种唤醒 exectutor 的方式(当它变成 ready 后)。 - 这一转变跟迭代器的非常类似。 - 状态机 borrow state from outside - 最终构建出 single-object state machine。 - 前期需要用户自己实现每一个 future,而用户实现 一个 future 的困难: - 当 future spawned 后,需要逃离周围上下文(surrounding context) - 因此不能从该上下文 borrow state, task 必须拥有它所有的 state。 ## 2.4 Async/await - future 与 green thread 在 stack 占用上的对比: - green thread:按需增长 - future: perfectly sized stack,需要多少就严格分配了多少 - future 的 stack 的实现方式为 struct,而 struct 在 Rust 中是可以被 move 的。 - move 会导致跟 green thread 一样的问题:stack 上的指针失效 - 因此需要限制 future 是 **immovable** 的。 - async/await 的三个要点: - 需要语言支持 async/await 语法,这样用户可以使用类 coroutine 函数构建复杂的 future - Async/await 语法需要支持将那些函数编译成 **自引用**的结构体,这种用户可以在 coroutine 中使用引用。 - 这个特性需要尽快发布。 - `Pin`类型实现了限制 future 可 move。 ------------ # 3. Organizational considerations - Rust 缺乏 runtime,导致 green thread 不可行 - Rust 需要支持 embedding(嵌入到其他应用或者运作在嵌入式系统) - Rust 不能为 green thread 执行必要的内存管理。 - Rust 天然地可以将协程编译成高度可优化的状态机,同时仍保持**内存安全性** - 我们不仅在 Future 中利用这一点,也在迭代器中加以利用。 - 在实现高性能网络服务时,在没有用户态并发的语言如 C 中,人们需要手写状态机。 - 而 `Future` 可以避免手写状态机,让编译器生成状态转换。 - 并且相比 C/C++ 能收获内存安全性。 - 支持 async/await 从商业角度,也更利于 Rust 的发展,许多工业软件需要高性能的网络服务。 - 用户的抱怨:crates.io上 Rust 异步的生态,都集中在使用 async/await。 ------- # To be continued