async/await非同步模型是否優於stackful coroutine模型?

這裡說的coroutine是stackful的,可以對比js的async/await和fibjs,還有c#的async/await對比go的goroutine。

async/await的優點是不需要為每個coroutine分配單獨的棧內存,只需要一個閉包保存狀態,避免了內存浪費;在調度上也更加靈活,可以讓用戶手動調度;另外在實現上也不需要改動虛擬機,只要在語言層面加上generator的支持就可以實現。那麼是不是可以認為async/await是一個更好的非同步方案呢?


問題不能割裂地看。首先,一個充分遞歸的函數無論如何都是需要一個stack來運行的,不管這個東西放在哪裡,你都要有一個這樣的結構。如果你寫了一個async函數,在一個分支里遞歸調用了自己兩次,儘管看起來還是狀態機,但是實際上這個stack隱含在了Task類的依賴結構裡面。所以是一樣的。

我以前問過Don Syme,我說反正這個東西就是不能完全消除的,那為啥不幹脆做成continuation(就像普通的coroutine一樣)?人家說,少一點是一點,大部分async函數都不是遞歸的,這樣就可以處理成狀態機,少了很多閉包的開銷。

聽起來其實也很有道理——只要這個await不是IO的await,的確可以顯著地降低開銷。

論靈活,事實上永遠是continuation最靈活。你看C#早期的await還不能放在try-catch裡面呢,雖然現在給做出來了。其實這東西實現起來根本沒有難度,所以我猜是因為Visual Studio的調試器支持的問題,導致一開始砍掉了。

幾個月前寫過一篇文章,是在說當初給GacUI的腳本加上C#這種狀態機型的coroutine的過程。這個功能跟C#的區別是,你可以自己提供coroutine的實現,自己提供像await和yield這樣的名字,然後編譯器替你把函數進行改寫。這樣你就可以根據需要創造自己的名字,而不用把什麼都寫成await和yield,不直觀。

這東西其實跟VC++新出的co_await的功能是幾乎一致的。

考不上三本也能給自己心愛的語言加上Coroutine(一)

考不上三本也能給自己心愛的語言加上Coroutine(二)

考不上三本也能給自己心愛的語言加上Coroutine(三)

考不上三本也能給自己心愛的語言加上Coroutine(四)


我們要先想清楚:問題是什麼

當代碼遇到一個「暫時不能完成」的流程時(例如建立一個tcp鏈接,可能需要5ms才能建立),他不想阻塞在這裡睡眠,想暫時離開現場yield去干點別的事情(例如看看另外一個已經建立的鏈接是否可以收包了)。問題是:離開現場後,當你回來的時候,上下文還像你走的時候嗎?

跳轉離開,在任何語言里都有2種最基本的方法:1)從當前函數返回; 2)調用一個新的函數。 前者會把上下文中的局部變數和函數參數全部摧毀,除非他返回前把這些變數找個別的地方保存起來;後者則能保護住整個上下文的內存(除了協程切換後會摧毀一些寄存器),而且跳轉回來也是常規方法:函數返回。

async/await和有棧協程的區別就在於,在這裡分別選用了這2種方法:

前者(async/await)在函數返回前把那些變數臨時保存在堆的某個地方,然後把存放地址傳回去,當你想返回現場的時候,把這些變數恢復,並跳轉回離開時候那個語句;持有指針語義的c/c++語言則略麻煩:因為可能這些局部變數中有誰持有另一個局部變數的地址,這樣「值語義」的恢復就會把他們變成野指針,所以需要在進入函數時所有的局部變數和函數參數都在堆上分配,這樣就不會有誰持有離開時棧上下文的指針了,換句話說,對c/c++來說,這是一種無棧協程(有些自己寫的無棧協程庫提供你在堆上面分配局部變數的介面,或者強迫你在進入這個函數前把要用到的所有局部變數在堆上面分配好內存)其它語言只要沒有值語義或變數天生不放棧上就沒這個概念。如果使用閉包語法返回現場,可以只需要恢復閉包中捕獲的變數;對於c++,在離開現場時不能提前析構掉那些沒有被捕獲的變數(否則析構順序未必是構造順序的反序,其實這個c++規則真是沒必要)。所以從C++的觀點來說,這是一種徹頭徹尾的「假」函數返回(有垃圾回收器的語言倒是有可能走到async之後的語句後,回收前面已經不用的臨時變數)。

