協程理論
這(zhè)是(shì)關于C++ 協程 TS的系列文章中的第一篇,C++ 協程 TS 是(shì)一項新的語言功能,目前有望納入 C++20 語言标準。
在本系列中,我将介紹 C++ 協程的底層機制(zhì)如何工(gōng)作,并展示如何使用它們來(lái)構建有用的高(gāo)級抽象,例如cppcoro 庫提供的抽象。
在這(zhè)篇文章中,我将描述函數和協程之間(jiān)的差異,并提供一些有關它們支持的操作的理論。本文的目的是(shì)介紹一些基本概念,這(zhè)些概念将有助于構建您對 C++ 協程的思考方式。
協程是(shì)函數也是(shì)協程
協程是(shì)函數的概括,它允許函數暫停然後恢複。
我将更詳細地(dì)解釋這(zhè)意味着什麽,但(dàn)在此之前我想首先回顧一下“正常”C++ 函數的工(gōng)作原理。
“正常”功能
一個普通(tōng)的函數可(kě)以被認爲有兩個操作:調用和返回 (請注意,我在這(zhè)裡(lǐ)将“抛出異常”廣泛地(dì)集中在返回操作下)。
Call操作創建一個激活幀,暫停調用函數的執行(xíng)并将執行(xíng)轉移到被調用函數的開(kāi)頭。
Return操作将返回值傳遞給調用者,銷毀激活幀,然後在調用者調用函數的點之後恢複調用者的執行(xíng)。
讓我們進一步分(fēn)析這(zhè)些語義......
激活框架
那麽這(zhè)個“激活框架”是(shì)什麽?
您可(kě)以将激活幀視(shì)爲保存函數特定調用的當前狀态的內(nèi)存塊。此狀态包括傳遞給它的任何參數的值以及任何局部變量的值。
對于“普通(tōng)”函數,激活幀還包括返回地(dì)址(從(cóng)函數返回時将執行(xíng)轉移到的指令的地(dì)址)以及用于調用調用函數的激活幀的地(dì)址。您可(kě)以将這(zhè)些信息一起視(shì)爲描述函數調用的“繼續”。 IE。它們描述了當該函數完成時,哪個函數的哪個調用應該繼續執行(xíng)。
對于“正常”函數,所有激活幀都具有嚴格嵌套的生命周期。這(zhè)種嚴格的嵌套允許使用高(gāo)效的內(nèi)存分(fēn)配數據結構來(lái)爲每個函數調用分(fēn)配和釋放激活幀。這(zhè)種數據結構通(tōng)常稱爲“堆棧”。
當在此堆棧數據結構上分(fēn)配激活幀時,它通(tōng)常稱爲“堆棧幀”。
這(zhè)種堆棧數據結構非常常見(jiàn),以至于大(dà)多(duō)數(所有?)CPU 架構都有一個專用寄存器,用于保存指向堆棧頂部的指針(例如,在 X64 中它是(shì)寄存器rsp)。
要(yào)爲新的激活幀分(fēn)配空間(jiān),隻需将該寄存器增加幀大(dà)小(xiǎo)即可(kě)。要(yào)爲激活幀釋放空間(jiān),隻需将此寄存器減少幀大(dà)小(xiǎo)即可(kě)。
“呼叫”操作
當一個函數調用另一個函數時,調用者必須首先做好挂起的準備。
此“挂起”步驟通(tōng)常涉及将當前保存在 CPU 寄存器中的任何值保存到內(nèi)存中,以便稍後在函數恢複執行(xíng)時需要(yào)時可(kě)以恢複這(zhè)些值。根據函數的調用約定,調用者和被調用者可(kě)能會(huì)協調誰保存這(zhè)些寄存器值,但(dàn)您仍然可(kě)以将它們視(shì)爲作爲Call操作的一部分(fēn)執行(xíng)。
調用者還将傳遞給被調用函數的任何參數的值存儲到新的激活幀中,函數可(kě)以在其中訪問(wèn)它們。
最後,調用者将調用者的恢複點地(dì)址寫入新的激活幀,并将執行(xíng)轉移到被調用函數的開(kāi)頭。
在 X86/X64 架構中,這(zhè)個最終操作有自(zì)己的指令,該call 指令将下一條指令的地(dì)址寫入堆棧,将堆棧寄存器增加地(dì)址的大(dà)小(xiǎo),然後跳(tiào)轉到指令操作數中指定的地(dì)址。
“返回”操作
當函數通(tōng)過return- 語句返回時,該函數首先将返回值(如果有)存儲在調用者可(kě)以訪問(wèn)的位置。這(zhè)可(kě)能是(shì)在調用者的激活幀中,也可(kě)能是(shì)在函數的激活幀中(對于跨越兩個激活幀之間(jiān)邊界的參數和返回值來(lái)說,區别可(kě)能會(huì)有點模糊)。
然後該函數通(tōng)過以下方式銷毀激活幀:
- 在返回點銷毀範圍內(nèi)的任何局部變量。
- 銷毀任何參數對象
- 釋放激活幀使用的內(nèi)存
最後,它通(tōng)過以下方式恢複調用者的執行(xíng):
- 通(tōng)過将堆棧寄存器設置爲指向調用者的激活幀并恢複可(kě)能已被函數破壞的任何寄存器來(lái)恢複調用者的激活幀。
- 跳(tiào)轉到“呼叫”操作期間(jiān)存儲的呼叫者的恢複點。
請注意,與“調用”操作一樣,某些調用約定可(kě)能會(huì)在調用者和被調用者函數的指令之間(jiān)劃分(fēn)“返回”操作的職責。
協程
協程通(tōng)過将Call和Return操作中執行(xíng)的一些步驟分(fēn)離(lí)爲三個額外(wài)操作來(lái) 概括函數的操作: Suspend、Resume和Destroy。
挂起操作會(huì)在函數內(nèi)的當前點挂起協程的執行(xíng),并将執行(xíng)轉移回調用方或恢複方,而不(bù)會(huì)破壞激活幀。協程執行(xíng)暫停後,暫停時作用域內(nèi)的任何對象仍保持活動狀态。
請注意,與函數的Return操作一樣,協程隻能在協程本身內(nèi)部的明确定義的挂起點處挂起。
Resume操作可(kě)在挂起的協程的挂起點恢複執行(xíng)。這(zhè)将重新激活協程的激活框架。
銷毀操作會(huì)銷毀激活幀而不(bù)恢複協程的執行(xíng)。挂起點範圍內(nèi)的任何對象都将被銷毀。用于存儲激活幀的內(nèi)存被釋放。
協程激活幀
由于協程可(kě)以在不(bù)破壞激活幀的情況下挂起,因此我們不(bù)能再保證激活幀的生存期将嚴格嵌套。這(zhè)意味着激活幀通(tōng)常不(bù)能使用堆棧數據結構進行(xíng)分(fēn)配,因此可(kě)能需要(yào)存儲在堆上。
C++ 協程 TS 中有一些規定,如果編譯器可(kě)以證明協程的生命周期确實嚴格嵌套在調用方的生命周期內(nèi),則允許從(cóng)調用方的激活幀分(fēn)配協程幀的內(nèi)存。如果您有足夠智能的編譯器,則在許多(duō)情況下這(zhè)可(kě)以避免堆分(fēn)配。
對于協程,激活框架的某些部分(fēn)需要(yào)在協程挂起期間(jiān)保留,而有些部分(fēn)隻需要(yào)在協程執行(xíng)時保留。例如,範圍不(bù)跨越任何協程挂起點的變量的生命周期可(kě)能會(huì)存儲在堆棧上。
您可(kě)以從(cóng)邏輯上認爲協程的激活框架由兩部分(fēn)組成:“協程框架”和“堆棧框架”。
“協程幀”保存協程激活幀的一部分(fēn),該部分(fēn)在協程挂起時持續存在,而“堆棧幀”部分(fēn)僅在協程執行(xíng)時存在,并在協程挂起并将執行(xíng)轉移回調用方/恢複方時釋放。
“暫停”操作
協程的挂起操作允許協程在函數中間(jiān)暫停執行(xíng),并将執行(xíng)轉移回協程的調用者或恢複者。
協程體(tǐ)內(nèi)的某些點被指定爲挂起點。在 C++ 協程 TS 中,這(zhè)些挂起點通(tōng)過使用co_await或co_yield關鍵字來(lái)标識。
當協程到達這(zhè)些挂起點之一時,它首先通(tōng)過以下方式準備協程恢複:
- 确保寄存器中保存的任何值都寫入協程框架
- 向協程幀寫入一個值,指示協程暫停在哪個暫停點。這(zhè)允許後續的Resume操作知道(dào)在哪裡(lǐ)恢複協程的執行(xíng),或者後續的Destroy 操作 知道(dào)哪些值在範圍內(nèi)并且需要(yào)銷毀。
一旦協程準備好恢複,協程就(jiù)被視(shì)爲“暫停”。
然後,協程有機會(huì)在執行(xíng)轉移回調用者/恢複者之前執行(xíng)一些附加邏輯。該附加邏輯可(kě)以訪問(wèn)協程框架的句柄,該句柄可(kě)用于稍後恢複或銷毀它。
這(zhè)種在協程進入“挂起”狀态後執行(xíng)邏輯的能力允許協程被安排爲恢複,而無需同步,否則如果協程在進入“挂起”狀态之前被安排爲恢複,則需要(yào)同步,因爲暫停和恢複協程進行(xíng)競賽的可(kě)能性。我将在以後的帖子中更詳細地(dì)討(tǎo)論這(zhè)一點。
然後,協程可(kě)以選擇立即恢複/繼續執行(xíng)協程,或者可(kě)以選擇将執行(xíng)轉移回調用者/恢複者。
如果執行(xíng)轉移到調用者/恢複者,則協程激活幀的堆棧幀部分(fēn)将被釋放并從(cóng)堆棧中彈出。
“恢複”操作
恢複操作可(kě)以在當前處于“挂起”狀态的協程上執行(xíng)。
當函數想要(yào)恢複協程時,它需要(yào)有效地(dì)“調用”到該函數的特定調用的中間(jiān)。恢複程序識别要(yào)恢複的特定調用的方式是(shì)調用提供給相(xiàng)應挂起void resume()操作的協程幀句柄上的方法。
就(jiù)像普通(tōng)的函數調用一樣,此調用resume()将分(fēn)配一個新的堆棧幀,并将調用者的返回地(dì)址存儲在堆棧幀中,然後再将執行(xíng)轉移到函數。
但(dàn)是(shì),它不(bù)會(huì)将執行(xíng)轉移到函數的開(kāi)頭,而是(shì)将執行(xíng)轉移到函數中上次挂起的位置。它通(tōng)過從(cóng)協程框架加載恢複點并跳(tiào)轉到該點來(lái)實現(xiàn)這(zhè)一點。
當協程下次挂起或運行(xíng)完成時,此調用resume() 将返回并恢複調用函數的執行(xíng)。
“破壞”行(xíng)動
Destroy操作會(huì)銷毀協程框架,但(dàn)不(bù)會(huì)恢複協程的執行(xíng)。
此操作隻能在挂起的協程上執行(xíng)。
Destroy操作的行(xíng)爲與Resume操作非常相(xiàng)似,因爲它重新激活協程的激活幀,包括分(fēn)配新的堆棧幀并存儲Destroy操作的調用者的返回地(dì)址 。
然而,它不(bù)是(shì)在最後一個挂起點将執行(xíng)轉移到協程主體(tǐ),而是(shì)将執行(xíng)轉移到另一個代碼路(lù)徑,該代碼路(lù)徑在挂起點調用範圍內(nèi)所有局部變量的析構函數,然後釋放協程使用的內(nèi)存。協程框架。
與Resume操作類似,Destroy操作通(tōng)過調用相(xiàng)應Suspendvoid destroy()操作期間(jiān)提供的協程幀句柄上的方法來(lái)識别要(yào)銷毀的特定激活幀 。
協程的“調用”操作
協程的調用操作與普通(tōng)函數的調用操作非常相(xiàng)似。事實上,從(cóng)調用者的角度來(lái)看沒有什麽區别。
然而,當函數運行(xíng)完成時,執行(xíng)不(bù)會(huì)僅返回到調用者,而對于協程,調用操作将在協程到達其第一個挂起點時恢複調用者的執行(xíng)。
當對協程執行(xíng)Call操作時,調用者分(fēn)配一個新的堆棧幀,将參數寫入堆棧幀,将返回地(dì)址寫入堆棧幀并将執行(xíng)轉移到協程。這(zhè)與調用普通(tōng)函數完全相(xiàng)同。
協程要(yào)做的第一件(jiàn)事是(shì)在堆上分(fēn)配一個協程幀,并将參數從(cóng)堆棧幀複制(zhì)/移動到協程幀中,以便參數的生命周期超出第一個挂起點。
協程的“返回”操作
協程的Return操作與普通(tōng)函數的 Return 操作略有不(bù)同。
當協程執行(xíng)return-statement(co_return根據 TS)操作時,它将返回值存儲在某處(具體(tǐ)存儲位置可(kě)以由協程自(zì)定義),然後銷毀任何範圍內(nèi)的局部變量(但(dàn)不(bù)包括參數)。
然後,協程有機會(huì)在将執行(xíng)轉移回調用者/恢複者之前執行(xíng)一些附加邏輯。
此附加邏輯可(kě)能會(huì)執行(xíng)某些操作來(lái)發布返回值,或者可(kě)能會(huì)恢複另一個正在等待結果的協程。它是(shì)完全可(kě)定制(zhì)的。
然後,協程執行(xíng)挂起操作(保持協程框架處于活動狀态)或銷毀操作(銷毀協程框架)。
然後根據挂起/銷毀操作語義将執行(xíng)轉移回調用者/恢複者 ,将激活幀的堆棧幀組件(jiàn)從(cóng)堆棧中彈出。
請務必注意,傳遞給Return操作的返回值與從(cóng)Call操作返回的返回值不(bù)同,因爲返回操作可(kě)能會(huì)在調用者從(cóng)初始Call 操作恢複之後很(hěn)長時間(jiān)才執行(xíng)。
一個例子
爲了幫助将這(zhè)些概念轉化(huà)爲圖片,我想通(tōng)過一個簡單的示例來(lái)演示調用協程、挂起并稍後恢複時會(huì)發生什麽情況。
假設我們有一個函數(或協程),f()它調用協程x(int a)。
在調用之前,我們遇到的情況有點像這(zhè)樣:
STACK REGISTERS HEAP
+------+
+---------------+ <------ | rsp |
| f() | +------+
+---------------+
| ... |
| |
然後,當x(42)調用 時,它首先爲 創建一個堆棧幀x(),就(jiù)像普通(tōng)函數一樣。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | |
| a = 42 | |
| ret= f()+0x123 | | +------+
+----------------+ +--- | rsp |
| f() | +------+
+----------------+
| ... |
| |
然後,一旦協程x()爲堆上的協程幀分(fēn)配了內(nèi)存并将參數值複制(zhì)/移動到協程幀中,我們最終将得到如下圖所示的結果。請注意,編譯器通(tōng)常會(huì)将協程幀的地(dì)址保存在堆棧指針的單獨寄存器中(例如,MSVC 将其存儲在寄存器中rbp)。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | |
| a = 42 | | +--> +-----------+
| ret= f()+0x123 | | +------+ | | x() |
+----------------+ +--- | rsp | | | a = 42 |
| f() | +------+ | +-----------+
+----------------+ | rbp | ------+
| ... | +------+
| |
如果協程x()随後調用另一個普通(tōng)函數,g()它将看起來(lái)像這(zhè)樣。
STACK REGISTERS HEAP
+----------------+ <-+
| g() | |
| ret= x()+0x45 | |
+----------------+ |
| x() | |
| coroframe | --|-------------------+
| a = 42 | | +--> +-----------+
| ret= f()+0x123 | | +------+ | x() |
+----------------+ +--- | rsp | | a = 42 |
| f() | +------+ +-----------+
+----------------+ | rbp |
| ... | +------+
| |
當g()返回時,它将破壞其激活框架并恢複 的x()激活框架。假設我們将g()的返回值保存在b存儲在協程框架中的局部變量中。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | |
| a = 42 | | +--> +-----------+
| ret= f()+0x123 | | +------+ | | x() |
+----------------+ +--- | rsp | | | a = 42 |
| f() | +------+ | | b = 789 |
+----------------+ | rbp | ------+ +-----------+
| ... | +------+
| |
如果x()現(xiàn)在到達挂起點并挂起執行(xíng)而不(bù)破壞其激活幀,則執行(xíng)返回到f()。
這(zhè)會(huì)導緻堆棧框架部分(fēn)從(cóng)x()堆棧中彈出,而将協程框架留在堆上。當協程第一次挂起時,會(huì)向調用者返回一個返回值。此返回值通(tōng)常包含挂起的協程框架的句柄,可(kě)用于稍後恢複它。當挂起時,它還存儲 協程幀中x()的恢複點的地(dì)址(稱爲恢複點)。x()RP
STACK REGISTERS HEAP
+----> +-----------+
+------+ | | x() |
+----------------+ <----- | rsp | | | a = 42 |
| f() | +------+ | | b = 789 |
| handle ----|---+ | rbp | | | RP=x()+99 |
| ... | | +------+ | +-----------+
| | | |
| | +------------------+
該句柄現(xiàn)在可(kě)以作爲函數之間(jiān)的正常值傳遞。在稍後的某個時刻,可(kě)能來(lái)自(zì)不(bù)同的調用堆棧,甚至在不(bù)同的線程上,某些東西(例如,h())将決定恢複該協程的執行(xíng)。例如,當異步 I/O 操作完成時。
恢複協程的函數調用一個void resume(handle)函數來(lái)恢複協程的執行(xíng)。對于調用者來(lái)說,這(zhè)看起來(lái)就(jiù)像對void帶有單個參數的返回函數的任何其他正常調用一樣。
這(zhè)将創建一個新的堆棧幀,記錄調用者的返回地(dì)址 resume(),通(tōng)過将其地(dì)址加載到寄存器中來(lái)激活協程幀,并x()在協程幀中存儲的恢複點恢複執行(xíng)。
STACK REGISTERS HEAP
+----------------+ <-+
| x() | | +--> +-----------+
| ret= h()+0x87 | | +------+ | | x() |
+----------------+ +--- | rsp | | | a = 42 |
| h() | +------+ | | b = 789 |
| handle | | rbp | ------+ +-----------+
+----------------+ +------+
| ... |
| |
總之
我将協程描述爲一個函數的概括,除了“正常”函數提供的“調用”和“返回”操作之外(wài),該函數還具有三個附加操作——“挂起”、“恢複”和“銷毀”。
我希望這(zhè)能爲如何思考協程及其控制(zhì)流提供一些有用的心理框架。
在下一篇文章中,我将介紹 C++ 協程 TS 語言擴展的機制(zhì),并解釋編譯器如何将您編寫的代碼轉換爲協程。