Rust的async-await:协作调度 vs 抢占调度


线程是为了并行化计算密集型任务。然而,如今,许多应用程序都是 I/O(输入/输出)密集型应用程序。
这样,线程就有两个重大问题:

  • 他们使用大量(与其他解决方案相比)内存
  • 启动和上下文切换的成本可以在大量(数万个)线程运行时感受到。

在实践中,这意味着通过使用线程,我们的应用程序将花费大量时间等待网络请求完成并使用比必要更多的资源。
从程序员的角度来看,async/await提供了与线程相同的东西:并发性、更好的硬件利用率、更高的速度,但对于 I/O 绑定的工作负载具有显着更好的性能和更低的资源使用率。
什么是I/O 绑定工作负载?这些任务大部分时间都在等待网络或磁盘操作完成,而不是受到处理器计算能力的限制。
线程是很久以前设计的,当时大多数计算都不是网络(Web)相关的东西,因此不适合太多并发 I/O 任务。
正如我们从 Jim Blandy 所做的这些测量中看到的那样,使用异步的上下文切换比使用 Linux 线程快大约 30%,并且使用的内存减少了大约 20 倍。
在编程语言世界中,处理 I/O 任务的方式主要有两种:抢占式调度和协作式调度。
 
抢先调度
抢占式调度是指任务的调度不受开发人员控制,完全由运行时管理。无论程序员是启动同步任务还是异步任务,代码都没有区别。
例如,Go编程依靠的是抢占式调度。
它的优点是更容易学习:对于开发者来说,同步和非同步代码之间没有区别。另外,它几乎不可能被滥用:运行时负责处理一切。
下面是一个在Go中进行HTTP请求的例子:
resp, err := http.Get("http://kerkour.com")

仅仅通过看这个片段,我们无法判断http.Get是I/O密集型还是计算密集型。
其缺点是。
  • 速度,受限于运行时Runtime的聪明程度。
  • 难以调试bug。如果运行时有bug,可能极难发现,因为运行时被开发者当成了黑暗魔法。

 
合作调度
另一方面,在合作式调度下,开发者负责告诉运行时Runtime一个任务何时要花一些时间来等待I/O。
也就是要等待?是的,你明白了。这正是 await 关键字的目的。
这是给运行时(和编译器)的指示,任务将花费一些时间来等待操作完成,因此计算资源可以在此期间用于其他任务。
它的优点是速度极快。基本上,开发者和运行时一起工作,和谐相处,以最大限度地利用处置时的计算能力。

合作调度的主要缺点是它更容易被滥用:如果一个等待被遗忘(幸运的是,Rust编译器会发出警告),或者事件循环被阻塞超过几微秒,就会对系统的性能产生灾难性的影响。
其推论是,一个async 程序应该极其小心地处理计算密集型操作。

下面是一个用Rust进行HTTP请求的例子。

let res = reqwest::get("https://www.rust-lang.org").await?;

.await 关键字告诉我们,reqwest::get 函数预计需要一些时间来完成。