前面寫了一篇,寫的很粗,這篇講講一些細節。實際上Fiber/Coroutine vs Async/Await之爭不是一個簡單的continuation如何實現的問題,而是兩個徹底不一樣的problem和solution domain。javascript
咱們回顧一下最純粹的Event Model。這曾經在UI編程,和如今仍然在microcontroller(MCU)編程中佔據主力地位,在系統編程上是thread model了。java
用MCU編程來說解最方便,在傳統UI編程上是同樣的。node
單核的MCU具備硬件的Thread。程序員
Main Thread是CPU的正常運行,Interrupt Thread(通常稱爲ISR,Interrupt Service Routine)是硬件上的比Main Thread優先級更高的Thread,即所謂的搶先(pre-emptive)。web
若是Main Thread在運行,有Interrupt進來,CPU會馬上跳轉到ISR入口執行,ISR原則上應該保存現場,運行,而後恢復現場,return。return以後Main Thread從新拿回CPU繼續運行。這裏壓棧彈棧的細節不說了,這就是一個搶先CPU的過程。數據庫
在這種模式下編程,ISR裏能訪問的變量,和Main Thread裏訪問的變量,很明顯存在race,須要鎖機制。在系統級編程這種鎖機制就是lock,可是上述狀況裏Main Thread和ISR不是對稱的,因此作法略有區別:Main Thread裏lock的辦法是禁止interrupt,而後開始執行critical section的代碼,完成後使能中斷;ISR裏,若是不考慮ISR之間的搶先的話,不須要這個過程,由於它天生比Main Thread優先級高。express
昨天咱們說了業界都在把non-blocking叫作asynchronous;這裏解釋一下asynchronous,asynchronous的正確含義是你寫了一個function,這個function在Main Thread和ISR裏都能用,它叫作asynchronous function;若是是系統級編程,thread之間是對等的,它叫作thread-safe。編程
上述的模型在邏輯上沒問題,可是有兩個實踐上的麻煩:api
asynchronous function很差寫,尤爲是出現nested,在ISR有搶先的時候就更加麻煩;promise
禁止中斷的時間不能太長,太長的話會丟失中斷處理,邏輯上會出現問題;
ISR裏的執行邏輯時間也不能太長,尤爲不能等待什麼,不然Main Thread會被block過久;
因此聰明人就有一個one-for-all的辦法:
在全局構造一個event queue;
任何ISR進來的時候,不去作邏輯處理,只把當時和中斷有關的狀態保存下來,構造一個event,壓入隊列;
在Main Thread裏一個一個的取event,調用相應的event handler處理。
在這個模式下:
只有event queue是須要lock的;惟一的race發生在存取event的時候,存不用lock,取的時候禁止和使能中斷,這個時間不長,避免丟失中斷。
中斷的真正處理邏輯實際上發生在Main Thread,至關於deferred,它有延遲,可是不會亂序。
全部的代碼段都運行在Main Thread,沒有race,也就是人們最推崇event model的特性之一:run-to-completion。
它對於硬件搶先的多線程是個很是好的簡化。
那麼有了Event,代碼模塊化的結果就是用State Machine建模。State Machine是一個理論上萬能的模型,任何代碼模塊你均可以給出state/event行爲矩陣,稱爲state transition table。它是對這個模塊的完備定義,也極具可測試性,也應用很是普遍。OO編程的本質是State Machine建模,全部的method均可以看做是event,它可能引發Object的狀態遷移。在良好設計下,State具備邊界,即所謂的封裝。每一個State都有owner,不是owner不能修改它,避免side-effect。
在實踐中,目前沒有任何一個流行語言能直接寫出簡潔的狀態機,尤爲是狀態機組合;因此它存在於Pattern層面而不是語言層面;客觀的說這是計算工業的恥辱,但咱們只能接受現狀。
IO不是象中斷同樣的自發事件。在通信編程領域你們發明了一對名詞來描述這個問題:solicited和unsolicited;不算特別恰當但有總比沒有好。
IO是solicited,即有request纔會有response;不須要request的那種自發event,是unsolicited event。
Event Model在處理這個問題上沒有理論上的障礙,你能夠對調用函數對外界進行一個操做,而後獲得結果時構造一個事件。
可是在實踐上,即便不考慮效率問題,這裏仍然有一個大麻煩:在代碼層面上,執行request的地方在一個函數裏,被某個event handler直接或間接調用,處理response的event handler在另外一個地方。代碼的可讀性和可維護性都是徹底沒有保障的,代碼質量取決於你的信仰、星座、美食愛好、或者性取向。
咱們須要一些技術讓代碼看起來是人類的,不是AI或者外星人的,來對付IO問題。
更寬泛的說,Thread Model,Unix進程,Unix的一切皆IO哲學,Unix的open/read/write/close包打天下,就是咱們在解決這類問題上的第一個大範圍成功的案例。可是Thread Model這個話題太大了,它還包括系統資源的虛擬化和物理多CPU的併發,因此咱們不用這種擴大化的概念來討論。咱們只討論兩個限定在單進程Event Model下的技術:coroutine和callback。
Coroutine也是一個擴大化的概念。咱們先討論廣的概念邊界,再來講它對io問題的解決辦法。
Coroutine的科學定義是stateful或者stackless function,它的標誌性原語是yield。
注意原語(primitive)是理解一種編程語言或者編程技術的最關鍵點。若是一種技術致力於解決某個特定問題,它最好的辦法不是用pattern來解決,而是定義原語。OO語言有class,extends,implements;函數式語言容許function做爲primitive value寫入assignment expression;以解決併發爲目標的Go語言把channel做爲標誌。
yield
是什麼意思呢?它說的不是io,它說的是cpu,交出cpu。從這個意義上說,coroutine第一解決的問題是調度。它解決其餘問題,包括timer/sleep,包括io,包括把一個總體計算切碎成不少單元來實現,都是靠yield。因此正確的表述是:Coroutine不是爲特別解決io問題的設計,它首先解決cpu調度,它能夠用於解決io問題。
第二點,爲何咱們須要coroutine?它最擅長解決的問題是什麼?
coroutine本質上仍然是一個event / state model,是一個object,可是不一樣的是,你不須要把全部的state都顯式表達出來,以對付continuation問題,coroutine容許開發者直接用語言提供的原始流程語句,來編碼state信息,你運行到哪裏,這個時候整個coroutine內的local variable的組合,就是當前的state,每運行一次,就是一次state transition;和對象同樣它要從構造開始,到析構結束(return)。
coroutine能解決一類對OO語言的state pattern實現來講特別無力的狀態機模型:流程即狀態。若是你的狀態機model的是一個複雜流程,充滿條件分支、循環、和他們的嵌套,用coroutine寫起來很是簡單,而與之對應的狀態機,都不用寫代碼,定義transition table的時候程序員就要進醫院了。
coroutine對付io了嗎?yes and no。它是標準的Thread Model,thread model下io什麼樣,它就什麼樣了,no more, no less。
這幾個貨本質上是同樣的,區別在形式上。固然不少時候形式很重要,可是咱們先談本質。
const myFunction = (dirpath, callback) => { // do something // first io operation if (err) return callback(err) else return callback(null, entries) } // my code myFunction('/home/hello', (err, entires) => { // blah blah blah }) // do something else console.log('blah, blah...')
咱們首先說callback的本質是一個event handler。調用myFunction
至關於在前面說的最淳樸的event model裏enqueue一個event,這個event的handler會根據event裏定義的dirpath執行某個操做,操做結束的時候會構造另外一個event,裏面包含error或result。
這個純粹模型的寫法會很是複雜,從這個意義上說,node.js callback是一種簡單的continuation實現。
But wait! 二者不是徹底一致的!
myFunction
函數裏入口處do something部分的代碼;若是是咱們上述的淳樸event model,它會在當前代碼結束以後執行,即console.log會先執行,等到全局的event manager開始層層dispatch event的時候,這個請求才可能landing到正確的handler,這段do something纔開始執行,在console.log以後。
這是一個subtle,可是極爲重要的區別。
插個話:callback形式若是在入口處do something馬上返回的話,對外部調用者來講是一場災難,由於它根本沒辦法肯定它提供的callback在console.log以前仍是以後執行。因此callback形式要guarantee它是異步的,用process.nextTick。promise和async/await在這個問題上是一大進步,它有異步保證,即便代碼形式上看起來是同步返回。
如今咱們在本身腦殼上敲一錘子,昏過去,醒來的時候站在V8虛擬機的中控臺上。V8激進的inline函數來提升執行效率,在源碼層面上的myFunction函數調用,對V8編譯的代碼來講有一個call/return的邊界嗎?probably not!對編譯代碼來講,極大的可能性是執行函數邊界在myFunction內部第一個io處,而不是函數入口。
若是仍然用淳樸Event Model來類比,enqueue的event是一個純粹的io操做請求,而不是要執行myFunction函數!
因此寫到這裏,一個關鍵的概念問題闡述清楚了:
coroutine is all about how to structure your control flow unit, while node callback is all about how to structure your io operation.
他們的出發點徹底不一樣。
在建模層面(而不是語言技術層面)Funtional Programming,FP,它不是OO的對立,而是OO的超集。
在FP模型下,程序分爲三個部分:Pure Functions,OO (state monads),和io (io monads)。
Pure的部分裏,Pure Function只有輸入輸出(函數的輸入輸出,不是io輸入輸出),function和immutable數據結構是孿生姐妹。
OO的部分,若是程序須要state,OO至少在JavaScript裏是絕對的最佳實踐,只有少許場合能夠用閉包代替。
io的部分,應該單獨抽象出來,用callback、promise或者async/await作薄層封裝。
站在Pure Function的角度看,state和io都是它的外部世界。
Side Effect一詞最廣的使用上指的是一個函數是否是pure。io function毫無疑問不pure,可是訪問state的呢?好比前面的代碼裏,若是myFunction修改了它的調用者域內的閉包變量呢?這也是side effect。
在OO裏咱們保障減小side effect的影響的辦法,對於state(而不是io)範疇的變量來講,是用封裝原則來保障的。
在FP裏對這個問題的有效辦法,則是immutable。
好比上面的代碼,若是你傳入myFunction的參數是一個對象,有深層次的結構,你會設計myFunction的函數約定是我要修改某個參數嗎?或者你會防止其餘程序員這樣作嗎?
簡單的辦法就是用immutable來處理在pure function domain的這類問題,你們都用immutable;即便你沒有顯式的包含某些immutable庫,JavaScript裏也有大量的集合類函數已經這樣作了。
Lock分爲兩類,atomic operation lock,和transactional lock。
transactional lock指的是一個操做的結果是all or none的,包括更新state,也包括執行output io操做。
容易實現transactional lock是須要fp和immutable的一個重要緣由。由於它讓這種lock容易書寫。
你能夠用一種鎖對付兩種狀況。可是很難。用Big lock併發效率有問題,細粒度鎖編程難度大;並且對於JavaScript的單進程Event Model來講,用細粒度鎖對付transactional的數據完整性問題是overkill的。
另一種鎖機制是Opportunistic lock,它和數據庫的事務操做是一樣的邏輯:你不斷的執行更新數據的操做,其實是建立了一個副本,在最後commit的時候所有生效或失敗。若是失敗了能夠重試這個過程。
在有immutable數據保證的狀況下,若是有多步io操做致使更新過程分了幾個步驟,這個不是問題,你一直在建立一個副本,在最後須要更新state monad的時候,用referential equality check檢查input是否發生了變化(你也能夠每一步都作,但概率上說意義不大)。
這樣書寫事務問題,即便對文科生改行來的程序員來講也不算太難。
在某些狀況下IO操做的原子鎖無可替代;
好比你要更新一個文件,你能夠用時間戳來替代上面說的immutable referential check,即先讀入文件時間戳,寫入前檢查時間戳是否發生變化,這麼作能大大減小race的概率,但不是解決了問題,由於讀入時間戳自己和寫入文件操做沒有原子性,能夠出現race。
那麼這種時候封裝原子操做是必要的,傳統的early lock也必要,但這是最細粒度鎖,它屬於原子操做鎖而不是事務鎖。
事務鎖本質上是big lock,即便要提升效率也只是每步操做檢查input,沒有邏輯難度,只有代碼量。
文件系統io是有須要寫原子操做鎖的狀況的,數據庫和api操做應該由提供者保證rmw操做(read-modify-write),若是須要的話。
因此問題不是簡單的fiber/coroutine vs async/await之爭,而是要站在更大的problem domain去全局的看。程序員須要的是全局的和一致的解決方案。
在前面的討論上說過了,fiber/coroutine徹底是關於調度控制流程的,而callback/promise/async/await徹底是關於結構化io操做的;二者沒在同一個角度上談問題。
fiber/coroutine不是完整的問題答案,除非你的problem domain裏最重要的問題是如何併發計算任務,io無所謂;
async/await回答瞭如何結構化io操做的問題,結合fp/immutable回答瞭如何在維護state和更新外部世界時解決事務性競爭問題。它是一個一攬子解決辦法,並且不難。
在針對state維護的問題上,state machine/event/state model是合格的,可是它與重io操做時的結構化io操做尤爲是transactional更新問題沒有直接答案。nodejs自己不是general purpose的系統級開發語言和環境,它是domain specific language (dsl)。
咱們不能說coroutine或者csp/channel在JavaScript上徹底沒有意義,可是nodejs在io併發上已經作得很好,而若是還要在計算任務併發上作得很好,支持多核,目前看差距太大了,須要解決的問題不少不少。
JavaScript的將來確定不在於目前worker引入的鎖,這是個joke,屬於monkey-patching。
在系統語言裏不得不用的細粒度鎖也不應在JavaScript裏出現,也不應用於解決事務問題。
Opportunistic Lock是被數據庫領域證明的和被普遍接受的solution,只是在語言一級去實現primitive支持上有困難。它須要:
JS語言和JSVM真正支持immutable數據類型;
在JSVM裏有Software Transactional Memory的實現;
理論上STM支持多核是沒問題的,系統語言的STM庫有不少成熟的,可是JS的語言對象模型是list/hash table,在JIT層面上又要編譯成類型對象,因此把對象模型扣在內存模型上並不簡單。
你應該花上幾周的時間瞭解一下Haskell。
Haskell是靜態語言,最純粹的simple typed lambda實現;它有着匪夷所思的強大的代數類型系統,可是究竟是靜態的代數類型系統是將來,仍是JIT的動態類型系統是將來,只有時間能回答了。
它有個搞笑的do語法,async/await該作的是就是haskell裏do該作的。do/io monad也是最能說明白nodejs callback的設計初衷和最恰當的應用場景的。
在pure function, state monad, 和io monad之間劃分清楚的界限,是程序建模的巨大進步,而不是把io封裝在OO對象的操做裏,它等於沒有區分state和io的不一樣。
不管用任何語言編程,這個建模方式和劃分模塊的辦法都是極具借鑑意義的;除非你的程序真的和老式程序同樣只須要封裝簡單的幾個文件操做。
時代不一樣了,web和network改變了咱們編程的問題域,相應的咱們在解法域須要新思惟也就理所應當。