天天看點

C++20:協程協程理論

文章目錄

  • 協程理論
    • 協程是函數,函數是協程
    • “普通”的函數
      • 活躍幀
      • “調用”操作
      • ”傳回“操作
    • 協程
      • 協程的活躍幀
      • “暫停”操作
      • “恢複”操作
      • “摧毀”操作
      • 協程的“調用”操作
      • 協程的“傳回”操作
    • 一個插圖
    • 總結
    • 參考

協程理論

這是C++ Coroutines TS系列文章的第一篇,Coroutines是一種新的語言特性,目前有望納入C++20語言标準(已經納入)。

在這個系列中,我将讨論C++協程底層的工作機制,以及展示它們如何被用來建構進階抽象。

在這篇文章中,我将描述函數和協程的差異,并且提供一些有關它們支援操作的理論。這篇文章的目的是引入一些基本的概念,這有助于建構你對C++協程的了解方式。

協程是函數,函數是協程

協程是函數的泛化,協程允許函數被暫停,并在之後恢複執行。

我将詳細解釋這意味着什麼,但在這之前,我想先回顧一下“普通”C++函數的工作方式。

“普通”的函數

一個普通的函數可以被認為具有兩個操作:調用和傳回。

調用操作建立一個活躍幀,暫停調用函數(調用者)的執行,并将執行權轉移到被調用函數的開頭。

傳回操作将傳回值傳遞給調用者,摧毀活躍幀,然後恢複調用函數(調用者)的執行。

讓我們再分析一下這些語義。。。

活躍幀

那麼,“活躍幀”是什麼?

你可以将它了解為記憶體塊,它維護了一個調用函數目前的狀态。這些狀态包括被傳遞給它的所有參數值和所有局部變量值。

對于“普通”函數,活躍幀也包含傳回位址 - 從函數傳回時将執行轉移到的指令的位址 - 以及用于調用函數的活躍幀的位址。您可以将這些資訊一起描述為函數調用的“延續”。即,它們描述了該函數完成時在哪個點應繼續執行哪個函數的調用。

對于“普通”函數,所有活躍幀有嚴格嵌套的生命周期。這種嚴格的嵌套允許使用高效的記憶體配置設定資料結構來為每個函數調用配置設定和釋放活躍幀。這些資料結構通常被稱為“棧”。

當一個活躍幀被配置設定在棧上時,它通常被稱為“棧幀”。這種棧資料結構非常普遍,以至于大多數(所有?)CPU架構都有專用的寄存器,用于儲存指向棧頂部的指針(例如,x64架構下的

rsp

)。

為了給新的活躍幀配置設定控件,你隻需要将該寄存器的值增加幀的大小。為了釋放活躍幀的空間,你隻需要将該寄存器的值減小幀的大小。

“調用”操作

當一個函數調用另一個函數時,調用者必須首先為暫停執行做準備。

“暫停“步驟通常設計将目前儲存在CPU寄存器中的所有值儲存到記憶體中,以便以後在函數恢複執行時根據需要可以還原這些值。取決于這個函數的調用約定,調用者和被調用者可以協調誰來儲存這些寄存器的值,但是你仍然可以将他們視為”調用“操作的一部分。

調用者還将傳遞給被調函數的所有參數的值存儲到新的活躍幀中,在此它們可以被函數通路。

最後,調用者将調用者的恢複點位址寫入新的活躍幀,并将執行權轉移到被調函數的開頭。

在X86/X86架構中,此最終操作具有其自己的指令,

call

指令,它将下一條指令的位址寫入棧中,遞增棧寄存器的值,然後跳轉到該指令操作數中指定的位址。

”傳回“操作

當函數通過

return

語句傳回時,函數首先存儲傳回值到調用者可以通路的地方。這将是調用者的活躍幀中或者這個函數的活躍幀中。

接着該函數摧毀活躍幀通過:

  • 摧毀所有局部變量
  • 摧毀所有參數對象
  • 釋放被活躍幀使用的記憶體

