老協程,新協程?
人們離開協作式多任務操作系統已經很多年了,但是很多時候的一些言論,對協程的認識,依舊提留在很多年前的協程中。
協程的好處有哪些? - vczh的回答 - https://www.zhihu.com/question/20511233/answer/73755582
如何理解協程? - vczh的回答 - https://www.zhihu.com/question/23290260/answer/24128791
一個技術群里的群友「....你回調函數傳遞下去就好了呀」
有一種很樸素的認識,就是認為協程僅僅是對狀態機的封裝和簡化。把藏在閉包或者別的函數代碼里的「函數A的完成回調代碼」,提升到和A並列的大括弧內。
即把A([](){
balbalbal;
});
變成
co_await(A);
balbalbal;
如果協程僅僅是只做這點事情,毫無疑問,他確實僅僅是一種狀態機+上下文延遲銷毀(閉包那種裡面想要訪問A調用者上下文的局部變數必須進行閉包拷貝,換成co_await那種會把這種局部變數保存在堆里而避免因為func返回而提前銷毀,因此不需要拷貝了)的封裝。目前c++的協程提案,C#的async/await 語義,都是這類。
難道還不止這些嗎?
假設d需要等待thread a和b和c完成之後才能執行(待會兒我們會把thread 變成coroutine 先別著急), 用回調的話,代碼會這樣變態
bool a_f = false; bool b_f = false;bool c_f = false;//-----------------------------------------void func(){a([&](){ if(b_f && c_f){ d(); } else{ a_f = true; } });b([&](){ if(a_f && c_f){ d(); } else{ b_f = true; }});c([&](){ if(b_f && a_f){ d(); } else{ c_f = true; }});}
注意上面這段代碼塊,a_f b_f c_f 如果他們不是全局變數 在哪裡構造 哪裡析構還是一個挺麻煩的事情(當然你也可以用new 來分配a_f b_f c_f,這樣就可以按值捕獲了。。)。
假設說d不止等待a,b,c 而是變成等待10個玩意都完成了之後才能執行。。。撲通撲通,上面的代碼規模先從3變到10, 再把等待同步變數x_f增大到10個。。假設產品又說d完成之後需要調用g, g又要一部分當前上下文局部變數做函數參數不方便放到d實現的末尾去調用,於是又到10個回調閉包里去增加對g的調用。。。快死人了吧。。
有聰明人站出來說 「等等,我們可以用等待計數的方法來迴避這麼多等待同步變數。」
int f;//-----------------------------------------void func(){f=3;a([&](){ if(--f==0){ d(); }});b([&](){ if(--f==0){ d(); }});c([&](){ if(--f==0){ d(); }});}
這個計數方案成功的把「多路聚合完成判定」給收攏了,不再需要寫那堆很容易出錯的布爾變數判定if表達式了。然後我們還意外的發現,其實閉包里的代碼都一樣的了,我們可以進一步簡化
int f;//-----------------------------------------void func(){f=3;auto x = [&](){ if(--f==0){ d(); }};a(x);b(x);c(x);}
代碼瞬間清爽許多 想要實現上面的3->10 或者增加一個g這樣的需求也簡單多了。
還剩下最後一個問題,f的生命周期應該如何管理呢?顯然把他弄成全局變數而使得func不可重入是不可接受的。而把f 放到func里做一個局部變數,他又會因為func的很可能在a,b,c返回之前提前返回而被銷毀掉,從而導致讓x訪問到一個非法引用。
深入一點:生命周期之所以是個問題,關鍵就在於c++是個沒有垃圾回收器的語言。毫無疑問,這裡的f 他應該屬於func上下文的一部分,生命周期應該給func來管理,然而抱歉func是一個無狀態的函數,是個靜態的代碼地址,,,那麼我們把func賦予某種「狀態性」。
解決方案很簡單,我們把func 變成一種「類」F,然後對這個類的非靜態成員函數F::func的調用,必須得先創建一個對象,然後f是這個對象的非靜態數據成員。這樣f的生命周期就留給func的調用者去管理了,由調用者觀察d的完成來銷毀f(所在的對象)。
struct F{ int f; void func(){f=3;auto x = [&](){ if(--f==0){ d(); }};a(x);b(x);c(x);}}func_obj;
由調用者來觀察d有無完成不免太low了(忙詢,等鎖?),我們可以讓d完成後主動反調用調用者的後續流程。所以需要F增加一個調用者函數的函數指針。
struct F{ int f; void (*caller_next_func)(); void func(){f=3;auto x = [&](){ if(--f==0){ d(); caller_next_func(); }};a(x);b(x);c(x);}}func_obj;
等等,你被調用者都從一個函數變身成一個有狀態的」函數「對象,憑什麼我調用者還只能是沒狀態的一個函數指針呢?我還得要區分調用或者沒調用過你這個func_obj呢。。好 F再加一個調用者上下文指針 作為自己的成員變數。
struct Context{///balbaba};struct F:public Context{ int f; void (*caller_next_func)(Context*); Context* caller_pointer; void func(){f=3;auto x = [&](){ if(--f==0){ d(); caller_next_func(caller_pointer); }};a(x);b(x);c(x);}}func_obj;
注意上面我們有意讓F也去繼承了Context,原因很簡單:別人等待我func, 我func不也是在等待a,b,c嗎? 為什麼我不能享受和我的調用者一樣的待遇?
於是最終的協程Cort,也就是上面說的調用者Context持有者和等待計數持有者就這樣出爐了:這裡所有的調用者和被調用者都是協程,上面提到的x也自然的變成了caller_next_func。
struct Cort;struct Cort{ int wait_count; void (*caller_next_func)(Cort*); Cort* caller_pointer; Cort():caller_pointer(0){ } void on_finish(){ if(caller_pointer != 0 && --caller_pointer->wait_count==0 ){ caller_pointer->caller_next_func(caller_pointer); } }};#include <stdio.h>//This coroutine does not await others(leaf coroutine), so its codes are regular.struct E:public Cort{ void func(){ printf("Hello cort!
"); on_finish(); }};struct F:public Cort{ E a; E b; E c; void func(){struct dummy{struct state0:public F{void func_begin(){ wait_count = 3; caller_next_func = state1::func_begin_static; a.caller_pointer = this;a.func(); b.caller_pointer = this;b.func(); c.caller_pointer = this;c.func(); }};struct state1:public F{void func_begin(){ printf("Hello cort second caller!
"); }static void func_begin_static(Cort* arg){return ((state1*)(arg))->func_begin();}}; }; ((dummy::state0*)(this))->func_begin(); on_finish(); }};struct G:public Cort{ F func_obj; void func(){struct dummy{struct state0:public G{void func_begin(){ wait_count = 1; caller_next_func = state1::func_begin_static; func_obj.caller_pointer = this;func_obj.func(); }};struct state1:public G{void func_begin(){ printf("Hello cort first caller!
"); }static void func_begin_static(Cort* arg){return ((state1*)(arg))->func_begin();}}; }; ((dummy::state0*)(this))->func_begin(); on_finish(); }};int main(){ G g; g.func();}
觀看代碼運行結果在此:C++ code - 62 lines - codepad
咳咳,是不是覺得上面的代碼很混亂啊,如果我用宏來把他們規約一下,馬上就很清晰了。
限於篇幅和避開一些難以理解的宏定義,這裡就不寫那些複雜的宏技巧了,也就不貼宏定義代碼了。
struct Cort;struct Cort{ int wait_count; void (*caller_next_func)(Cort*); Cort* caller_pointer; Cort():caller_pointer(0){ } void on_finish(){ if(caller_pointer != 0 && --caller_pointer->wait_count==0 ){ caller_pointer->caller_next_func(caller_pointer); } }};#include <stdio.h>//This coroutine does not await others(leaf coroutine), so its codes are regular.struct E:public Cort{ void func(){ printf("Hello cort!
"); on_finish(); }};struct F:public Cort{ E a; E b; E c; typedef F this_type; void func(){ CO_BEGIN CO_AWAIT_ALL(a,b,c); printf("Hello cort second caller!
"); CO_END }};struct G:public Cort{ F func_obj; void func(){ CO_BEGIN CO_AWAIT(func_obj); printf("Hello cort first caller!
"); CO_END }};int main(){ G g; g.func();}
以上即是鄙人新作cort_proto yuanzhubi/cort_proto 的主要的一些設計思路和實現的展開,可參見
https://github.com/yuanzhubi/cort_proto/blob/master/cort_proto.hcort_proto v0.9.0 第一版發布
當然實際代碼免不了複雜許多。而且葉子協程也不需要手寫那個on_finish調用。
總結:
- 傳統的無棧協程co_await(包括現在C++20那個協程提案)只能等待一個別的協程而不能等待多個。而後者這種情況直接翻譯成傳統的狀態機非常複雜(直接拿給編譯器去實現也很複雜,而且會摧毀很多語法假設)。事實上,很多有棧協程方案等待多個別的協程方案也並不trivial(例如golang的sync.WaitGroup,注意和go channel的select 進行區分,後者是when any語義)。個人認為,支持直接的多路等待,可以叫做」新協程「,來和傳統的協程進行區分,避免認為協程還是通常意義的狀態機解構。
- 這種樹形的多路等待在cort_proto中通過」等待計數「方案給予了解決。而且只需要C++03。還支持把成員函數定義在類外面。有很多小技巧等待你去發現。
- 多路等待在實際中對於提升性能和簡化編程是有意義的,如果葉子協程中包含了IO(利用了內核可以進行並行IO操作的能力)或者其他多核操作(例如創建了線程並間接等待他的完成)。例如你可以同時等待3個redis操作和1個db操作的完成之後給客戶端進行回包,耗時是4個操作的最大值而不是和。
- 除了等待計數,筆者在https://github.com/yuanzhubi/coroutine_proto/blob/master/cort_proto.h 中還曾經嘗試過用鏈表而不是計數的方案來解決多路等待,這種方案的好處在於外部知道父協程在等待哪些子協程(上面計數的方案父協程只記錄了自己在等多少個子協程,子協程才知道自己唯一的父協程是誰),可以在子協程完畢之前斷開父協程和子協程的關係來取消父協程的等待(例如某些ui場景,可以讓用戶考慮選擇結束某個下載,而不一定非得由下載流終止才能結束下載)。然而實際在使用中筆者發現了非常多的問題,這種來自外部的等待中斷不亞於在父協程和子協程之間拋出了一個異常或者信號,需要每一次CO_AWAIT之後都要由使用者檢查是否發生了外部中斷而wait並未實際完成進而寫一些類似異常處理的代碼。綜合性能,使用的友好度來看,筆者最終放棄了這一方案,而選擇了耦合度較低的計數,並且不再允許侵入式的外部取消等待。此類取消等待應該從子協程發起而不是從父協程發起。
- 下一階段 我們會從底層分析下這個協程方案和現在C++的微軟協程提案上的關係。可以看到二者在某些地方高度相似,甚至在觸發某些調試器bug的行為上都一模一樣。
(簡單突破知乎二維碼識別的「知見障」)
推薦閱讀: