白潔血戰Node.js併發編程 002 異步

這篇文章大面積重寫了,更準確和嚴格的描述了Node.js的運行時模型,但本文中的部分例子被移除了。node

請閱讀:Reactor Model算法

前言

異步(Asynchronous)在不一樣的上下文下能夠有不少不一樣的解釋;在Node.js上下文裏,它指的是如何對待一個或多個過程,因此咱們先來談過程。express

過程

這裏說的過程(Process)是抽象的概念,實際的實現多是進程(另外一種定義的Process),線程(thread),協程(coroutine/fiber),甚至只是一個函數(function)。編程

過程(Process)是一種解決問題的方法,或者說是解法域模型:把一個複雜問題拆解成多個簡單問題,每一個問題用一個過程解決,經過建立和銷燬過程,以及過程通信,來完成整個計算任務。segmentfault

計算科學家們(尤爲在談併發編程的時候)喜歡舉下面的例子來講明這種思惟方式:數組


問題是求解整數n以內的全部質數(n > 2)。promise

解法的關鍵點是爲每個已知的質數創建一個Process,算法以下:安全

  1. 第一個質數是2,咱們創建一個Process,標記爲P2;
  2. 咱們開始把從2到n的數依次發送給P2;
  3. P2的邏輯是:網絡

    1. 若是這個數能夠被2整除,扔掉它;
    2. 遇到第一個不能被2整除的數,其實是3,它建立下一個Process,記爲P3;
    3. 以後P2把全部不能被2整除的數都扔給P3;
  4. P3的邏輯和P2同樣,只要把2換成3;

這個過程繼續下去,會建立P5, P7, P11...等等。當全部的數都被處理完以後,這些Process自己就是問題的答案。多線程


這個例子展現了用Process創建模型解決問題的基本要素:

  1. 每一個Process是一個計算任務;
  2. Process能夠建立,稱爲fork;例子中沒有,但固然它也能夠銷燬,稱爲join;
  3. Process之間能夠通信;

這個例子是否是很優雅不在咱們的討論之列,即便它很優雅,大部分實際的編程問題沒有這種優雅特性。

在這裏咱們只強調一點:用Process建模是用divide and conquer的辦法解決問題的一種方式,一樣的方式也存在於Component-based, Object-Oriented等多種編程模型技術中。

串行組合過程

咱們先看最傳統的基於blocking i/o編程的程序,若是把每一個function都理解爲一個process,這個程序的運行過程如何理解。

在全部的命令式語言中,函數都具備良好的可組合性(Composibility),便可以經過函數調用實現函數的組合;一個函數在使用者看來,不知道它是一個簡單的函數仍是複雜的函數組合,即函數和函數組合具備自類似性,或者說在語法上具備遞歸特性。因此咱們只需回答針對一次函數組合,如何從Process的角度理解便可。

在一個父函數中調用子函數,父函數自己能夠看做一個Process,P1,在它調用子函數時,能夠理解爲建立(fork)了一個新的Process,P2,而後P1被阻塞(blocked),一直等到P2完成並經過通信把執行結果返還給P1(join),而後P1繼續執行。

若是這樣去理解咱們能夠看到,傳統的基於blocking i/o的編程:

  1. 程序運行時能夠由一組Process的組合來描述;
  2. 在任什麼時候刻,只有一個Process在運行,Process之間的組合在運行時只有串行,沒有併發;

異步

在上述傳統的blocking io編程模式下,整個程序可能被一個i/o訪問block住,這不是一個充分利用CPU計算資源的方式,尤爲對於網絡編程來講,它幾乎是不可接受的。

因此操做系統自己提供了基於socket的回調函數機制,或者文件i/o的non-blocking訪問,讓應用程序能夠充分利用處理器資源,減小執行等待時間;代價是開發者須要書寫併發過程。

對於Node.js而言,在代碼層面上,建立一個過程,是你們熟知的形式,例如:

fs.readdir('directory path', (err, files) => {
    
})

這裏的fs.readdir是一個Node API裏的文件i/o操做,它也能夠是開發者本身封裝的過程。

這個函數調用後當即同步返回,在返回時,建立了一個過程,這個過程的結果沒有同步得到,從這個意義上說,咱們稱之爲異步函數

若是連續調用兩次這樣的函數,在應用內就建立了兩個過程,咱們稱之爲併發

對事件模型的Node.js而言,這樣來實現非阻塞和併發編程有兩個顯而易見的優點:

  1. 它不須要額外的語法原語去實現fork,一個函數便可建立一個process,包括構造函數;
  2. Node.js是單線程執行的,因此process之間的通信是同步的;

同步的意思須要這樣理解:假如用線程或者進程來實現process,process A只能經過異步通信通知process B本身發生了某種狀態遷移,由於這個通信是異步的,因此process B不能相信收到的消息是它收到消息那個時刻的process A的真實狀態(可是能夠相信它是一個歷史狀態),雙方的邏輯也必須健壯到對對方的狀態假設是歷史狀態甚至錯誤狀態之上,就像tcp協議那樣。

