【譯】Async/Await(一)——多工

Praying發表於2021-01-16

原文標題: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

相關文章