为什么在Rust中实现异步代码是特别困难?

使用异步 Rust 比 Go(goroutine)或线程要困难得多:
举个例子,假设我们需要在 Web 服务中运行一些 cpu 密集型操作(假设线程阻塞 3 秒)。

  1. 异步 Rust 与 Tokio 会阻塞任务,使用 spawn_blocking 与 Tokio 进行异步 Rust
  2. Go语言只是阻塞 goroutine;Go将 cpu 繁重的工作调度到新的 goroutine 上

为什么 Rust 中的阻塞比 Go 的阻塞更有害?
为什么在 Rust 中生成一个新线程比生成一个新的 goroutine 更困难或更危险?

在 JavaScript、Python 或 Rust 等显式 async 语言中,如果一个占用 CPU 资源较多的操作运行了 3 秒钟,在此期间就不会有其他工作发生。

Go 和 Erlang 等使用 "绿色线程 "的语言会隐式修改所有代码,使其定期调用调度程序,并询问是否需要在其他操作运行时暂停一下。这解决了阻塞问题,但也带来了其他问题:因为调度器总是在你的背后,所以会产生 CPU 开销。有调度器的语言不能被其他语言调用(这也是为什么没有用 Go 编写的跨语言库,只有 C/C++/Rust 才有)。如果调用其他语言(例如 C 或 Rust 库),就会再次遇到阻塞问题。

归根结底,这些只是不同的权衡。Rust 的设计牺牲了抗阻塞性,以获得可嵌入性(可在微控制器上运行,可作为库从任何语言调用)和极高性能。

关于Tokio

  • Tokio 默认使用多线程。
  • Tokio 使用抢工调度。

这样做的最终结果是,如果某个线程出现懈怠(或阻塞),其他线程应能完成所有可转移的任务。

这需要注意两点:

  1. 如果有 N 个线程在运行,而 N 个线程被阻塞,那么就没有线程可以接替。在单线程模式下运行 tokio 时尤其如此...
  2. spawn_local 可用于运行必须在当前线程上执行的任务,这可能是因为这些任务没有实现发送功能。如果当前线程被阻塞,这些任务就会等待。

使用 Tokio 时,您可以选择单线程或多线程调度程序。
多线程确实稍微缓解了这个问题,但是您仍然可以通过 N 个长时间运行的操作来阻塞 N 个线程,因此您不能依赖它来实际解决问题。

抛弃其他语言导致的毛病
Sun 微型系统的 Solaris 操作系统拥有最轻量级的线程。如果你需要一个线程来照看一个空闲的 TCP 连接,Solaris 就是你的最佳选择。他们编写了 java,因此他们围绕这种线程技术构建了 IO 阻塞,这给他们的操作系统带来了不公平的优势。

后来出现了 epoll、kqueue、IO-completion port,于是就有了更好的异步 IO 方法。

Javascript 就是围绕这种回调方法构建的。随着 async/await 语义的出现,它变得更加完美。但 Javascript 最初的目的是调度事件和网络请求。你不会用它来编写一个复杂的系统。(至少在当时是这样)

Python 有可以使用 async/await 的 asyncXXX 库,但它们肯定无法与 90% 的库很好地集成。

Golang 在历史上处于一个罕见的时刻,它在原生(无 JIT)中使用类似 GC 管理的 go 例程,解决了一系列独特的问题。高并发、占用空间小的系统需求量很大,如 docker、RocksDB、etcd 等。

在 Rust 中,作为一个基础库生态系统,网络服务器、游戏引擎或内核模块都属于 Rust。

任何怪癖和缺乏直观性的怪癖都是问题。

以 Java 为例,我不得不担心 GC 什么时候会启动,不得不为每个运行环境仔细调整 JRE。我不得不担心我的通用执行器池是否会被第三方库滥用。

但我不需要 Rust 给我带来同样的麻烦。

有了 Rust 中的作用域线程,我可以让一个函数调度(不退出)所有我认为值得调度的并行面,并让它们共享调用者堆栈中的引用。如果我的主线程需要两个并行面,我就必须预先启动(可能是在嵌套的作用域线程中),这样两个并行面都不会返回。但我可以组成 K 个重线程,激活 K 个独立模块。每个模块都可以单独使用 epoll/queue/IOcompletion。你可以让两个 epoll 系统各自独立运行。由于不会有 10,000 个模块,因此永远不会直接面临 C10k 的问题。同样,1,2,3 个线程可以满足 10,000 个潜在的 TCP 连接。

我喜欢异步 IO 的唯一一个用例是发出两个原本阻塞的 IO 请求。在 IO 完成时进行一些后处理,然后将两个任务连接起来。但这可以通过许多 Rust crates 明确实现。async 语法固然不错,但你需要明确地使用某些板块。

剩下的情况是一个代理有 10K 个流在飞行中。但我认为,使用 epoll 和显式状态机可以更高效地处理这种情况。以nginx为例。在数据库代理中,如果活动任务多于 CPU,就会给底层数据库带来问题。例如,你的Rust服务器会让你的数据库超载,而你的数据库可以说比你的无状态网络服务器群还要弱。

线程和消息传递
在 Rust 和 Tokio 中,"fire-and-forget "风格的异步完全可以正常工作,就像任何现代语言的其他异步功能一样。

当你想在线程之间进行消息传递时,核心设计就会崩溃。在大多数其他语言中,线程和消息传递是设计的一部分。然而,在 Rust 和 Tokio 中,这感觉非常突兀,因此你最终会在代码中使用奇怪的抽象来处理编译器本该为你做的事情。

你必须设置一个通道,生成一个任务,然后将其封装在一个结构体中以保持状态。所有这一切最终都会变得非常难读且杂乱无章。在监听任务中,甚至在处理消息之前,你就必须编写脚手架消息接收器。编译器的每一步都在与你对抗,告诉你什么能做什么不能做,因此最终会变得痛苦而乏味,我很少想把它作为一种模式来使用。

更困难的是,如果你刚开始学习 Rust,你将在一个看似非常简单的项目中处理所有复杂的借用检查器。