在Node.js裏沒有這個煩惱,由於這些process的代碼都在同一個線程內運行,不管是process A仍是process B遇到事件:

  1. 均可以同步讀取的對方的真是狀態
  2. 均可以經過方法調用或者消息機制讓本身和另外一方實現一次同步和同時的狀態遷移

    • 同步的意思是經過同步函數完成(在一個Node event loop的tick內)
    • 同時的意思是process A s1 -> s2和process B t1 -> t2同時遷移

同時的特性被稱爲(shared transition),能夠看做兩個LTS(Labelled Transition System)的交互(interaction),也能夠用Petri Net描述。它可讓過程組合容易具備完備的狀態和狀態遷移定義。

從上述這個意義上說,在併發模型上,Node領先Go或者任何其餘異步過程通信的語言一大截;可是反過來講,Node的單線程執行模型對於計算而言,沒能利用到更多的處理器,是它的顯著缺點。可是對於io,Node則徹底不輸用底層語言編程的多線程程序。

Callback Hell與CPS

這是被談論最多的話題。

如何用Node.js書寫異步與併發,和開發者面對的問題有關。若是在寫一個微服務程序,fork過程主要發生在http server對象的事件裏,或者express的router裏;同時fork出來的每個過程,咱們用串行組合過程的方式完成它,即若是使用callback形式的異步函數嵌套下去,最終會獲得一個能夠利用到Node的異步併發特性,可是形式上很是難讀的代碼,這被稱爲Callback Hell。

在ES7中出現的async/await語法一推出就大受歡迎:

  1. 它比較好的解決了Callback Hell的問題,
  2. 書寫條件流程在形式上也迴歸了傳統的代碼形式,
  3. 能catch全部的錯誤
  4. 經過Promise.all提供最簡單的併發(fork/join)支持

在串行組合過程時,開發者最關心的問題是如何讓過程連續下去,因此從這個意義上說callback函數,或者對應的promise,async/await,也被一些開發者稱爲Continuation Passing Style(CPS)。

這樣說在概念上和實踐上都沒有問題。可是這件事情在整個Node併發編程上是很是微不足道的。由於這樣的模型沒有考慮到一個重要的問題:過程能夠是有態的

過程的狀態

當咱們在傳統的blocking i/o模式下編程,書寫一個表示過程的函數,或者在Node.js裏用callback形式或async語法的函數,書寫一個表示過程的函數,其狀態能夠這樣表述:

P: s -> 0

它只有兩個狀態,在運行(s),或者結束了(0)。結束的意思是這個過程不會對程序邏輯和系統狀態產生任何將來影響

咱們優先關心結束,而不是關心它的成功、失敗、返回值,由於前者是對任何過程都普適的狀態描述,能夠理解爲語法,後者是針對每一個具體過程都不一樣的語義。

固然不是全部的過程都會自發結束,好比用setInterval建立的週期性fire的時鐘,調用了listen方法的http server,或者打開了文件句柄的fs.WriteStream,若是他們沒有遇到嚴重錯誤致使自發結束,他們須要使用者的進一步觸發(trigger)才能結束。

對於setInterval而言這個觸發是clearInterval,對於http server而言這個觸發是close,對於fs.WriteStream而言,這個觸發是end,不管哪一種狀況,開發者應該從抽象的角度去理解這個問題:

  1. 若是從Process模型去理解,它們須要使用者發送message
  2. 若是從狀態角度去理解,它們須要使用者觸發event(形式上能夠是method call)

咱們舉兩個例子來講明這種狀態更爲豐富的過程,即沒法用P過程表示的有態過程。

Q過程

第一個例子,咱們考慮一個有try/catch邏輯的過程,若是async/await函數形式來寫它大致是這樣的代碼:

async function MyProcess () {
    try {
        // 1 do some await
    } catch (e) {
        // 2 
          // 3 do some await
          throw e
    } 
}

這個Process開始執行後就進入了s狀態,它有可能在try block成功完成任務,即s -> 0。它也可能遇到錯誤走向catch block,可是錯誤並非從2的位置拋出,讓使用者能夠馬上獲知和採起行動,它被推遲到3結束。

這樣作的好處是這個過程仍然能夠用s -> 0來描述,可是,從併發編程的角度說,使用者的error handler邏輯的執行時間被推遲了。可能不少狀況下3的邏輯的時間足夠短,並不須要去計較,從practical的角度說,我也會常常這樣寫代碼,由於它容易。

可是從Process狀態定義的完備角度說,這是個設計缺陷。

一樣的過程咱們能夠換成Event Emitter的形式來實現,Emitter的實現不強制同時拋出error(或data)和拋出finish這兩個事件,這對於callback形式或者async函數是強制的:返回即中止。

這樣就給使用者提供了選擇:

  1. 若是它選擇在error/data以後馬上開始後續邏輯,這種狀況下咱們稱之爲race。
  2. 若是它選擇必須在finish以後纔開始後續邏輯,這種狀況咱們稱之爲settle。

race和settle都是join邏輯。但他們沒必要是互斥的(exclusive or),使用者也徹底能夠在error(或data)的時候觸發一個後續動做,在settle的時候觸發另外一個動做。這樣的模型纔是普適的和充分併發的。

更爲重要的,不管你採用什麼樣的模型去封裝一個單元,一個重要的設計原則是,這個封裝應該提供機制(Mechanism),而不是策略(Policy),選擇策略是使用者的自由,不是實現者的決策。

若是分開兩次拋出error和finish,使用者有自由選擇race,或者settle,甚至both,這是使用者的Policy。

在這種狀況下,咱們能夠用下述狀態描述來表示這個過程:

Q: s -> 0 | s -> e -> 0

約定:咱們用s,或者s1, s2, ...表示可能成功的狀態,或者說(意圖上)走在走向成功的路上,用e,或者e1, e2, e3...表示明確放棄成功嘗試,走在儘量快結束的路上的狀態。0只表明結束,對成功失敗未置能否。

在這個Q過程定義中,全部的->狀態遷移都是過程的自發遷移,不包含使用者觸發的強制遷移。在後面的例子中咱們會加入強制遷移邏輯。

在這裏咱們先列出一個重要觀點(沒有試圖去prove它,因此目前不稱之爲定理或者結論):完整的Q過程是沒法簡化成s -> 0的P過程的,因此它也沒法應用到串行組合P過程的編程模式中。

在開發早期對Q過程有充分認知是很是必要的,由於開發者可能從很小的邏輯開始寫代碼,把他們寫成P過程,而後層層封裝P過程,等到他們發現某個邏輯須要用Q過程來描述時,整個代碼結構可能都坍塌了,須要推倒重來。

這是我爲何說async/await的CPS實現不那麼重要的緣由。在Node中基於P過程構建整個程序是很簡單的,若是設計容許這樣作,那麼恭喜你。若是設計上不容許這樣作,你須要仔細理解這篇文章說的Q過程,和對應的代碼實現裏須要遵循的設計原則。

Q過程的問題自己不限於Node編程,用fiber,coroutine,thread或者process實現併發同樣會遇到這個問題。要實現完整的Q過程邏輯必須用特殊的語法建立過程,例如Go語言的Goroutine,或者fibjs裏的Coroutine;使用者和實現者之間須要經過Channel或對等的方式實現通信。實現者的s/e/0等狀態對使用者來講是顯式的。

在Node裏作這件事情事實上是比其餘方式簡單的,由於前面說的,inter-process communication是同步的,它比異步通信要簡化得多。

銷燬過程

Node/JavaScript社區能夠說很不重視從語法角度作抽象設計了。

http.ClientRequest裏的取消被稱爲abort,Node 8.x以後stream對象能夠被使用者直接銷燬,方法名是destroy,可是 Node 8很新,你不能期望大量的已有代碼和第三方庫在可見的將來都能遵循一致的設計規則。在Promise的提案裏,開發者選擇了cancel來表示這個操做。

我在這篇文檔裏選擇了destroy,用這個方法名來表示使用者主動和顯式銷燬(放棄)一個過程。新設計的引入須要小小的修改一下Q過程的定義:

Q: s -> 0 | s => e -> 0

=>在這裏用於表示它能夠是一個自發遷移,也能夠是一個被使用者強制的遷移;若是是後者,它必須是一個同步遷移,這在實現上沒有困難,可以使用同步的時候咱們不必去使用異步,徒然增長狀態數量卻沒有收益。

在Node生態圈裏有很多能夠取消的過程對象,例如http.ClientRequest具備abort方法,所以相應的requestsuperagent等第三方增強版的封裝,也都支持了abort方法。

可是絕大多數狀況下它們都不符合上述Q過程定義,而是把他們定義成了:

P: s => 0

即狀態s到0的遷移,多是自發的,多是被強制的(同步的)。這些庫能這麼作是由於:它們處理的是網絡通信,Node在底層提供了abort去清理一個socket connection,除此以外沒有其餘負擔,因此在abort函數調用後,即便後續還有一些操做在庫裏完成,你仍然能夠virtually的看成他們都結束了,由於它符合咱們前面對結束的定義「不會對程序邏輯和系統狀態產生任何將來影響」。

不少庫都選擇了這樣的簡化設計,這個設計在使用者比較當心的前提下也能歸入到「用串行Process」來構造邏輯的框架裏,由於大部分庫都採用了一個callback形式的異步函數同步返回句柄的方式。

let r = request('something', (err, res) => {
  
})

// elsewhere
r.abort()