最後,恢複調用者的執行通過:

  • 恢複調用者的活動架構通過設定棧寄存器以隻想調用者的活躍幀,然後恢複所有可能被該函數破壞的寄存器
  • 跳轉到調用者的恢複點,恢複點在”調用“操作中被存儲

請注意,與“調用”操作一樣,某些調用約定可能會在調用方和被調用方功能說明之間劃分“傳回”操作的職責。

協程

協程概括了一個函數的操作,通過劃分在調用和傳回操作中的一些步驟為三個額外的操作:暫停,恢複和摧毀(Suspend, Resume and Destroy)。

暫停操作在函數中的目前點暫停協程的執行,并将執行權傳回給調用者或恢複者,而不會破環活躍幀。在暫停協程執行後,在暫停點範圍内的所有對象仍保持活動狀态。

注意,像函數的”傳回“操作一樣,一個協程隻能被協程本身暫停。

恢複操作恢複一個暫停協程的執行。這将重新活躍協程的活躍幀。

摧毀操作摧毀活躍幀并且不恢複協程的執行。任何在暫停點傳回内的對象都将被摧毀。被用于存儲這個活躍幀的記憶體将被釋放。

協程的活躍幀

因為協程可以被暫停而無需摧毀活躍幀,我們不能再保證活躍幀的生命周期将嚴格嵌套。這一位置活躍幀通常不使用棧資料結構配置設定,而是可能被存儲在堆上。

如果編譯器可以證明協程的生命周期确實嚴格地嵌套在調用者的生存期内,則C ++協程TS中有一些規定允許從調用者的活躍幀中為協程幀配置設定記憶體。在許多情況下,隻要您有一個足夠聰明的編譯器,就可以避免堆配置設定。對于協程,在協程暫停時需要保留活躍幀的某些部分,而隻有在協程執行時才需要保留某些部分。例如,範圍不跨越任何協程暫停點的變量的生存期可能會存儲在棧中。

您可以從邏輯上将協程的活躍幀想象為由兩部分組成:“協程幀”和“棧幀”。

“協程幀”維護了協程活躍幀的一部分,該活躍幀在協程被暫停時仍然存在,而“棧幀”部分僅在協程執行期間存在,而在協程暫停并将執行權轉移回調用者/恢複者時被釋放。

“暫停”操作

協程的暫停操作允許協程在函數中間暫停執行,并轉移執行權給協程的調用者或恢複者。

協程的體内某些點被指定為暫停點。 在C++協程TS中,這些暫停點是通過使用

co_await

co_yield

關鍵字來辨別的。

當協程到達這些暫停點之一時,它首先準備協程以恢複通過:

  • 確定将寄存器中儲存的所有值寫入協程幀
  • 向協程幀中寫入一個值,該值訓示協程所暫停的暫停點。這使後續的Resume操作可以知道在哪裡繼續執行協程,或者通過後續的Destroy可以知道範圍内哪些值需要銷毀。

一旦協程準備完畢,協程将被考慮“暫停”。

在執行權轉移會調用者或恢複者之前,協程有機會執行一些其他的邏輯。這些額外的邏輯通路協程幀的句柄,該句柄可用于在之後恢複或摧毀協程。

在協程進入“暫停”狀态後執行邏輯的這種能力允許協程被排程恢複,而無需同步,如果協程在進入“暫停”狀态之前被排程恢複,則需要同步。 我将在以後的文章中對此進行更詳細的介紹。

然後,協程可以選擇立即恢複/繼續執行協程,也可以選擇将執行權轉移回調用方/恢複方。

如果執行權轉移到調用者/恢複者,則協程活躍幀的棧幀部分将被釋放并從棧中彈出。

“恢複”操作

恢複操作可以在處于暫停狀态的協程上執行。

當一個函數想要恢複協程是,他需要“調用”該函數的特定調用。恢複者辨別特定的調用方式是通過在提供給相應的暫停操作的協程幀句柄上調用

void resume()

方法。

就像一個正常的函數調用一樣,調用

