【译】Async/Await(一)——多任务

原文标题:Async/Await
原文链接:https://os.phil-opp.com/async-await/#multitasking
公众号: Rust 碎碎念
翻译 by: Praying

在本文中我们将讨论协作式多任务(cooperative multitasking)和 Rust 中的 async/await 特性。我们会详细了解 async/await 在 Rust 中是如何工作的,包括Future trait 的设计,状态机的转换和pinning。 然后,我们通过创建一个异步键盘任务和一个基本的执行器(executor),为我们的内核添加基本的 async/await 支持。

本文在Github[1]上是公开的。如果你有任何问题,请在 Github 上提 issue。你还可以在底部留下评论,本文完整的源码可以在post-12[2]分支看到。

多任务(Multitasking)

多任务[3]是大多数操作系统的基本特征之一,指能够并发地执行多个任务。例如,你可能在阅读本文的同时还运行着一些其他的程序,比如一个文本编辑器或者终端窗口。即使你只开着一个浏览器窗口,依然还会有各种后台任务在运行,管理着你的桌面窗口,检查更新或者索引文件。

尽管看上去似乎所有的任务是以并行的方式在运行,但实际上 CPU 核心一次只能执行一个任务。为了营造任务并行运行的错觉,操作系统会在活动任务之间快速切换,使每个任务都能向前推进一点儿。因为计算机运行速度很快,所以在绝大多数时候我们都注意不到这些切换。

虽然单核 CPU 一次只能执行单个任务,但是多核 CPU 能够真正以并行的方式执行多任务。例如,一个 8 核心的 CPU 可以同时运行 8 个任务。我们会在以后的文章中介绍如何设置多核 CPU。在本文中,为简单起见,我们主要讨论单核 CPU。(值得注意的是,所有的多核 CPU 都是从一个单独的活动核心开始的,所以我们目前可以把它们视作单核 CPU。)

存在两种形式的多任务:协作式多任务(Cooperative multitasking)要求任务周期性地放弃对 CPU 的控制权从而使得其他任务可以向前推进。抢占式多任务(Preemptive multitasking)利用操作系统功能通过强制暂停任务从而在任意时间点进行任务切换。下面我们将更加详细地讨论这两种形式的多任务并分析它们各自的优缺点。

抢占式多任务(Preemptive Multitasking)

抢占式多任务背后的理念是,操作系统控制了什么时间去切换任务。为此,它利用了每次中断时重新获得 CPU 控制这一事实。这样,只要系统有新的输入,就可以切换任务。例如,在鼠标移动或者网络包到达时它也可以切换任务。操作系统还可以通过配置一个硬件定时器在指定时间后发送中断,来决定一个任务被允许运行的准确时长。

下图解释了在一次硬件中断时的任务切换过程:

在第一行,CPU 正在执行程序(Program)A里的任务(Task)A1。所有其他的任务都是暂停的。在第二行,一个硬件中断抵达 CPU。正如Hardware Interrupts[4]这篇文章所描述的那样,CPU 立即停止了任务A1的执行并跳转到定义在中断向量表( interrupt descriptor table , IDT)中的中断处理程序(interrupt handler)。通过这个中断处理程序,操作系统现在再次控制了 CPU,从而使得它能够切换到任务B1而不是继续执行任务A1

保存状态

因为任务会在任意时刻被中断,而此时它们可能正处于某些计算的中间阶段。为了能够在后面进行恢复,操作系统必须将任务的整个状态进行备份,包括它的调用栈(call stack)[5]以及所有的 CPU 寄存器的值。这个过程被称为上下文切换(context switch)[6]