這個寫法不是解決destroy問題的銀子彈,由於它沒辦法同時提供race和settle邏輯。並且在r.abort()以後,callback函數是否該被調用,也是一個爭議話題,不一樣的庫或者代碼可能處理方式不一致,是一個須要注意的坑。

還有不少更爲複雜的場景的例子。不一一列舉了。咱們倒回來回顧一下Node API自己,至關多的重要組件都是採用Event Emitter封裝的,包括child,stream,fs.stream等等。他們基本上均可以用Q過程描述,但很難歸入到P過程和P過程串行組合中去。

小結

在這一部份內容裏咱們提出了用Process Model來分析問題的方法,它是一個概念模型,不只限於分析Event Model的Node/JavaScript,一樣能夠用於多進程,多線程,或者協程編程的場景。

基於過程的分析咱們看出Node的特色在於能夠用函數這樣簡單的語法形式建立併發過程,也指出了Node的一大優點是過程通信是保證同步的。

最後咱們提出了過程能夠是有態的,一個相對通用的Q過程狀態定義。這是一個難點,但並非Node的特點,任何雙方有態通信的編程都困難,例如實現一個tcp協議,可是在併發編程上咱們迴避不了這個問題。

Node的異步和併發編程能夠簡單的分爲兩部分:

  1. 如何封裝一個有態異步過程(Q過程)
  2. 如何寫出健壯的過程組合

前者是Node獨有的特點,它不難,可是要有一套規則;後者不是Node獨有的,並且Node寫起來只會比多線程編程更容易,若是在Node裏寫很差,在其餘語言裏也同樣。

一般的狀況是開發者在設計上作一點妥協,犧牲一點併發特性,串行一些邏輯,在可接受的狀況下這是Practical的,由於它會大大下降錯誤處理的難度,但遇到設計需求上不能妥協的場景,開發者可能在錯誤處理上永遠也寫不對。

這篇文章的後面部分會講解第一部分,如何封裝異步過程。如何組合這些異步過程,包括等待、排隊、調度、fork/join、和如何寫出極致的併發和健壯的錯誤處理,是另一篇文章的內容。

異步過程

異步過程這個名字不是很恰當,過程自己就是過程,沒什麼同步異步的,同步或者異步指的是使用者的使用方式。但在沒有歧義的狀況下咱們先用這個稱呼。

在Node裏通常有兩種方式書寫一個異步過程:callback形式的異步函數,和Event Emitter。

還有一種不通常的形式是把一個Event Emitter劈成多個callback形式的異步函數,這個作法的一個收益是,和OO裏的State Pattern同樣,把狀態空間劈開到多個代碼block以內;很顯然它不適合寫複雜的狀態遷移,會給使用者帶來負擔,但在只有兩三個串行狀態的狀況下,它可使用,若是你偏心function形式的語法,唾棄Class的話。

異步函數

異步函數的狀態定義是:

P: s -> 0

它沒有返回值(或句柄),它惟一的問題是要保證異步。初學者常犯的錯誤是寫出這樣的代碼:

const doSomething = (args, callback) => {
  if (!isValid(args)) {
    return callback(new Error('invalid args'))
  }
}

這是個很嚴重的錯誤,由於doSomething並非保證異步的,它是可能異步可能同步的,取決於args是否合法。對於使用者而言,若是象下面這樣調用,doElse()和doAnotherThing()誰先執行就不知道了。這樣的邏輯在Node裏是嚴格禁止的。

doSomething((err, data) => {
    doAnotherThing()
})

doElse()

正確的寫法很簡單,使用process.nextTick讓callback調用發生在下一個tick裏。

const doSomething = (args, callback) => {
  if (!isValid(args)) {
    process.nextTick(() => callback(new Error('invalid args')))
    return
  }
}

異步函數能夠同步返回一個對象句柄或者函數(就像前面說的http.ClientRequest),若是這樣作它是一個Event Emitter形式的等價版本。咱們先討論完Emitter形式的異步過程,再回頭看這個形式,它事實上是一個退化形式。

Event Emitter

咱們直接考慮一個Q過程:

P: s -> 0 | s => e -> 0

代碼大致上是這樣一個樣子,:

class QProcess extends EventEmitter {
  constructor() {
    super()
  }

  doSomething () {

  }

  destroy() {

  }
  
  end() {
      
  }
}

這個過程對象能夠emit error, data, finish三個event。

用Process Model來理解它,這個class的方法,至關於使用者過程向實現者過程發送消息,而這個class對象emit的事件,至關於實現者向使用者發送消息。從Process模型思考有益於咱們梳理使用者和實現者之間的各類約定。

使用者調用new QProcess時建立(fork)了一個新的Process。絕大多數Process不須要有額外的方法,由於大部分參數直接經過constructor提供了。

Builder Pattern在Node和Node的第三方庫中很經常使用,例如superagent,它提供了不少的方法,這些方法都是在構造時使用的,他們都是同步方法,並且沒有fork過程,直到最後使用者調用end(callback)以後才fork了過程,咱們不仔細討論這個形式,它很容易和這裏的QProcess對應,惟一的區別是end的含義和這裏定義的不一樣。

