原文
了解對稱轉移
協程組提供了個編寫異步代碼
的絕妙方法,與同步代碼一樣.只需要在合適地點加上協待
,編譯器就會負責掛起
協程,跨掛起點
保留狀態,并在操作
完成后恢復
協程.
但是,最初有個令人討厭
的限制,如果不小心,很容易導致棧溢出
.如果想避免它,則必須引入額外
同步成本,以便在任務<T>
類型中安全地避免它.
好的是,在2018
年調整了協程的設計
,以添加一個叫"對稱轉移
"的功能,來允許掛起A并恢復B協程,而不消耗額外棧空間
.
此功能解除
了協程組的一個關鍵限制
,并允許更簡單,更高效地
實現異步
協程類型,而不會為棧溢出
付出成本.
本文,試解釋棧溢出
,及"對稱轉移
"如何解決它.
協程工作原理背景
請考慮以下協程:
任務 福(){協中;
}
任務 條(){協待 福();
}
假設有個當另一個
協程等待它時,會懶執行主體,且不支持返回值的簡單
任務類型.
分析條()
計算協待 福()
:
1,條()
協程調用福()
函數.注意,從調用者角度,協程
只是個普通函數.
2,調用福()
執行以下幾個步驟:
1,為(一般在堆上
)協程幀
分配存儲
2,復制
參數到協程幀
中(本例中無參,因此這是無操作).
3,在協程幀
中構造承諾
對象
4,調用承諾.取中()
以取福()
的返回值.這生成返回
的任務對象
,并使用剛剛創建的引用協程幀
的標::協柄
來初化它.
5,在初掛起
(即左大括號
)處掛起
協程
6,返回任務對象
到條()
.
3,接著,條()
協程計算從福()
返回的任務上的協待
式.
1,掛起條()
協程,然后在返回
任務上傳遞引用條()
的協程幀的標::協柄
,來調用掛起協()
方法.
2,然后,在福()
的承諾
對象中,掛起協()
方法存儲條()
的標::協柄
,然后在福()
的標::協柄
上調用.恢復()
來恢復福()
協程.
4,福()
協程同步
執行并運行到完成.
5,在終掛起
(即右大括號
)掛起福()
協程,然后恢復,在啟動前在其承諾
對象中存儲的以標::協柄
標識的即.條()
協程.
6,恢復并連續執行條()
協程,最終到達調用從福()
返回的臨時任務對象的析構器中包含協待
式的語句的末尾.
7,然后,在福()
的協程句柄上,任務
析構器調用.消滅()
方法,然后析構
協程幀及承諾
對象和參數的副本.
好的,簡單調用,似乎步驟太多.
為了幫助更深入
理解,看看使用協程組(不支持對稱轉移)設計實現此任務類
的簡單實現時會怎樣.
任務實現大概
類 任務{
公:類 承諾類型{/*見下*/};任務(任務&&t)無異:協程_(標::交換(t.協程_,{})){}~任務(){如(協程_)協程_.消滅();}類 等待器{/*見下*/};等待器 符號 協待()&&無異;
私:顯 任務(標::協柄<承諾類型>h)無異:協程_(h){}標::協柄<承諾類型>協程_;
};
任務
對與調用協程
時創建的協程幀關聯的標::協柄
有獨占所有權.任務
對象是個可確保任務
對象出域時,在標::協柄
上調用.消滅()
的資取化
對象.
因此,現在擴展一下承諾類型
.
實現任務::promise_type
上篇已知,承諾類型
成員定義
了在協程幀
內創建并控制
協程行為的承諾
對象的類型.
首先,要實現取中()
來構造調用協程
時返回的任務對象
.此方法只要用新創建的協程幀
的標::協柄
初化任務.
可用標::協柄::從承諾()
方法從承諾
對象構建這些句柄.
類 任務::承諾類型{
公:任務 取中()無異{中 任務{標::協柄<承諾類型>::從承諾(*本)};}
接著,期望協程最初
在開大括號
處掛起,以便等待
返回的任務時,可稍后從此恢復
協程.
懶啟動
協程有幾個好處:
1,表明可在開始
執行協程前,附加連續
的標::協柄
.表明不必用線程同步
來仲裁稍后附加連續
和協程運行完成間的競爭
.
2,這表明任務
可無條件地析構協程幀
,不必擔心是否可能在另一個線程上執行協程
,因為在等待它之前不會開始執行協程
,且在它執行
時掛起了調用協程
,因此在完成執行協程
前,不會試調用任務
析構器.
3,這樣,編譯器更好內聯分配
協程幀到調用者幀中.見P0981R0
這里來了解(哈樓)
堆分配優化的更多信息.
4,它還提高了協程
代碼的異常安全性
.如果沒有立即協待
返回的任務,并執行其他可能觸發
異常的操作,從而導致棧展開
和并運行任務析構器
,則可安全
析構協程,因為知道它尚未啟動.
5,沒有分離
,懸掛
引用,析構器
中阻塞,終止
或未定義行為
.我在這里關于結構化并發的CppCon2019
演講中更詳細
介紹的內容.
為了使協程在左大括號處初掛起
,定義了個返回內置總是掛起
類型的初掛起()
方法.
標::總是掛起 初掛起()無異{中{};}
接著,需要定義執行協中
時或在協程結束
時調用的中空()
方法.此方法可無操作,只要有它,編譯器就知道在此協程類型中協中;
有效.
空 中空()無異{}
還要添加如果異常
逃逸協程
時則會調用的對異常()
方法.這里,可按無異
調用任務
協程體,并在有異常時調用標::終止()
.
空 對異常()無異{標::終止();}
最后,執行協程
到達右大括號時,期望在終掛起
點掛起協程
,然后恢復
連續,即等待
此協程完成的協程
.
為此,需要在承諾
中的數據成員
保存連續的標::協柄
.還需要定義,返回
在當前協程在終掛起
點掛起后恢復連續
的可等待
對象的終掛起()
方法.
在掛起當前協程
后,懶恢復
連續非常重要,因為連續
可能會立即調用,在協程幀
上調用.消滅()
的任務析構器
.
.消滅()
方法僅對掛起
協程有效,因此在掛起當前協程
前,恢復
連續是未定義行為
.
編譯器在右大括號
處,插入代碼來計算協待承諾.終掛起();
語句.
注意,調用終掛起()
方法時,尚未掛起協程
.掛起
協程前,需要等到調用
返回的可等待
的掛起協()
方法.
構 止等待器{極 直接協()無異{中 假;}空 掛起協(標::協柄<承諾類型>h)無異{//現在在終掛起點掛起`協程`.在承諾中查找`連續`并恢復它.h.承諾().連續.恢復();}空 恢復協()無異{}};止等待器 終掛起()無異{中{};}標::協柄<>連續;
};
好的,這就是完整的承諾類型
.最后需要實現任務::符號 協待()
.
實現任務::符號 協待()
在理解協待()
帖子中這里,在計算協待
式時,(如果定義了協待符號,)編譯器生成調用協待()
符號,然后返回對象
必須定義直接協(),掛起協()
和恢復協()
方法.
當協程
等待任務時,期望總是掛起等待協程
,然后,一旦掛起,在要恢復
的協程的承諾
中存儲
等待協程的句柄
,然后在任務的標::協柄
上調用.恢復()
來開始執行任務.
因此,相對直接代碼:
類 任務::等待器{
公:極 直接協()無異{中 假;}空 掛起協(標::協柄<>連續)無異{//在任務的`承諾`中存儲`連續`,以便在任務完成時,`終掛起()`知道`恢復`此協程.協程_.承諾().連續=連續;//然后恢復當前在`初掛起`(即在左大括號處)掛起的任務的協程.協程_.恢復();}空 恢復協()無異{}
私:顯 等待器(標::協柄<任務::承諾類型>h)無異:協程_(h){}標::協柄<任務::承諾類型>協程_;
};
任務::等待器 任務::符號 協待()&&無異{中 等待器{協程_};
}
從而完成任務
類型必要代碼.
棧溢出問題
但是,當你在協程中開始
寫循環
,且協待
可在該循環體
中同步
完成的任務
時,就會出現實現限制
.
如:
任務 同步完成(){協中;
}
任務 同步循環(整 數){對(整 i=0;i<數;++i){協待 同步完成();}
}
上述簡單
任務實現,計數為10
,1000
甚至100'000
時,同步循環()
函數(可能)正常
工作.但是,可能會傳遞一個值(如100萬)時,會導致此協程
崩潰.
崩潰的原因是棧溢出
.
為什么會導致棧溢出?
首次開始執行同步循環()
協程時,可能是因為其他協程在協待
返回的任務.這會依次
掛起等待協程并調用在任務的標::協柄
上調用恢復()
的任務::等待器::掛起協()
.
因此,啟動同步循環()
時,棧將如下:
棧 堆
+-------------+<--棧頂+------
|同步循環$恢復|活動協程循環幀|
+-------------+|+---------
|協柄::恢復|| |任務::承諾
+-------------+|-連續--.||
|任務::等待器::掛起協||+--
+-------------+|... |v
|等待協程$恢復|+-----------
+-------------+|等待協程幀|
//`連續`指向`等待協程幀`
注意:編譯
協程函數時,編譯器一般會將其拆分
為兩部分:
1,處理協程幀的構造
,復制參數
,構造承諾
和生成返回值
的斜坡
函數",及
2,包含協程體用戶編寫的"協程體
"邏輯.
用$恢復
后綴來表明協程
的"協程體
"部分.
然后,當同步循環()
等待從同步完成()
返回的任務
時,掛起當前協程
并調用任務::等待器::掛起協()
.
然后,掛起協()
方法,在與同步完成()
協程關聯的協程句柄
上調用.恢復()
.
這恢復了同步運行完成的同步完成()
協程,并在終掛起
掛起.然后,它調用,與同步循環()
關聯的協程句柄
上調用.恢復()
的任務::承諾::止等待器::掛起協()
.
最終
結果是,如果在恢復同步循環()
協程后及,在分號處析構同步完成()
返回的臨時任務
前查看程序狀態
,則棧/堆
應該像這樣:
棧 堆
+-------------棧頂
|同步循環$恢復|活動協程指向`上個頂`
+-------------+|
|協柄::恢復|.------'
+-------------+|
|止等待器::掛起協||
+-------------+|+-
|同步完成$恢復|||同步完成幀||
|協柄::恢復 ||+----------+|
|任務::等待器::掛起協|V|
+------------+<--上個棧頂+-+|
|同步循環$恢復| |同步循環幀||
+------------+|+----------------------+||
|協柄::恢復|||任務::承諾|||
+------------+||-連續--.|||
|任務::等待器::掛起協||+--|---+||
+------------+|-任務臨時指向同步完成幀
|等待協程$恢復|+-----------
+-------------+|等待協程幀|
接著是調用
析構同步完成()
幀的任務析構器
.然后,遞增計數
變量并再次循環,創建一個新的同步完成()
幀并恢復它.
事情是最終同步循環()
和同步完成()
遞歸地相互
調用.每次都消耗
更多的棧空間
,最終,溢出棧
并進入未定義行為
狀態,導致程序立即崩潰.
這樣構建的協程
中,非常容易編寫循環
并造成無限遞歸
.
協程組解決方法
好的,如何避免無限遞歸
上面實現中,使用返回空
的掛起協()
變體.在協程組中,還有個返回極
的掛起協()
版本,如果它返回真
,則掛起協程
,執行返回到恢復()
的調用者,否則,如果返回假
,則立即恢復
協程,但這次
不消耗額外棧空間
.
因此,為避免無限相互遞歸
,可利用掛起協()
的布爾返回版本,如果同步完成任務
,則通過從任務::等待器::掛起協()
方法返回假
來恢復當前協程
,而不用標::協柄::恢復()
遞歸恢復協程.
為此
實現通用方法,要有兩個部分.
在任務::等待器::掛起協()
方法中,可調用.恢復()
開始執行協程.然后,調用.恢復()
返回時,檢查
是否已完成協程
.
如果已運行完,則可返回假
,來表示應該立即恢復等待協程
,或可返回真
,指示執行應該返回
到標::協柄::恢復()
的調用者.
在運行完協程
時運行的任務::承諾類型:::止等待器::掛起協()
中,要檢查等待協程
是否已從任務::等待器::掛起協()
返回真
,如果是,則調用.恢復()
來恢復它.
否則,需要避免
恢復協程并通知任務::等待器::掛起協()
,它需要返回假
.
但是,還有個額外問題,因為協程
可在當前線程
上開始執行,然后掛起
,然后,在調用.恢復()
之前,在不同
線程上,恢復運行
至完成.
因此,要解決上述第1部分和第2部分
之間同時有的潛在競爭
.
要用標::原子
值來決定競賽
的獲勝者.
現在是代碼
.可如下修改:
類 任務::承諾類型{...標::協柄<>連續;標::原子<極>準備好=假;
};
極 任務::等待器::掛起協(標::協柄<>連續)無異{承諾類型&承諾=協程_.承諾();承諾.連續=連續;協程_.恢復();中!承諾.準備好.交換(真,標::內存序取釋放);
}
空 任務::承諾類型::止等待器::掛起協(標::協柄<承諾類型>h)無異{承諾類型&承諾=h.承諾();如(承諾.準備好.交換(真,標::內存序取釋放)){//未同步完成`協程`,請在此處恢復.承諾.連續.恢復();}
}
表明,c++協程::任務<T>
實現這里為避免無限遞歸
的方法,且運行良好.
嗚呼!問題解決了嗎?
問題所在
雖然上述
方法確實解決了遞歸問題,但它有幾個缺點.
1,首先,它引入了非常昂貴的標::原子
操作.掛起
等待協程時,調用者上有個原子交換
,運行到完成時,調用者
上有另一個原子交換
.
2,如果只在單線程上執行應用
,則即使不必,也支付了同步線程
的原子
操作成本.
3,其次,它引入
了額外的分支
.一個在調用者
中,要決定是掛起
還是立即
恢復協程,另一個在被調
中,要決定是恢復
還是掛起
連續.
4,注意,額外
分支的成本,甚至可能是原子操作
的成本,一般相比協程中的業務邏輯
,相形見絀.然而,按零成本抽象宣傳
協程,有人甚至
使用協程
來掛起函數,以避免等待L1
緩存未命中,這里.
5,第三,可能也是最重要的一點,在等待協程
恢復的執行環境
中引入了一些不確定性
.
假設有以下代碼:
c++協程::靜線程池 tp;
任務 福()
{標::輸出<<"福1"<<標::本線程::取標識()<<"\n";//掛起協程并重新分發到線程池線程.協待 tp.調度();標::輸出<<"福2"<<標::本線程::取標識()<<"\n";
}
任務 條()
{標::輸出<<"條1"<<標::本線程::取標識()<<"\n";協待 福();標::輸出<<"條2"<<標::本線程::取標識()<<"\n";
}
使用原始實現,保證在協待 福()
之后運行的代碼
,在完成福()
的同一線程上內聯運行
.
如,一個可能的輸出是:
條1`1234`
福1`1234`
福2`3456`
條2`3456`
然而,隨著使用原子
,完成福()
可能會與掛起條()
競爭,因此表明,有時協待 福()
之后的代碼
可能會,在條()
開始執行的原始線程
上運行.
如,現在可如下輸出:
條1`1234`
福1`1234`
福2`3456`
條2`1234`
對許多
用例,該行為
可能不會有影響.但是,對旨在轉換
執行環境的算法
,會有問題.
如,通過()
算法等待一些可等待
,然后在指定
分發的執行環境
中生成它.此算法的簡化版本
如下:
元<型名 可等待,型名 調度器>
任務<等待結果型<可等待>>通過(可等待 a,調度器 s)
{動 結果=協待 標::移動(a);協待 s.調度();協中 結果;
}
任務<T>取值();
空 消費(常 T&);
任務<空>消費者(靜線程池::調度器 s)
{T 結果=協待 通過(取值(),s);消費(結果);
}
對原始版本,總是可保證在s線程池
上調用消費()
.但是,對原子版本,可能會在與s
調度器關聯的線程
上執行消費()
,或在消費()
協程開始執行的線程
上執行.
如何無原子操作,額外分支和非確定性恢復環境
的成本的解決棧溢出
?
“對稱轉移”
GorNishanov(0913)
的論文P0R2018"
,提出"對稱協程
控制轉移",來允許不消耗額外棧空間
的,掛起
,A
協程然后對稱
恢復B
協程.
它提出了兩個
關鍵變化:
1,允許從掛起協()
返回標::協柄<T>
,來指示應對稱轉移
執行到由返回的句柄
標識的協程
.
2,添加返回特殊的標::協柄
的標::實驗性::無操協程()
函數,它可從掛起協()
返回該函數以掛起
當前協程,并從調用.恢復()
中返回,而不是執行轉移
到另一個協程.
"對稱轉移
"的意思
在標::協柄
上調用.恢復()
來恢復協程時,執行恢復協程
時,.恢復()
的調用者在棧
上仍活著.
下一次掛起此協程
,且對該掛起點
的掛起協()
調用返回空
(表示無條件掛起)或真
(指示條件掛起
)時,返回調用.恢復()
.
這可類比
協程執行的"非對稱轉移
",其行為與普通的函數調用一樣..恢復()
的調用者可是任一
函數(也可不是協程).
掛起該協程
,并從掛起協()
返回真
或空
時,執行調用從.恢復()
返回,且每次調用.恢復()
恢復協程時,都會創建新棧幀
來執行該協程
.
但是,使用"對稱轉移
",只是掛起
一個協程并恢復
另一個協程.兩個協程
間沒有隱式調用者/被調
關系,掛起協程
時,可把執行
轉移到掛起的任一協程
(包括自身),且在下次
掛起或完成時,不必把執行
轉移回上一個
協程.
看看等待者
使用對稱轉移
時,編譯器降級協待
式為什么:
{推導(動)值=<式>;推導(動)可等待=取可等待(承諾,靜轉<推導(值)&&>(值));推導(動)等待器=取等待器(靜轉<推導(可等待)&&>(可等待));如(!等待器.直接協()){用 句柄型=標::協柄<P>;//<掛起協程>動 h=等待器.掛起協(句柄型::從承諾(p));h.恢復();//<返回到調用者或恢復者>//<恢復點>}中 等待器.恢復協();
}
放大與其他協待
形式不同的關鍵部分:
動 h=等待器.掛起協(句柄型::從承諾(p));
h.恢復();
//<返回調用者或恢復者>
一旦降級
協程狀態機,<返回到調用者或恢復者>
部分基本上變成了返回;
語句,導致調用上次
恢復協程
來返回到其調用者的.恢復()
.
表明從當前函數
自身是標::協柄::恢復()
的調用體,有個調用與標::協柄::恢復()
有相同簽名
函數的另一個函數
,然后是返回;
.
一些編譯器在啟用優化
時,可優化
,只要滿足某些條件,就可把調用轉換為尾調用
.
碰巧,該尾調用
優化正可避免
之前遇見的棧溢出
問題.但是,要保證轉換尾調用
.
尾調用
尾調用
是指在調用
結束前彈出當前棧幀
,且當前函數的返回地址
成為被調
返回地址.即.被調
直接返回此函數
調用者.
在X86/X64
架構上,一般表明編譯器生成
首先彈出
當前棧幀,然后使用跳
指令而不是調用
指令跳轉
到被調
函數入口,然后在調用
返回后彈出
當前棧幀的代碼
.
但是,該優化
一般有限.即,它要求:
1,調用約定
支持尾調用
,且對調用者和被調
相同;
2,返回
類型相同;
3,在調用
后到返回
調用者前,不需要運行非平凡
析構器;及
4,調用不在試/抓
塊內.
協待
的對稱轉移
形式是專門
為協程
滿足所有這些要求
而設計的.
1,調用約定,當編譯器降級
協程為機器代碼
時,它將協程
分為兩部分:斜坡
(分配和初化
協程幀)和主體
(包含用戶編寫的協程體
的狀態機).
協程的函數簽名
(及用戶指定的調用約定
)僅影響斜坡
,而主體
受編譯器控制,且用戶代碼
永遠不會直接調用它,僅由斜坡
函數和標::協柄:::恢復()
調用.
協程主體
的調用約定
不是用戶可見
的,完全依賴編譯器,因此可選擇支持尾調用
并由所有協程體
使用的適當調用約定
.
2,返回類型
相同,源和目標
協程的.恢復()
方法的返回類型
都是空
,因此可輕松
滿足此要求.
3,沒有非平凡
析構器,尾調用
時,要可在調用
目標函數前釋放
當前棧幀,這要求所有棧分配
對象生命期
在調用
前結束.
一般,只要域
內有非平凡
析構器的對象,就有問題
,因為這些對象
的生命期尚未結束,且在棧上
分配這些對象.
但是,掛起協程
時,它會在不退出
域時就這樣,它是,在協程幀
中而不是在棧
中保存生命期跨掛起點
的對象.
可在棧上
分配生命期不跨掛起點
的局部變量
,但這些對象
生命期已結束,且在下一次掛起協程
前調用
它們的析構器
.
因此,對要在尾調用
返回后運行的棧分配對象
,不應有非平凡
析構器.
4,調用不在試/抓
塊內,這有點麻煩,因為在每個協程
中都有個隱式的包含用戶
編寫協程體的試/抓
塊.
從規范中,看到協程定義:
{承諾類型 承諾;協待 承諾.初掛起();試{F;}抓(...){承諾.對異常();}
終掛起:協待 承諾.終掛起();
}
其中F是協程體
用戶部分.
因此,每個用戶
編寫的協待
式(初掛起/終掛起
式除外)都在試/抓
塊的環境中.
但是,實現通過在試
塊環境外實際調用.恢復()
來解決.
因此,執行對稱轉移
的協程,一般滿足可執行尾調用
的所有要求.無論是否啟用
優化,編譯器保證
總是一個尾調用
.
這表明用掛起協()
的標::協柄
的返回風格,可掛起
當前協程,并在不會消耗額外棧空間
時,就把執行
轉移到另一個協程
.
這樣允許編寫相互遞歸
地恢復
彼此到任意深度
,而不必擔心棧溢出
的協程.
重新審視任務
因此,借助新"對稱轉移
"功能,修復任務
類型實現.
為此,要在實現中更改兩個掛起協()
方法:
1,首先,等待
任務時,執行對稱轉移
來恢復任務的協程.
2,其次,任務
的協程完成時,執行對稱轉移
以恢復等待協程
.
為了解決等待
方向,需要在此更改任務::等待器
方法:
空 任務::等待器::掛起協(標::協柄<>連續)無異{//在任務的承諾中`存儲`連續,以便在任務完成時,`終掛起()`知道恢復此協程.協程_.承諾().連續=連續;//然后恢復當前在初掛起(即在左大括號處)掛起的`任務協程`.協程_.恢復();
}
轉為:
標::協柄<>任務::等待器::掛起協(標::協柄<>連續)無異{//在任務的承諾中`存儲`連續,以便在任務完成時,`終掛起()`知道恢復此協程.協程_.承諾().連續=連續;//然后,從`掛起協()`返回其句柄,來`尾恢復`當前在初掛起(即在打開的大括號處)掛起的`任務協程`.中 協程_;
}
為了解決返回路徑
,需要從下面
更新任務::承諾類型::止等待器
方法:
空 任務::承諾類型::止等待器::掛起協(標::協柄<承諾類型>h)無異{//現在在終掛起點掛起`協程`.在承諾中查找其`連續`并恢復它.h.承諾().連續.恢復();
}
為:
標::協柄<>任務::承諾類型::止等待器::掛起協(標::協柄<承諾類型>h)無異{//現在在終掛起點掛起`協程`.在承諾中查找其`連續`并`對稱`恢復它.中 h.承諾().連續;
}
現在有個既沒有空
返回掛起協
風味所有的棧溢出
問題,也沒有布爾
返回掛起協
風味的不確定性
恢復環境問題的任務
實現.
可視化棧
現在再看看原始示例:
任務 同步完成(){協中;
}
任務 同步循環(整 數){對(整 i=0;i<數;++i){協待 同步完成();}
}
首次開始執行同步循環()
協程時,這是因為其他某個協程協待
返回的任務.調用標::協柄::恢復()
來恢復的其他協程
,會對稱轉移
來啟動它.
因此,啟動同步循環()
時,棧將如下:
棧 堆
+-------------+<--棧頂+-- |
|同步循環$恢復|活動協程->|同步循環幀|
+-------------+|+--------+|
|協柄::恢復|||任務::承諾||
+-------------+||-連續指向下面的等待協程幀||等待協程幀|
現在,執行協待 同步完成()
時,它會對稱轉移
到同步完成
協程.
它如下完成:
1,調用返回任務::等待器
對象的任務::符號 協待()
2,然后掛起并調用返回同步完成
協程的協柄
的任務::等待器::掛起協()
.
3,然后尾調用/跳轉
到同步完成
協程.這在激活同步完成
幀前,彈出同步循環
幀.
如果現在在恢復同步完成
后查看棧,將是如下:
棧 堆.->+-------+<-.||同步完成||||幀||||+--------+|||||任務::承諾||||||-連續--.|||||+--------+|||-,+-------+||V|
+---------------+<--棧頂|++ |
|同步完成$恢復|||同步循環幀||
+---------------+活動協程---|++||
|協柄::恢復|||任務::承諾|||
+---------------+||-連續--.|||
|...||+----------|---+||
+---------------+|任務臨時||||-協程_-----|---------|+--------------------------+|等待協程 幀|+--------------------------+
注意,此處棧幀數
沒有增加.
在同步完成
協程完成且執行到達右大括號
后,它計算協待 承諾.終掛起()
.
這會掛起協程,并調用返回連續
的標::協柄
(即指向同步循環
協程的句柄)的止等待器::掛起協()
.
然后,執行對稱轉移/尾調用
以恢復同步循環
協程.
如果在恢復同步循環
后查看棧,將如下:
棧 堆+--------------+<-.|同步完成|||幀|||+----------+||||任務::承諾|||||-連續--.||||+------------------|---+||V|
+----------------+<--棧頂
|同步循環$恢復|活動 協程->|同步循環 幀||
|協柄::恢復()|||任務::承諾|||
+--------------+||-連續--.|||
|...||+--------+||
+--------------+|任務 臨時||||-協程_-----|---------||等待協程 幀|
恢復同步循環
協程后,先是在到達分號時執行
調用從同步完成
調用返回的臨時任務
的析構器
.它析構
協程幀,釋放
其內存,剩下:
棧 堆
+-----------+<--棧頂+--+
|同步循環$恢復|活動協程->|同步循環幀|
|協柄::恢復|||任務::承諾||
+-----------------+||-連續--.|||等待協程幀|
現在又回到了執行同步循環
協程,現在擁有與開始
時相同數量的棧幀和協程幀
,且每次循環
時都會這樣做.
因此,可按需
任意次迭代循環
,且只會使用固定大小
存儲空間.
對稱轉移為掛起協
的通用形式
對稱轉移
理論上可取代掛起協()
的空和布爾
返回形式.
但先看看添加到協程設計中的P0913R0
提案這里的另一部分:標::無操協程()
.
終止遞歸
使用對稱轉移
協程,每次掛起協程
時,都會對稱
地恢復另一個協程
.只要有另一個要恢復的協程
,這很好,但有時沒有要執行的另一個協程
,只需要掛起
并讓執行返回至標::協柄::恢復()
的調用者.
掛起協()
的空
返回和布爾
返回風格都允許掛起協程
并從標::協柄::恢復()
返回,但如何對對稱轉移
這樣?
答案是使用特殊的由標::無操協程()
函數生成的叫"無操
協程句柄"的內置標::協柄
.
"無操
協程句柄"命名,是因為它的.恢復()
實現僅使它立即
返回.即是無操作恢復
協程.一般,它的實現包含單個中
指令.
如果掛起協()
方法返回標::無操協程()
句柄,則它不會把執行轉移
到下個協程,而是把執行傳輸回標::協柄::恢復()
的調用者.
表示await_suspend()
的其他風格
有了這些信息,現在可展示如何使用對稱轉移
形式來表示掛起協()
的其他風格.
空返回
:
空 我等待器::掛起協(標::協柄<>h){本->協程=h;入列(本);
}
也可用布爾
返回形式如下編寫:
極 我等待器::掛起協(標::協柄<>h){本->協程=h;入列(本);中 真;
}
也可用對稱轉移
形式編寫:
標::無操協程句柄 我等待器::掛起協(標::協柄<>h){本->協程=h;入列(本);中 標::無操協程();
}
布爾
返回形式:
極 我等待器::掛起協(標::協柄<>h){本->協程=h;如(試開始(本)){//異步完成操作.返回`真`以把執行`轉移`到`協柄::恢復()`的調用者.中 真;}//同步完成`操作`.返回`假`可立即恢復當前協程.中 假;
}
也可用對稱轉移
形式編寫:
標::協柄<>我等待器::掛起協(標::協柄<>h){本->協程=h;如(試開始(本)){//異步完成操作.返回`標::無操協程()`以把`執行`轉移到`協柄::恢復()`的調用者.中 標::無操協程();}//操作同步完成.返回`當前協程`句柄以`立即恢復`當前協程.中 h;
}
為什么要有三個風格?
擁有對稱轉移
風格時,為什么仍有掛起協()
的空和布爾
返回風格呢?
原因部分是歷史
的,部分是務實
的,部分是性能
的.
空
返回版本可通過從掛起協()
返回標::無操協程句柄
類型來完全替換
,因為這是編譯器表明協程
無條件地把執行轉移
到標::協柄::恢復()
的調用者的等效
信號.
部分是在引入對稱轉移
前已使用它,部分原因是空
形式導致無條件
掛起時,代碼/鍵入
更少.
然而,與對稱轉移
形式相比,布爾
返回版本有時在可優化性
方面可能會略有優勢.
考慮在另一個
翻譯單元中定義的布爾
返回掛起協()
方法.此時,編譯器可在掛起
當前協程的等待協程
中生成代碼,然后調用掛起協()
返回后通過執行下一段
代碼有條件地恢復
它.
如果掛起協()
返回假
,它確切
地知道要執行的代碼段.
而對稱轉移
風格,仍需要或返回到調用者/恢復
或恢復
當前協程,來表示相同
結果.或需要返回標::無操協程()
或當前協程
句柄,而不是返回真
或假
.
可強制轉換
這兩個句柄為標::協柄<空>
類型并返回
它.
但是,現在,因為掛起協()
方法是在另一個
翻譯單元中定義的,編譯器無法看到返回句柄
引用的協程
,因此恢復
協程時,它現在必須執行一些更昂貴的間接調用
,且可能執行一些分支
來恢復協程,再對比布爾
返回的單個分支
.
未來可能可內聯定義掛起協()
,但調用遠方定義的布爾
返回方法,然后有條件
地返回適當的句柄.
如:
構 我等待器{極 直接協();//理論上,編譯器應該可按與`布爾`返回版本相同的優化,但目前沒有.標::協柄<>掛起協(標::協柄<>h){如(試開始(h)){中 標::無操協程();}異{中 h;}}空 恢復協();
私://此方法在遠方單獨的翻譯單元中定義.極 試開始(標::協柄<>h);
}
因此,目前而言,一般
規則是:
1,如果需要無條件
返回.恢復()
調用者,請使用空
返回風格.
2,如果需要有條件
返回到.恢復()
調用者或恢復當前協程,請使用布爾
返回風格.
3,如果需要恢復
另一個協程,請使用對稱轉移
風格.
添加到C++20
的協程中的新對稱轉移
功能使得不必擔心棧溢出
的,編寫遞歸
相互恢復
協程更加容易.此功能是創建高效且安全
的異步協程
類型(如任務
)的關鍵.