【譯】Async/Await(一)——多任務
- 2021 年 1 月 16 日
- 筆記
原文標題:Async/Await
原文鏈接://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)](<//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](<//en.wikipedia.org/wiki/Yield_(multithreading “yield”)>)操作,yield 操作放棄 CPU 的控制並允許其他任務運行。例如,可以在一個複雜的循環每次迭代後插入一個 yield。
常見的是將協作式多任務和異步操作(asynchronous operations)[9]相結合。不同於總是等待一個操作完成並且阻止其他任務這個時間運行,如果操作還沒結束,異步操作返回一個「未準備好(not ready)」的狀態。在這種情況下,處於等待中的任務可以執行一個 yield 操作讓其他任務運行。
保存狀態
因為任務定義了它們自身的暫停點,所以它們不需要操作系統來保存它們的狀態。它們可以在自己暫停之前,準確保存自己所需的狀態以便之後繼續執行,這通常會帶來更好的性能。例如,剛剛結束一次複雜計算的任務可能只需要備份計算的最後結果,因為它不再需要任何中間過程的結果。
語言支持的協作式多任務實現甚至能夠在暫停之前備份調用棧中所需要的部分。例如,Rust 中的 async/await 實現存儲了所有的局部變量(local variable),這些變量在一個自動生成的結構體中還會被用到(後面會提到)。通過在暫停之前備份調用棧中的相關部分,所有的任務可以共享一個調用棧
,從而使得每個任務的內存消耗比較小。這也使得在不耗盡內存的情況下創建幾乎任意數量的協作式任務成為可能。
討論
協作式多任務的缺點是,一個非協作式任務有可能無限期運行。因此,一個惡意或者有 bug 的任務可以阻止其他任務運行並且拖慢甚至鎖住整個系統。因此,僅當所有的任務已知是都能協作的情況下,協作式多任務才應該被使用。舉一個反例,讓操作系統依賴於任意用戶級程序的協作不是一個好的想法。
儘管如此,協作式多任務的強大性能和內存優勢使得它依然成為在程序內使用的好方法,尤其是與異步操作相結合後。因為操作系統內核是一個與異步硬件交互的性能關鍵型(performance-critical)程序,所以協作式多任務似乎是實現並發的一種好方式。

參考資料
Github: //github.com/phil-opp/blog_os
[2]
post-12: //github.com/phil-opp/blog_os/tree/post-12
[3]
多任務: //en.wikipedia.org/wiki/Computer_multitasking
[4]
Hardware Interrupts: //os.phil-opp.com/hardware-interrupts/
[5]
調用棧(call stack): //en.wikipedia.org/wiki/Call_stack
[6]
上下文切換(context switch): //en.wikipedia.org/wiki/Context_switch
[7]
協程(coroutine): //en.wikipedia.org/wiki/Coroutine
[8]
async/await: //rust-lang.github.io/async-book/01_getting_started/04_async_await_primer.html
[9]
異步操作(asynchronous operations): //en.wikipedia.org/wiki/Asynchronous_I/O