resume()

将配置設定一個新的棧幀,然後在棧幀中存儲調用者的傳回位址,這些操作将在執行權轉移給被調函數之前進行。

然而,代替轉移執行權到函數的開始,恢複操作将轉移執行權到函數最後一次被暫停的點。通過從協程幀中加載恢複點并跳轉到改點來完成這個操作。

當協程接下來暫停或運作完畢時,調用

resume()

将傳回并恢複調用函數的執行。

“摧毀”操作

摧毀操作摧毀協程幀,不會恢複協程的執行。

這個操作隻能在被暫停的協程上執行。

摧毀操作的行為更像重新激活協程活躍幀的恢複操作,包括配置設定一個新的棧幀并存儲摧毀操作的調用者的傳回位址。

然而,代替轉移執行權到最後一次的挂起點,它将轉移執行權到一個替換的代碼路徑,這将摧毀所有作用域中的局部變量(在暫停點之前的),然後釋放被協程幀使用的記憶體。

與恢複操作相似,摧毀操作辨別特定的将要摧毀的活躍幀是通過在協程幀句柄上調用

void destory()

方法。

協程的“調用”操作

協程的調用操作和普通函數的調用操作類似。事實上,從調用者的角度來看,沒有什麼不同。

然而,不隻是當函數執行完畢時,執行權才傳回給調用者,協程的調用操作也将恢複調用者的執行,當協程抵達它的第一個挂起點時。

當在一個協程上執行調用操作時,調用者配置設定一個新的棧幀,寫入參數到棧幀中,寫入傳回位址到棧幀中,并且轉移執行權給協程。這與調用一個普通的函數完全相同。

協程第一件要做的事情時在堆上配置設定一個協程幀,然後從棧幀中拷貝或移動參數到協程幀中,以便延長參數的生命周期超出第一次暫停點。

協程的“傳回”操作

協程的傳回操作與普通函數的有點不同。

當一個協程執行一條

return

語句時(根據技術規範為

co_return

),它将存儲傳回值然後摧毀所有作用域中的變量(不包括參數)。

之後協程将有一個機會去執行一些額外的邏輯在轉移執行權到調用者/恢複者之前。

這些額外的邏輯可能執行一些操作去釋出傳回值,也可能恢複另一個等待結果的協程。這完全是自定義的。

之後協程可以執行一個暫停操作(保持協程幀的活躍)或一個摧毀操作(摧毀協程幀)。

之後執行權被轉移給調用者/恢複者根據暫停/ 恢複操作的語義,将活躍幀的棧幀元件從棧中彈出。

需要注意的是,被傳遞給傳回操作的傳回值與調用操作的傳回值是不一樣的,因為傳回操作可能在調用者從初始調用操作恢複很久之後才執行。

一個插圖

為了幫助将這些概念付諸實踐,我想通過一個簡單的示例說明協程被調用,暫停和恢複時的發生了什麼。

假定有一個函數(或協程)

f()

,它調用了一個協程

x(int a)

調用之前的情形大緻如下:

棧                         寄存器                   堆

                          +------+
+---------------+ <------ | rsp  |
|  f()          |         +------+
+---------------+
| ...           |
|               |
           

當調用

x(42)

時,首先為

x()

建立一個棧幀,像普通的函數一樣。

棧                         寄存器                   堆
+----------------+ <-+
|  x()           |   |
| a  = 42        |   |
| ret= f()+0x123 |   |    +------+
+----------------+   +--- | rsp  |
|  f()           |        +------+
+----------------+
| ...            |
|                |
           

然後,當協程

x()

已經為協程幀在堆上配置設定完記憶體并且參數值已被拷貝或移動至協程幀後,我們将最終得到一樣東西,這将在下一個圖解中看到。注意編譯器通常會維護協程幀的位址(例如,MSVC存儲在

rbp

寄存器中)。