這裏提供了兩個特殊方法,destroy和end,前者是銷燬過程使用的,它在錯誤處理時很常見。在串行組合過程的編程方式下,咱們沒有任何理由去destroy其中的一個正在執行的過程;可是在併發編程下,一個過程即便沒有發生任何錯誤,也可能由於併發進行的另外一個過程發生錯誤而被銷燬,從這個角度說,Q過程都應該提供destroy方法。

end在這裏是對應stream.Writable的end邏輯;一個寫入流(咱們一樣把他當Process看),若是沒有end它永遠不會結束;這種過程對象和等價於for-loopsetInterval不一樣,在絕大多數狀況下咱們不會但願它是永遠不結束的。

doSomething是一個通用的寫法,若是QProcess封裝的過程須要儘早開始工做,可是它也須要不斷的接受數據,doSomething用於完成這個通信,典型的例子是stream.Writable上的write方法。write方法不一樣於end方法的地方在於,write方法不會致使QProcess出現對使用者而言顯式的狀態遷移,但end方法是的。

error事件,固然能夠argue說一個QProcess能夠在拋出error後繼續工做,可是咱們不討論這個場景;咱們假定QProcess在拋出error後沒法繼續走在可能成功的s路線上;若是它能夠繼續,那麼那個error請使用其餘event name,看成一種data類型來處理。

Node的Emitter必須提供error handler,不然它會直接拋出錯誤致使整個應用退出,因此從這個意義上說,error是critical的。符合咱們說的限制。

data事件,它相似write,它應該表示QProcess仍然走在可能成功的s路線上。

finish事件,它符合前面咱們說過的過程結束的定義。

實現者承諾

1. 異步保證

在任何狀況下,QProcess都不容許在使用者調用方法時,同步Emit事件。

在Event Emitter形式下的Q過程,仍然要遵循異步保證。它的出發點是一致的,若是沒有這個保證,使用者沒法知道它調用方法以後的代碼,和event handler裏的代碼哪個先執行。若是遇到同步的錯誤,QProcess仍然須要象異步函數同樣,用process.nextTick()來處理。

2. emit時的狀態完備性

若是QProcess須要emit事件,它必須保證本身處於一個對使用者而言,顯式且完整的狀態下。

QProcess內部的實現也會偵聽其餘過程的事件,這些事件的到來可能會致使QProcess執行一連串的動做。

例子:若是一個QProcess內部包含一個ChildProcess對象,在QProcess處於s狀態時,它拋出了error,這時過程已經沒法繼續,QProcess執行一連串的動做向e狀態遷移:

transition: s -> action 1 -> action 2 -> action 3 -> e

emit error的時間點在進入e狀態以後,emit error的含義是通知使用者發生了致命錯誤,並且QProcess已經遷移至e狀態

在action過程當中隨意的emit error是嚴格禁止的。

由於:使用者可能在error handler中調用QProcess的方法,不管是同步仍是異步方法。若是嚴格要求QProcess在有效狀態下emit,那麼QProcess的實現承諾就是這些方法在有效狀態下可用。若是寫成在action 1以後也容許emit error,對QProcess的要求就提高到在任何transition action時均可用,這種是無厘頭的挑戰自我設計,毫無心義。並且即便你成功的挑戰了自我,使用者也帶來了額外的負擔,它的handler裏對QProcess的狀態假設是什麼?是狀態遷移中?這明顯不合理。

Q過程對使用者是顯式有態的,是它的執行邏輯的依據,因此這裏應該消除歧義,杜絕錯誤。這種錯誤是嚴重的協議錯誤。

3. 只emit一次,承諾狀態

QProcess在內部的一次event handler中只容許emit一次,並且承諾狀態:

  1. 若是emit error,表示QProcess處於e狀態
  2. 若是emit data,表示QProcess處於s狀態
  3. 若是emit finish,表示QProcess處於0狀態

有兩種常見的錯誤狀況。

第一個例子:假如QProcess的第一個操做,例如經過fs.createReadStream()建立一個文件輸入流,由於文件不存在馬上死亡了。這時它有這樣一些選擇:

  1. 先emit error,而後同步emit finish;
  2. 先emit error,而後異步emit finish;
  3. 只emit error,再也不emit finish;
  4. 只emit finish,不emit error;

正確的作法是4。由於QProcess已經結束,是顯式狀態0,emit finish通知使用者本身發生了狀態遷移(s -> 0)是正確的作法。至於錯誤,推薦的作法是直接在finish裏提供,在對象上建立標記讓使用者去讀取也是能夠的。

在這樣的設計下,emit error的語義被縮減的了,即若是QProcess emit error,說明它必定處於e狀態,不是0狀態,這有助於使用者使用(代碼路徑分開原則,後述)。

