内容提要
异步Rust需要运行时,这是并发编程的固有代价,调度器必须存在。Rust将运行时作为crate而非内置,以保持嵌入式和FFI能力,但导致Tokio默认化、Send约束扩散和生态分裂。作者主张调度器应像Zig那样作为参数显式传递,从而可追踪阻塞、避免函数着色并实现运行时无关。
延伸解读
调度器必须存在:并发编程的固有代价
文章指出,任何并发模型都需要调度器,区别只在于调度器位于何处。使用线程时,内核充当调度器;使用异步时,运行时库承担这一角色。因此,抱怨异步 Rust 需要运行时,本质上是抱怨并发本身的复杂性。Rust 作为底层语言,选择将这种复杂性暴露给用户,而不是像 Go 那样用便利性换取性能。理解这一点,有助于区分哪些问题是异步 Rust 特有的,哪些是所有并发编程共有的。
运行时作为 crate 的权衡:Tokio 默认化与生态分裂
Rust 将运行时作为 crate 而非内置,是为了保持嵌入式和 FFI 能力,避免强制捆绑调度器。但这一决定导致 Tokio 成为事实标准,Send 约束扩散到泛型代码,以及异步与非异步库的生态分裂。文章认为,这些是调度器住在库代码中的下游后果,而非语言设计缺陷。读者应意识到,许多所谓“异步 Rust 问题”其实源于运行时选择,而非异步本身。
Zig 的显式传递方案:可追踪阻塞与运行时无关
文章推崇 Zig 的做法:将 I/O 接口作为参数显式传递给需要它的函数。这样,异步性和 I/O 成为所传实现的具体属性。好处有三:能精确追踪阻塞操作发生的位置;避免函数着色(着色被编码为参数而非语言特性);库代码对执行器保持无关,消除生态分裂。作者认为,将调度器放在参数中传递是理想位置,但 Zig 尚未稳定,该方案未经大规模验证。
Tokio 的隐式环境执行器:隐藏的全局状态与风险
文章批评 Tokio 的隐式环境执行器模型:spawn 的任务会神奇地附加到线程本地上下文,若不存在则运行时 panic。这相当于一个秘密参数在函数间传递,却无法在编译期追踪,破坏了 Rust 的显式原则。例如在 rayon 线程中调用 tokio::spawn 会导致进程中止。作者主张,若需全局执行器,应显式从全局变量获取,使调用点可被 grep 追踪,而非隐藏的动态作用域。
Q&A
为什么异步 Rust 需要一个运行时?
因为并发编程本身就需要调度器来管理任务的执行,而 Rust 没有内置运行时,所以必须通过外部 crate 提供。这是并发编程的固有代价,无法避免。
Rust 为什么不像 Go 那样内置运行时或绿色线程?
Rust 的设计目标包括嵌入式支持和与 C/C++ 的 FFI 互操作,内置运行时会增加嵌入难度并在 FFI 边界引入开销。因此 Rust 选择将运行时作为 crate 提供,以保持语言轻量和可嵌入性。
Tokio 默认化会带来哪些问题?
Tokio 默认化导致人们误以为异步 Rust 等同于 Tokio,忽略了其他运行时的存在。它还造成生态分裂,因为库代码往往假设使用 Tokio,难以做到运行时无关。此外,Tokio 的隐式环境执行器模型会引入隐藏的全局状态和运行时恐慌。
为什么异步 Rust 中 Send 约束会扩散?
因为语言本身无法知道运行时是否会在不同线程间移动任务。像 Tokio 这样的工作窃取运行时要求 Future 是 Send 的,以便在线程间转移。这种约束会通过泛型代码传播,导致开发者需要在很多地方添加 Send + 'static 标注。
Zig 的异步方案是如何处理调度器的?
Zig 将 I/O 接口作为参数显式传递给需要它的函数。异步性和 I/O 成为所传递实现的具体属性。这样做可以追踪阻塞操作、避免函数着色,并使库代码与执行器无关,从而避免生态分裂。
作者认为调度器应该放在哪里?
作者认为调度器应该像 Zig 那样作为参数显式传递,而不是放在内核或隐式的环境执行上下文中。这样可以使调度器可替换、可追踪,并解决许多异步 Rust 的问题。