棧                         寄存器                   堆
+----------------+ <-+
|  x()           |   |
| a  = 42        |   |                   +-->  +-----------+
| ret= f()+0x123 |   |    +------+       |     |  x()      |
+----------------+   +--- | rsp  |       |     | a =  42   |
|  f()           |        +------+       |     +-----------+
+----------------+        | rbp  | ------+
| ...            |        +------+
|                |
           

如果協程

x()

之後調用了另一個普通函數

g()

,他将看起來像下面一樣。

棧                         寄存器                   堆
+----------------+ <-+
|  g()           |   |
| ret= x()+0x45  |   |
+----------------+   |
|  x()           |   |
| coroframe      | --|-------------------+
| a  = 42        |   |                   +-->  +-----------+
| ret= f()+0x123 |   |    +------+             |  x()      |
+----------------+   +--- | rsp  |             | a =  42   |
|  f()           |        +------+             +-----------+
+----------------+        | rbp  |
| ...            |        +------+
|                |
           

g()

傳回時,将摧毀它的活躍幀然後存儲

x()

的活躍幀。假設

g()

的傳回值存儲在局部變量

b

中,

b

被存儲在協程幀中。

棧                         寄存器                   堆
+----------------+ <-+
|  x()           |   |
| a  = 42        |   |                   +-->  +-----------+
| ret= f()+0x123 |   |    +------+       |     |  x()      |
+----------------+   +--- | rsp  |       |     | a =  42   |
|  f()           |        +------+       |     | b = 789   |
+----------------+        | rbp  | ------+     +-----------+
| ...            |        +------+
|                |
           

如果此時

x()

擊中了一個暫停點并且在不摧毀它的活躍幀的情況下暫停執行,他麼執行權将傳回給

f()

這導緻

x()

的棧幀部分将從棧幀中彈出,而協程幀保留在堆中。當協程第一次暫停時,一個傳回值被傳回給調用者。這個傳回值通常維護一個協程幀的句柄,它可以被用來在之後恢複協程的執行。當

x()

暫停時,也将存儲

x()

的恢複點的位址在協程幀中(

RP

為resume-point)。

棧                         寄存器                   堆
                                        +----> +-----------+
                          +------+      |      |  x()      |
+----------------+ <----- | rsp  |      |      | a =  42   |
|  f()           |        +------+      |      | b = 789   |
| handle     ----|---+    | rbp  |      |      | RP=x()+99 |
| ...            |   |    +------+      |      +-----------+
|                |   |                  |
|                |   +------------------+
           

現在這個句柄可以作為一個正常的值在函數之間傳遞。之後,從一個不同的調用棧或者甚至在一個不同的線程上,一些東西(我們稱為

h()

)将決定去恢複協程的執行。例如,當一個異步I/O操作完成時。

恢複協程的函數調回

void resume(handle)

去恢複協程的執行。對于調用者而言,這就像是任何其他普通的帶有

void

傳回類型和一個單獨參數的函數一樣。

這将建立一個新的棧幀,它記錄了調用

resume()

的調用者的傳回位址,激活協程幀通過加載它的位址到寄存器,并且在存儲在協程幀中的恢複點處恢複

x()

的執行。

棧                         寄存器                   堆
+----------------+ <-+
|  x()           |   |                   +-->  +-----------+
| ret= h()+0x87  |   |    +------+       |     |  x()      |
+----------------+   +--- | rsp  |       |     | a =  42   |
|  h()           |        +------+       |     | b = 789   |
| handle         |        | rbp  | ------+     +-----------+
+----------------+        +------+
| ...            |
|                |
           

總結

我們将協程描述為一個函數的概括(generalisation),出了”普通“函數的”調用“和”傳回“操作之外,它還有3個額外的操作 - “暫停”,”恢複“和”摧毀“。

我希望這将提供一些有用的心裡架構關于如何思考協程和它們的控制流。

在下一篇文章中,我将讨論C++協程技術規範語言擴充中的機制并解釋編譯器是如何将你寫的代碼翻譯成協程。

參考

翻譯自https://lewissbaker.github.io/2017/09/25/coroutine-theory

繼續閱讀