在這個例子下若是選擇1呢?你怎麼考慮若是在emit error以後:

  1. 實現者對本身的狀態承諾是什麼?
  2. 使用者若是在error handler裏調用QProcess的同步方法,它會被強制狀態遷移嗎?若是遷移了,那麼隨後emit finish還能成立嗎?
  3. 使用者的error handler方法和finish handler方法是可能異步可能同步執行的,使用者要保證在這樣的狀況下也OK嗎?

第二個例子:假如QProcess處於s狀態,拋出了data事件,對QProcess而言它不知道這個data是否非法,可是使用者可能有額外的邏輯認定這個data是錯誤的,這個時候它調用了QProcess的destroy方法,這個方法要求QProcess的強制狀態遷移s -> e

若是遵循這一條設計要求,這種設計就是很安全的。不然連續的emit的第二次的emit對狀態的假設就無法確認了,難道使用者還須要在第二次emit以前去檢查一下本身的狀態嗎?

error的處理有另一種設計路徑。在s狀態下emit error,而後使用者調用destroy方法強制其進入e狀態;邏輯上是對的,也具備數學美感,由於它沒區分s和e的處理方式;但我傾向於不要給使用者製造負擔,實現者的代碼寫一次,使用者的代碼寫不少次,這樣設計須要使用者在每一次都要去調用destroy,相對麻煩。固然若是你有足夠的理由局部去作這樣的設計,能夠的。

4. Destroy是同步方法

回到Process模型上來。

若是一個Process的實現是用操做系統進程、線程來實現,同步destroy的可能性是沒有的,只能發送一個message或signal,對應的進程或者線程在將來處理這個消息,對於使用者而言它仍然可能在destroy以後得到data之類的message,固然這也不是很麻煩,使用者只要創建一個狀態做爲guard,表示Process已經被destroy了,忽略除了exit以外的消息便可。

在Node裏面,邏輯上是同樣的,可是實現者的destroy的代碼能夠同步執行,它也是同步遷移到e狀態的,使用者不須要創建guard變量來記錄實現者的狀態;按照Node的stream習慣,實現者應該有一個成員變量,destroyed,設置爲bool類型,供使用者檢查實現者狀態。

5. End是同步方法

邏輯上是和Destroy同樣的,不一樣之處在於實現者都處於s狀態。

在一些錯誤處理狀況下,使用者可能會根據一個過程對象是否end採起不一樣的錯誤處理策略。還沒有end的過程通常被拋棄了,一般也沒法繼續進行;可是已經end的過程可能會等待它完成。即對一組併發的過程可能採用不一樣的策略。

可是在設計角度說,仍是前面那句話,要堅持mechanism和policy分離的原則。實現者應該提供這個機制而不是強制使用者必須用某種策略,策略是使用者的邏輯。它能夠所有拋棄還沒有完成的Process,也能夠只拋棄還沒有end的,對於大文件傳輸來講這能夠避免沒必要要的再次傳輸,畢竟網絡傳輸已經完成,只是文件還沒有徹底寫入文件系統。

使用者策略

下面來看使用者策略和須要注意的問題,其中一些問題的討論會給使用者和實現者都增長新規則。

Mutex

Event Handler是一種狀態資源。

假如咱們在用狀態機方法寫一個對象(不須要是上述的過程),這個對象的某個狀態有一個時鐘,它在進入這個狀態時須要創建這個時鐘,在超時時向另外一個狀態遷移,可是它也能夠在計時狀態下收到其餘事件從而遷出這個狀態,這是它必須清除這個時鐘,這是狀態機編程下的資源處理原則:在enter時建立,在exit時清理。爲何要清理呢?即便不考慮浪費了一個系統時鐘資源,這個時鐘掛上了一個callback,咱們必須阻止它的執行,不然系統邏輯就錯了。

因此從這個意義上說,Event Emitter上的全部Listener,也要被看做是資源。須要作相應的清理,不然可能會致使錯誤。

例子,啓動一個子進程,等待它返回一個消息,做爲過程須要的結果。

let c = child.spawn('some child process')
c.on('error', err => {})
c.on('message', message => {})
c.on('exit', (code, signal) => {
    
})

這段常見的代碼形式最終如何封裝取決於咱們的設計要求,若是容許省事咱們能夠先把它封裝成s -> 0,即便用最簡單的回調函數形式。

咱們看一下這三個handler,他們都是咱們須要在開始的時候就掛載上去的,包括exit(finish),由於子進程可能沒有完成工做就意外死亡了。可是exit有點特殊,若是error先發生了,咱們就不關心message和exit了,若是message先發生了,咱們也再也不關心error和exit了,這意味着咱們已經拿到了正確的結果,即便在這個結果以後發生了子程序的意外退出,無所謂了,而若是exit先發生了,那麼咱們也不用關心error和message了,由於ChildProcess應該承諾這是finish。