因为调用栈可能非常大,操作系统通常会为每个任务设置一个单独的调用栈,而不是在每次任务切换时都备份调用栈。这样带有单独调用栈的一个任务被称为[执行线程(thread of execution)](<https://en.wikipedia.org/wiki/Thread_(computing "执行线程(thread of execution)")>)或者短线程(thread for short)。在为每个任务使用一个单独的调用栈之后,在上下文切换时就只需要保存寄存器里的内容(包括程序计数器和栈指针)。这种方式使得上下文切换的开销最小化,这是非常重要的,因为上下文切换每秒会发生 100 次。

讨论

抢占式多任务的主要优势是操作系统可以完全控制一个任务的允许执行时间。这种方式下,它可以保证每个任务都获得一个公平的 CPU 时间片,而不需要依靠任务的协作。这在运行第三方任务或者多个用户共享一个系统时是尤其重要的。

抢占式多任务的缺点在于每个任务都需要自己的栈。相较于共享栈,这会导致每个任务更高的内存使用并且经常会限制系统中任务的数量。另一个缺点是操作系统在每一次任务切换时都必须要保存完整的 CPU 寄存器状态,即使任务可能只使用了寄存器的一小部分。

抢占式多任务和线程是一个操作系统的基础组件,因为它们使得运行不可靠的用户态程序成为可能。我们会在以后的文章中充分地讨论这些概念。但是在本文中,我们将主要讨论协作式多任务,它也为我们的内核提供了有用的功能。

协作式多任务(Cooperative Multitasking)

不同于在任意时刻强制暂停正在运行的任务,协作式多任务让每个任务运行直到它自愿放弃对 CPU 的控制。这使得任务在合适的时间点暂停自身,例如在它需要等待一个 I/O 操作时。

协作式多任务通常被用于编程语言级别,例如以协程(coroutine)[7]或者async/await[8]的形式。它的思想是,程序员或者编译器在程序中插入[yield](<https://en.wikipedia.org/wiki/Yield_(multithreading "yield")>)操作,yield 操作放弃 CPU 的控制并允许其他任务运行。例如,可以在一个复杂的循环每次迭代后插入一个 yield。

常见的是将协作式多任务和异步操作(asynchronous operations)[9]相结合。不同于总是等待一个操作完成并且阻止其他任务这个时间运行,如果操作还没结束,异步操作返回一个“未准备好(not ready)”的状态。在这种情况下,处于等待中的任务可以执行一个 yield 操作让其他任务运行。

保存状态

因为任务定义了它们自身的暂停点,所以它们不需要操作系统来保存它们的状态。它们可以在自己暂停之前,准确保存自己所需的状态以便之后继续执行,这通常会带来更好的性能。例如,刚刚结束一次复杂计算的任务可能只需要备份计算的最后结果,因为它不再需要任何中间过程的结果。

语言支持的协作式多任务实现甚至能够在暂停之前备份调用栈中所需要的部分。例如,Rust 中的 async/await 实现存储了所有的局部变量(local variable),这些变量在一个自动生成的结构体中还会被用到(后面会提到)。通过在暂停之前备份调用栈中的相关部分,所有的任务可以共享一个调用栈 ,从而使得每个任务的内存消耗比较小。这也使得在不耗尽内存的情况下创建几乎任意数量的协作式任务成为可能。

讨论

协作式多任务的缺点是,一个非协作式任务有可能无限期运行。因此,一个恶意或者有 bug 的任务可以阻止其他任务运行并且拖慢甚至锁住整个系统。因此,仅当所有的任务已知是都能协作的情况下,协作式多任务才应该被使用。举一个反例,让操作系统依赖于任意用户级程序的协作不是一个好的想法。

尽管如此,协作式多任务的强大性能和内存优势使得它依然成为在程序内使用的好方法,尤其是与异步操作相结合后。因为操作系统内核是一个与异步硬件交互的性能关键型(performance-critical)程序,所以协作式多任务似乎是实现并发的一种好方式。

参考资料

[1]

Github: https://github.com/phil-opp/blog_os

[2]

post-12: https://github.com/phil-opp/blog_os/tree/post-12

[3]

多任务: https://en.wikipedia.org/wiki/Computer_multitasking

[4]

Hardware Interrupts: https://os.phil-opp.com/hardware-interrupts/

[5]

调用栈(call stack): https://en.wikipedia.org/wiki/Call_stack

[6]

上下文切换(context switch): https://en.wikipedia.org/wiki/Context_switch

[7]

协程(coroutine): https://en.wikipedia.org/wiki/Coroutine

[8]

async/await: https://rust-lang.github.io/async-book/01_getting_started/04_async_await_primer.html

[9]

异步操作(asynchronous operations): https://en.wikipedia.org/wiki/Asynchronous_I/O

posted @ 2021-01-16 20:17  Praying  阅读(826)  评论(0编辑  收藏  举报