後者(有棧協程)在離開前只需要把函數調用中可能被破壞的callee-saved 寄存器給保存在當前棧就完事了(別的協程和當前協程棧是完全隔離的,不會破壞自己堆棧),跳轉回來的時候把在棧中保存的寄存器都恢復了並跳轉回離開時候那個語句就行了。

綜上:前者(尤其是c、c++)需要編譯器的特殊支持,對使用了async/await語義的函數中的局部變數的分配,恢復進行些特殊的處理;後者則只需要寫寫彙編就搞定了(一般需要給 進入協程入口函數,協程間切換,協程函數入口函數返回後回收協程資源並切換去另一個協程 這3個地方寫點彙編,也有的協程庫把這3種情況都統一起來處理)。

誰優誰劣呢?

語法友好度:衡量這個玩意兒的標準,莫過於「邏輯聚合性」:邏輯相關的代碼能否寫在相近的代碼處。例如 redis/nginx中處處可見這種上下文被分割的代碼,因為任何一個「暫時不能完成「的場景都會把場景前後代碼邏輯寫在完全不同的兩個函數里。。對於async/await 或無棧協程語義,c/c++在沒有閉包之前的,還需要達夫設備跳轉回離開現場的那行代碼,有了閉包之後,上下文之間就只被return ( [xxx](){ 分開了,代碼可以認為基本沒有被分割( C# 新版js, VC和clang實驗性的resumable function連這點分開都沒有了);不過依然遠遠比不上有棧協程,因為他語法完全是常規的函數調用/函數返回,使用hook之類的手法甚至可以把已有的阻塞代碼自動yield加無阻塞化(參見libco, libgo)。可以認為在這一項:前者在得到現代化編譯器輔助後,和後者相近但依然有差距且容易對一些常識產生挑戰;後者語法非常適合傳統編程邏輯。

時間/空間效率:async/await 語義執行的是傳統的函數調用函數返迴流程,沒有對棧指針進行手工修改操作,cpu對return stack buffer的跳轉預測優化繼續有效;有棧協程需要在創建時根據協程代碼執行的最壞情況提前分配好協程函數的棧,這往往都分配的過大而缺乏空間效率,而且在協程間切換的時候手工切換棧,從而破壞了return stack buffer跳轉預測,協程切換後函數的每一次返回都意味著一次跳轉預測失效,所以流程越複雜有棧協程的切換開銷越大(非對稱調度的有棧協程會降低一些這方面的開銷,boost新版有棧協程徹底拋棄了對稱協程)。對於async/await 語義實踐的無棧協程,如果允許提前析構不被捕獲的C++變數,或者你返回前手工銷毀或者你用的是帶垃圾回收器的語言,空間效率會更佳。 可以認為在這一項:前者遠勝後者,而且後者會隨著你業務複雜度加深以及cpu流水線的變長(還好奔4過後的架構不怎麼漲了)而不斷變差。筆者寫的yuanzhubi/call_in_stack yuanzhubi/local_hook, 以及一個沒有開源的jump assembler(把有棧協程切換後的代碼輸出的彙編語句中的ret指令全部換成pop+jmp指令再編譯,避開return stack buffer 預測失敗)都是來優化有棧協程在時間/空間的表現的 。

調度:其實2者都是允許用戶自己去管理調度事宜的,不過前者必須返回由調度函數選擇下一個無棧協程的切入,後者允許」深度優先調度「:即當某個協程發現有「暫時不能完成「的場景時自己可以根據當前場景選擇一個邏輯相關的協程進行切入,提升內存訪問局部性,不過這對使用者的要求和業務侵入度非常高。。整體而言的話,可以認為在這一項:前者和後者大致持平,前者是集中式管理而後者是分散式管理,後者可以挖掘的潛力更高但對使用者要求很高且未必能適應業務的變更。

結論:性能上,前者有一定時間優勢但不是精雕細琢的多用途公共開源組件完全可以忽略,而空間上前者超越後者很多;易用度上,前者正在快速演進 慢慢的追上後者(c#這樣的async/await鼻祖已經完全不存在這個問題);和已有組件的可結合度上,後者始終保持優勢(不管已有組件是源碼還是二進位)。孰優孰劣,如何側重,如何選擇(如果你們有選擇的機會的話),,也許 純屬你頭兒的口味問題吧 哈哈哈。


看到很多同學提狀態機,,其實這種理解沒有什麼問題,而是人和編譯器的觀點有所不同:人會抽象出很多狀態,在痛苦這些狀態如何在各種上下文跳轉中傳遞和保存(狀態機); 編譯器則在痛苦怪異的上下文跳轉中,局部變數的保存和恢復(無棧協程)。 前者會自行決定某些局部變數是「真的局部」變數,後續無需恢復了;後者會把他們全盤考慮下來,把所有的量都要在各個狀態間傳遞和保存(當然有的語言可以智能些,按需傳遞)。從本質來說,如果是由編譯器來玩狀態機實現的async/await和無棧協程的,概念上沒有什麼區別。 人才說狀態,機器只說變數,內存這些。


不太懂你在說什麼,實際上generator就是有自己獨立的堆棧的……至於閉包,也不見得比generator節約空間啊


async / await 是用於流程式控制制,且只用於表達非同步流程,需要配合其他並發機制,才能發揮作用。coroutine 是並發機制,用於表達概念上同時進行的過程。兩個正交的概念,沒有優劣之分。

換句話說,沒有 coroutine 或者 OS 線程等設施,async / await 就沒用了。還有,在運行期,是沒有 async / await 這個概念的,只有 coroutine 或者 OS 線程,這是根本的不同。

不需要分配棧內存?那非同步的操作,是誰幫你做了?還不是其他並發單元!


自答一波,好多答案里似乎都搞錯了概念,導致了一些無謂的爭議。這裡談一下我的理解。

首先coroutine是個很寬泛的概念,async/await也屬於coroutine的一種。但是問題是拿async/await和stackful coroutine比較。所謂stackful是指每個coroutine有獨立的運行棧,比如每個goroutine會分配一個4k的內存來做為運行棧,切換goroutine的時候運行棧也會切換。stackful的好處在於這種coroutine是完整的,coroutine可以嵌套、循環。

與stackful對應的是stackless coroutine,比如generator,continuation,這類coroutine不需要分配單獨的棧空間,coroutine狀態保存在閉包里,但缺點是功能比較弱,不能被嵌套調用,也沒辦法和非同步函數配合使用進行控制流的調度,所以基本上沒辦法跟stackful coroutine做比較。

但是async/await的出現,實現了基於stackless coroutine的完整coroutine。在特性上已經非常接近stackful coroutine了,不但可以嵌套使用也可以支持try catch。所以是不是可以認為async/await是一個更好的方案?

最後有個匿名用戶死活在哪裡糾結並發需要多線程,這裡我統一做個回復。很多人是從多核時代入行的,看到的非同步框架都是使用了線程池,所以想當然的認為並發必須依賴多線程去處理,更有人連並發和並行的概念都搞混,認為單核CPU就不能並發了。實際上並發這個概念在沒有多核CPU甚至沒有線程的年代(早期的Linux是沒有線程的)就有了。並發一般特指IO,IO是獨立於CPU的設備,IO設備通常遠遠慢於CPU,所以我們引入了並發的概念,讓CPU可以一次性發起多個IO操作而不用等待IO設備做完一個操作再做令一個。怎麼實現呢?原理就是非阻塞操作+事件通知,在核心態非阻塞操作對應的是讀寫埠和DMA,而事件通知則有專門的術語叫中斷響應。過程有2種,一種是IO設備發起中斷告訴CPU現在可以進行IO操作,然後CPU進行相應的操作,還有一種是CPU先發起IO操作,然後IO設備完成處理後發起中斷告訴CPU操作完成。在核心態是不存在多線程這種概念的,一切都是非同步的事件驅動(中斷響應),線程是核心給用戶態提供的高層概念,線程本身也依賴中斷來進行調度。早期的用戶態IO並發處理是用poll(select)模型去輪詢IO狀態,然後發起相應的IO操作,稱之為事件響應式的非同步模型,這種方式並不容易使用,所以又發展出了阻塞式IO操作,讓邏輯掛起並等待IO完成,為了讓阻塞式IO能夠並發就必須依賴多線程或者多進程模型來實現。但是線程的開銷是非常大的,當遇到大規模並發的時候多線程模型就無法勝任了。所以大規模並發時我們又退回去使用事件響應,epoll在本質上還是poll模型,只是在演算法上優化了實現,此時我們只用單線程就可以處理上萬的並發請求了。直到多核CPU的出現,我們發現只用一個線程是無法發揮多核CPU的威力的,所以再次引入線程池來分攤IO操作的CPU消耗,甚至CPU的中斷響應也可以由多個核來分攤執行,此時的線程數量是大致等於CPU的核心數而遠小於並發IO數的(這時CPU能處理百萬級的並發),線程的引入完全是為了負載均衡而跟並發沒有關係。所以不管是用select/epoll/iocp在邏輯層都繞不開基於事件響應的非同步操作,面對非同步邏輯本身的複雜性,我們才引入了async/await以及coroutine來降低複雜性。


async await不是模型,我想你指的應該是stackless的coroutine吧

其實論效率,最高的還是C++那個,編譯器提供協程的語義,而不是其他編譯器那樣去rewrite或者寫死進運行時里,優化過後協程都可以不存在


原是回復在題主自答評論下,既然是題主,就拿出來做答案了。

一個不普適的方案怎麼可能是更好的方案,是否stackless也不是性能差異的關鍵,無非狀態如何保存而已,語義上await既不缺少約束條件,又做不到普適,要來何用?就為特定系統下編程需要?


c#的async await 是通過將 代碼轉化為 狀態機來實現的,而所引用的變數,作為狀態機類內部變數存在,async await 轉化成的狀態機,通過c#虛擬機內部的 Task 調度隊列來執行;

c#中的 generator 和 async await 類似,也是將代碼生成為一個狀態機對象,來供外部調用,但是generator 不支持多個generator的 嵌套, 而 async await 支持 互相嵌套,也就是 stackful stackless 協程之間的區別;

unity中通過改造,在generator的基礎上實現了,stackful 的協程Coroutine,主要是維護了一個 Coroutine的隊列。

lua中的協程coroutine,本質還是一個完整的lua_state, lua_state 中有整個調用的堆棧信息,可以隨時在上次yield的地方繼續執行指令,並且lua實現了尾遞歸優化,來幫助減少堆棧的深度,減少內存消耗。

這兩種協程的stackful的協程 能力上是等價的,不存在誰強誰弱的問題。

區別只在於實現的方式不同,c#需要編譯器足夠強大可以將非同步代碼生成成狀態機代碼;而lua的實現只需要保存運行環境即可。

前者的編譯器複雜,後者的運行時內存佔用會大。

再從並發角度看,c#的async await 結合task的 synchronizationcontext 也可以構建類似於erlang的 actor模式,將相關task 放到同一個線程安全環境中執行;

而lua這種方式,可以將lua虛擬機作為一個環境,而在每個虛擬機內部,將相關的lua協程調度執行。

erlang中因為變數都只是 不可變的,因此可以做process級別的並發。對於可變變數語言,協程級別是不能做安全的並發,並發層次得放在actor層次,actor來管理數據的一致性, golang 的 actor級別,就是 channel 。

協程的發展歷史:

早期只是做單線程的多個上下文切換;後來將協程和線程之間剝離開,一組協程可以跑在一個 上下文下面來操作共同的數據,而這個上下文可以由調度器來靈活調度在空閑的線程上面


說實話就 我 使用下來 感覺是,goroutine+chan+select 這種模式 要優於 async/await

原因 是因為 前者 完整地提供了 一整套並行編程的方案(數據同步 等)。更像一個開放式的流水線。

而 async/await 這種 還不足以與之相提並論。僅僅只是非同步 (一種暗箱,黑盒)。

另外 從思維理解上來說,goroutine的 方式也更加便於理解。而 async/await 其實是有點莫名其妙的。

這裡有一篇駁 老趙的文章:為什麼goroutine和channel不是以類庫的形式存在--駁老趙《為什麼我認為goroutine和channel是把別的... - 推酷

補充:其實知乎上 有不少針對 協程 的討論。

而針對goroutine 也是非常多。其中有不少針對 stackless和stackful 的討論。我個人比較傾向 stackful 。有興趣的知乎朋友 可以一看(知乎 搜索 goroutine)。


第一,c#的async/await,說白了,是task,不只是你所謂的「沒有單獨的棧內存,只要狀態」,每個task都有自己的棧的,但是執行是在線程池裡面的抽線程,所以c#的async/await的task有自己的棧,也稱狀態,是有山下文的,占內存。

其二,從原理上講,都像是那麼回事。但是要論棧纖程,分很多的,go是典型的自己實現,ccpp是有很多庫也是stackfu纖程的。要比的話,要具體點,比如就比c#和go,js就別扯進來了。

其三,從語法上講,這個是我最想說的,並發嘛,有信心的都自己玩輪子,無非就是非同步復用IO介面+線程池那一套,花樣玩的千奇百怪的,自從有纖程開始,人們就發現,纖程真的是個好東西,你不用造論子,纖程的使用也更友好,就比如典型的golang,它完全沒有async/await的東西,golang知道你的代碼哪裡會有IO阻塞,因為底層有函數鉤子,阻塞的時候卸掉纖程掛起另一個,而async/await它是手動的,它甚至告訴你,這個IO介面是async的,你可能需要手動await。當然async/await也能處理非IO阻塞業務,但不是重點。所以就使用角度來說,golang這種玩意兒,好太多。但是golang雖然好,詬病也很多。牛逼點的就用新的c++玩stackful coroutine吧,也不錯。


我一直認為,async/await模型是語言在語言本身的層面不方便實現coroutine協程、卻又需要coroutine協程時的一個無奈的選擇,這類語言通常不經虛擬機執行或者虛擬機在同一線程/進程內無法(或不適合)同時存在多個實例,否則直接控制虛擬機是邏輯和實現都更為簡單的方案,就像Lua那樣。不知道這種理解是否正確,期待更多大牛的答案。

一個例子,某腳本語言實現CoRoutine,一共用了501行代碼(含注釋),我把代碼簡化了一下,去掉TLS 、內存管理和其它一些細節上的代碼,剩下來的大概是這樣子的:

//協程管理器
class CoRoutineManager
{
public:
// 從源代碼創建一個協程,參數:|腳本代碼|入口函數|
void AddCoRoutine(const string code,const string entryName);

// 添加一個協程,參數:要求創建新協程的虛擬機|協程函數|協程函數的參數數組
void AddCoRoutine(VM *currentVM, IScriptFunction *func,IScriptArray& *parameters);

// 執行協程
int Run()
{
VM* vm=m_CoRoutines[m_CurrentCoRoutine];
vm-&>Execute();
if(vm-&>GetState()==VMState::Finished)
{
//協程執行完畢,從協程隊列中刪除
}
}

// 執行下一個協程
void NextCoRoutine()
{
if(m_CoRoutines.size()==0) return;
++m_CurrentCoRoutine;
if(m_CurrentCoRoutine&>=m_CoRoutines.size()) m_CurrentCoRoutine=0;
m_CoRoutines[m_CurrentCoRoutine]-&>Execute();
}

//獲取當前協程數量
size_t GetCoRoutineCount() const {return m_CoRoutines.size();}
protected:
std::vector& m_CoRoutines;
size_t m_CurrentCoRoutine;
};

static CoRoutineManager g_CoRoutineManager;

//供腳本調用的yield函數
static void ScriptYield()
{
// 獲取調用本函數的虛擬機
VM *vm = IScriptEngine::GetActiveVM();
//執行下一個協程
g_CoRoutineManager.NextCoRoutine();
//阻塞當前協程,等待下一次喚醒
vm-&>Suspend();
}

//供腳本調用的創建協程函數
void void ScriptCreateCoRoutine(IScriptFunction *func, IScriptArray& *parameters)
{
if( func == null )
return;

// 獲取調用本函數的虛擬機
VM *vm = IScriptEngine::GetActiveVM();
g_CoRoutineManager.AddCoRoutine(vm,func,parameters);
}

//應用程序
void main()
{
//從文件中載入腳本代碼
string code=LoadCodeFromFile("scriptFile.script");
//為腳本代碼中的main函數創建一個協程
g_CoRoutineManager.AddCoRoutine(code,"main");

//執行協程
while(g_CoRoutineManager.GetCoRoutineCount()&>0)
{
g_CoRoutineManager.Run();
}
}

與C++這類語言相比,基於輕量虛擬機的語言實現CoRoutine機制真的簡單很多。


這倆玩意兒不是配合一起用的媽


兩者模型是一樣的思想。

aaync/await使用系統層與用戶層之間的事件通知,coroutine使用用戶層與用戶層之間的事件通知。

不明白題主說的帶stack的coroutine是什麼。帶stack屬性的只有線程和進程,coroutine本身只是一個可以非同步執行的代碼塊而已。


async/await和stackfull co不是同一層的概念,不能默認前者就是stackless co,c#的也不全然都是,也看到有其他答主提到了幾種工作在不同級別的async/await實現方式。

——不過不糾結了。

在流行語言中,分別只對比c#和go的對應實現來看的話,明顯前者寫著不會更費事,性能卻好的多,具體實現上的細節有其他答主闡明得很詳細了。

題主問這個問題顯然不接受否定的答案,他只是想看看多少人持有相同的看法吧。


先簡單說一下C#和go

1. C#中的async函數被調用時,並不一定非同步執行,取決於awaiter的IsComplete返回值。go語言中,一旦執行go指令,那麼函數中的代碼必將運行於另外的goroutine中。從這一點上來看,C#的async/await更加合理一些,對於特定場景,效率高一些。


答案是優於,問題是實現起來技術難度非常高,幾乎沒有類似的完整案例,但是實現後優化的潛力非常大,我們公司有一個團隊利用async await架構的優勢,在新研發的微線程架構上選擇了stackless微線程架構模型,這種模式完全不需要堆棧的切換,再結合最新的最大並發化的wait free queue(這個結構也是超越這個時代的產品,完全不同於基於cas的lock free體系,性能遠超現有的一切其他跨線程隊列),理論上可實現更高的性能,從底層開始用了一年時間實現了基於c++的stackless微線程,N:M的多線程模式,目前基於此架構的kv資料庫性能已經遠超c語言編寫的memcache redis等的性能,支持微線程任意調度,支持指定線程運行某個微線程,包括線程內調度和跨線程調度,微線程隊列是基於wait_free_queue封裝的,可實現跨線程雙端同時無鎖無等待並發,yield支持五種工作模式,線程內yield三種,一種是同一線程的deque任務包切換,一種是跨線程傳入的wait free queue下一任務包切換,一種是執行boost asio io_service中的async任務,線程間yield有兩種,一種是快速切換,如果沒有其它可執行線程,將在0.1微秒內返回,否則切換線程,一種是10微秒掛起強制切換。wait free queue這塊是公司自研的模板, 大約4000多行代碼,支持任意對象,任意大小,單隊列支持3億tps, 支持多生產者/多消費者無等待並發。整體上網路層async read是boost asio async read,然後重新實現了整個async write部分,參考了dpdk的實現,支持全自動切換輪詢和非同步模式,可提供更好的性能,await部分就是上面說的boost asio io_service的可重入的實現,deque和wait free queue的std::function可重入實現,thread yield,然後就是封裝完整的一個yield函數。


推薦閱讀:

學習 ASP.NET MVC 框架有什麼好的視頻教程或書籍?
目前看來 ASP.NET 中的 Razor (CSHTML) 語言是雞肋還是奇葩?
怎麼讓代碼的邏輯更清晰?
極大極小演算法有些不明白 ?
C#4 VS2015 把delegate的null check代碼標灰了,該怎麼辦?

TAG:JavaScript | 非同步 | C | C# | Go語言 |