因此看起來很是簡單的代碼,仔細分析一下才會發現三者是互斥的。無論結果如何,first win。因此代碼寫成這個樣子了:

function doSomething(args, callback) {
  let c = child.spawn('some child process, with args')
  c.on('error', err => {
    c.removeAllListeners()
    c.on('error', () => {})
    callback(err)
  })
  
  c.on('message', message => {
    c.removeAllListeners()
    c.on('error', () => {})
    callback(null, message)
  })
  
  c.on('exit', (code, signal) => {
      // if ChildProcess is trusted, no need to remove any listeners
    callback(new Error(`unexpected exit with code ${code} and signal ${signal}`))
  })
}

在error和message handler裏都清除了與之互斥的代碼路徑。有一個小小的坑是Emitter的error handler必須提供,不然會形成全局錯誤,這是Node的設計,因此咱們塞上一個function mute它。另外這裏的child對象是咱們本身建立的,這樣粗暴的removeAllListeners就能夠了。若是是外部傳入的,不能這樣暴力,而只能清除本身裝上去的handler。

這段代碼一般的寫法是在function內放一個閉包變量,例如let finished = false,而後在全部的event handler裏面作guard,若是隻有一層邏輯,這樣寫是OK的,可是閉包變量作guard在邏輯多的時候,尤爲是出現等待同步邏輯的時候,很無力。它沒有清楚的看到全部的狀態空間,容易致使錯誤。

習慣上我把這個設計原則稱爲mutex(mutual exclusive),固然有時候不必定是雙方互斥,象上面的例子就是多方的。mutex固然在thread model下有其餘的含義,可是在Node.js的上下文下沒有那個含義的mutex,咱們姑且用這個詞。

這裏順便給一個拆除pipe的例子,由於這種狀況太場景了,寫不對的開發者不在少數。

假定在代碼中有ws是write stream,rs是read stream,調用過rs.pipe(ws)

rs.removeAllListeners()
ws.removeAllListeners()
rs.on('error', () => {})
ws.on('error', () => {})
rs.unpipe()
rs.destroy()  // node 8.x specific
ws.destroy()  // node 8.x specific
  1. 這裏removeAllListeners()是簡化的寫法,若是stream不是本身建立的,需指定listener。
  2. 拆除和重裝listeners應該在調用方法以前,由於咱們不肯定Node stream遵循了寫在前面的設計原則,即它會不會同步emit error或其餘事件。
  3. destroy是node 8.x纔有的方法,若是是早期的版本沒有這個方法,會報錯。

代碼分開原則

這個原則表述起來很容易。

  1. 空間上分開,例如error是走向錯誤的代碼(e-path),data是走向可能得到結果的代碼(s-path);這一點在Event Emitter的形式上(error和data handler)已經獲得保證,在callback或者finish handler上須要本身檢查,但不是很大的麻煩;
  2. 時間上分開,這個要麻煩一點,它的意思是說最好在每一個狀態下只給全部的過程對象裝上對這個狀態向下一個狀態遷移的handler代碼,在真正遷移時,先移除全部舊的handler,而後裝上新的handler。

好比把上面P過程實現的代碼進化成過程,即考慮使用者對finish事件是有興趣的,它可能須要採起settle邏輯,而不只僅是race。

在這種狀況下:

  1. s -> 0一步完成且獲得message的成功路徑是沒有的。成功的路徑只能是s1 -> s2 -> 0,其中s1沒有message,s2有。
  2. s -> 0一步完成未得到message的路徑是有的。
  3. s -> e -> 0的路徑是有的,先遇到錯誤,遷移到e狀態,而後遷移到完成。
  4. 實際上還可能遇到s1 -> s2 -> e -> 0的狀況,即錯誤發生在獲取message以後;與之相反的,也可能遇到先獲得error而後獲得message的可能,那麼這兩種狀況咱們都拋棄了,咱們的設計目的是使用者對finish有興趣,不是爲了窮舉狀態空間。
class DoSomething extends EventEmitter {
  
  constructor(args) {
    super()
    this.destroyed = false
    this.finished = false
    
    this.c = child.spawn('some child process, with args')
    c.on('error', err => {
      // enter e state
      this.destroyed = true
      c.removeAllListeners()
      c.on('error', () => {})
      c.on('exit', (code, signal) => {
        this.finished = true
        this.emit('finish')
      })
      this.emit('error', err)
    })
    
    c.on('message', message => {
      // s1 -> s2
      this.message = message
      c.removeAllListeners()
      c.on('error', () => {})
      c.on('exit', (code, signal) => {
        this.finished = true
        this.emit('finish')
      })
      this.emit('data', this.message)
    })
    
    c.on('exit', (code, signal) => {
      // -> 0
      this.finished = true
      this.emit('finish', new Error('unexpected exit'))
    })
  }
  
  destroy () {
    if (this.finished || this.destroyed) return
    this.destroyed = true
    this.c.removeAllListeners()
    this.c.on('error', () => {})
    this.c.on('exit', () => {
      this.finished = true
      this.emit('finished')
    })
    this.c.kill()
  }
}

這個例子不算特別的有表明性,可是它展現了和OO編程模式下的State Pattern同樣的代碼路徑分開原則,收益也是同樣的。

destroy的實現也很容易。

代碼中的這種實現方式大部分開發者都會贊成:強制轉換狀態,且繼續emit finish。但咱們有另外的設計方式。

更好的Destroy設計

若是考慮error code path和success code path的分開,我推薦另外一種設計方式:

destroy在調用後,過程對象再也不emit finish事件;若是destroy提供了callback,在原有應該emit finish事件的地方,改成調用該callback;若是destroy沒有提供callback,do nothing。換句話說,若是使用者提供了callback,它就選擇了settle邏輯,若是它不提供callback,就是race了。

按照這樣的設計方式,destroy的代碼改成:

destroy (callback) {
    if (this.finished || this.destroyed) return
    this.destroyed = true
    this.c.removeAllListeners()
    this.c.on('error', () => {})
    if (callback) {
      this.c.on('exit', () => {
        this.finished = true
        callback()
      })
    }
    this.c.kill()
  }

站在使用者角度講,這樣的實現更好。由於使用者能夠分開讓過程自發運行和強制銷燬的finish handler代碼。destroy操做如此特殊,它幾乎只用於錯誤處理階段,讓使用者爲此獨立提供代碼塊是更方便的,這符合咱們嚴格分開成功和錯誤處理的代碼路徑的原則。

退化的場景

下面來給前面用P過程狀態定義實現的異步函數加裝destroy方法,粗暴的方式是直接返回函數(而不是對象句柄)。

function doSomething(args, callback) {
  let destroyed = false
  let finished = false
  
  let c = child.spawn('some child process, with args')
  c.on('error', err => {
    c.removeAllListeners()
    c.on('error', () => {})
    finished = true
    callback(err)
  })
  
  c.on('message', message => {
    c.removeAllListeners()
    c.on('error', () => {})
    finished = true
    callback(null, message)
  })
  
  c.on('exit', (code, signal) => {
      // if ChildProcess is trusted, no need to remove any listeners
    finished = true
    callback(new Error(`unexpected exit with code ${code} and signal ${signal}`))
  })
  
  return (callback) => {
    if (destroyed || finished) return
    c.removeAllListeners()
    c.on('error', () => {})
    if (callback) {
      c.on('exit', () => callback())
    }
    c.kill()
  }
}

這個邏輯和以前是同樣的。可是這個作法更容易出現設計形式上的爭議:由於doSomething在調用時提供的callback沒有保證返回。

咱們說這個原則在doSomething不提供返回的時候確定是須要保證的。可是若是象這樣寫,這個原則能夠修改。

反對這樣設計的理由是充分的,任何請求或者Listener在提供者銷燬時應該獲得通知,這是對的;好比在OO編程時咱們常常會觀察一些對象,或者向某個提供服務的組件請求服務,即便被觀察對象或者服務組件銷燬也應該提供返回或通知。

可是這裏的設計原則和上述場景不同。在這裏咱們強調Owner原則,使用者建立實現者是給本身使用的,本身是Owner,本身銷燬這個實現者而後還要堅持從原有的callback或者finish handler獲得一個特定的Error Code判斷銷燬緣由,這沒有必要。

至於上述的Observer Pattern的Listener,或者向Service組件的請求的實現,咱們在下一篇會給出代碼例子。

在這裏咱們強調的是,這裏的callback或者finish handler,不是上述Observer/Service Request意義上的listener/callback,Observer和Service Requester並不是被觀察對象或者服務的Owner,他們固然無權修改對象或服務的特性,並且服務質量也必須被保證。

可是在這裏,咱們是在用callback或者emitter這種代碼形式實現process composition所需的inter-process communication。咱們有權爲了使用便利而設計代碼形式。

總結

爲複雜Process創建顯式狀態,是解決問題的根本方法。若是設計要求就是具備這些狀態的,你用什麼方法寫都同樣,這不是Node特有的問題。

Node特有的問題在於它寫異步有態過程須要遵循的設計原則,不少開發者不熟悉狀態機方法,因此很難寫的健壯。這個話題也可能有它比較獨特的地方,由於Node是強制異步過程和事件模型的混合體,可是這種編程模式在嵌入式系統、內核、以及一些系統程序中,是很是常見的(大多數是C語言的,指針實現callback)。

這篇文章我會長期維護。若是須要更多的代碼示例,或者有很具體的問題須要討論,能夠提出。

只要能把基礎的異步過程寫對,如何組合它們實現併發,併發控制,等待,同步(join),以及健壯的錯誤處理,是易如反掌的,那是咱們下一篇的話題。

相關文章
相關標籤/搜索