簡介
Reactor模型是一種在事件模型下的併發程式設計模型。
Reactor模型首先是一個概念模型;它可以描述在Node.js中所有的併發和非同步程式設計行為,包括基礎的非同步API,EventEmitter物件,以及第三方庫或實際專案中的已有的非同步過程程式碼。
其次,Reactor模型定義了一組簡單但嚴格的設計規則,這些規則涵蓋非同步過程的行為、狀態、和組合設計。這些規則可以用於設計和實現一個Node.js應用的全部行為邏輯。
對於已有的Node.js API、庫和專案程式碼,大多數情況下它們的行為和狀態設計已經遵循了Reactor模型的要求,但是在組合實現上,可能一些程式碼不符合要求,但可以通過重構使之符合Reactor模型規範,通常不必大面積重寫。
第三,Reactor模型沒有提供任何程式碼,包括庫函式或者基礎類程式碼,也不要求統一的程式碼形式,只要應用Reactor模型的設計規則即可。
Reactor模型是與Process模型對等的併發模型,它適用於使用事件模型和支援非同步io的程式設計環境例如,Node.js。和設計模式(Design Pattern)中的解決某類問題的行為型模式相比,Reactor是事件模型下通用的行為模型,所以我們不把Reactor模型看作一種設計模式,設計模式可以在Reactor模型之上實現。
動機
Node.js已經問世8年,但對很多開發者而言它仍然是一個較新的語言環境;它獨特的結合了事件模型和非阻塞io,非阻塞性在程式碼中通過程式碼單元的組合,把幾乎所有程式碼都變成了非同步形式。
對基於Process模型實現併發程式設計的問題,既不缺乏抽象的理論模型,也不缺乏解決各種具體問題的設計模式,且不限於開發語言,甚至存在語言針對高併發問題設計(例如Go);但是在事件模型和結合了非同步過程的Node.js中,我在近兩年的高強度開發中幾乎沒有讀到過在這方面具有統一的模型抽象、系統、全面、同時象GoF設計模式那樣直接指導程式設計實踐的文章。
這是建立Reactor模型和寫下這篇文章的目標。
方法論
我們從三個方面闡述Reactor模型:
- 什麼是Reactor?它是什麼?有什麼基礎特性?
- 如何實現組合模式?通過組合我們不僅僅可以用Reactor描述一個元件的行為,還可以用它來描述整個應用。
- 如何使用Reactor模型應對併發程式設計中的各種問題?
這篇文章闡述1和2的內容,3是下一篇文章的內容。
Reactor
一個Reactor表示一個正在執行的過程,它是一個概念【見腳註1】;和Process模型中的Process概念一樣,是對一個過程的抽象。
在實際的Node.js程式碼中,程式執行時每次呼叫一個非同步函式,或者用new
關鍵字建立了一個表示過程的物件,在應用中都建立了一個Reactor;在這個非同步函式執行結束,或者過程物件丟擲最後的事件(通常為finish
或者close
)時,這個Reactor的生命週期就結束了;不管是否稱之為Reactor,開發者對它並不陌生。
我們用略微嚴格的方式來看這個有生命週期的動態過程,具有哪些特性和設計規則。
特性(Properties)
一個Reactor具有五個基本特性:
- Reactor是反應式的(Reactive);
- Reactor具有輸入輸出(io);
- Reactor是非同步的(asynchornous);
- Reactor是有態的(stateful);
- Reactor是動態的(dynamic);
Reactive
Reactor本質上是狀態機(state machine);雖然應用Reactor模型不強制要求開發者使用設計模式中的State Pattern程式碼形式,但要求開發者為每個Reactor在心裡構建一個狀態機模型。
Reactor的內部行為用State/Event來描述,它是Reactor的實現(Implementation)方法。在一個Reactor收到事件時——這個事件可能來自內部,也可能來自外部——它會根據狀態機行為設計更新自己的狀態,這個過程我們稱為React或Reaction。
Input & Output
Reactor具有輸入輸出;在形式上:
- 對於一個非同步函式,我們可以說它的引數是輸入,返回結果是輸出(包括錯誤);
- 對於一個EventEmitter物件,呼叫它的方法可以看作是輸入,它emit的事件都可以看作輸出;
- 如果一個物件向外提供callback形式或者async形式的非同步方法,那麼呼叫該方法可以看作輸入,該方法的返回可以看作是輸出;
與內部的State/Event狀態機實現不同,input/output描述的是Reactor的外部介面(interface)。
一個Reactor的input,在內部看都可以理解為事件,雖然其中一些可能導致Reactor的狀態遷移,而另一些不會;在討論Reactor的狀態機行為時,我們使用input event或external event表示Reactor收到的外部事件。
Reactor可以產生output,用於輸出資料或通知外部自己發生了狀態遷移;Reactor模型主要關心後面一種情況。
Reactor可以組合,其組合方式和OO中的組合邏輯一樣,同樣的我們把一個Reactor包含的Reactor稱為其成員(member)。成員丟擲(output)的事件對於Reactor的內部狀態機實現而言是事件,但它不是input,因為它來自內部。我們使用internal event表示一個Reactor收到的來自成員的事件。
Asynchronous
在任何情況下,一個Reactor都不允許在input時同步產生output。這就是Reactor模型中對非同步的定義,它被定義成了一個Reactor的內稟屬性。
這個要求對於非同步函式來說是一種常識;但對於EventEmitter物件而言,一些實際程式碼並沒有做到這個承諾(甚至沒有試圖這樣去做);在Reactor Model中,這個行為是強制要求的。
Stateful
Reactor是有態的。
在實現組合(包括有併發的組合)時,Reactor需要清楚的知道每個成員的狀態,至少一個成員是否在進行中還是已結束是必須清楚的,所以Reactor的狀態遷移定義至少是running -> stopped
。
和input/output一樣,這裡說的狀態指的是Reactor的外部狀態,而非其內部實現的完整狀態機狀態。因為Reactor是對過程的通用抽象,在絕大多數情況下,在外部看只需為其建立很少的狀態,例如:正常執行、已經發生過錯誤、已經被強制終止,已經結束等等。
我們用顯式狀態(explicit state)一詞來表述Reactor的外部狀態【見腳註2】。
Dynamic
Reactor是動態的包含兩個意思:
- 它可以被動態建立和銷燬的【見腳註3】。
- Reactor是執行時概念,而不是程式碼層面的編譯時的概念;如果用物件導向程式設計來類比,它對應object,而不是class。
Summary
Reactor不是一個高深或複雜的概念,它甚至可以認為是從程式碼中總結出來的。
輸入輸出和動態性寫在這裡是為了看起來略微嚴謹,實際上幾乎所有的模型的基礎構件,Process,Object, Actor等等,都有輸入輸出和動態性。
Reactive特性是Reactor的內部實現要求,寫在這裡是為程式設計考慮;如果要嚴格(形式化)定義Reactor,它不是必須的要素,因為Reactor概念是黑盒抽象,如何實現是白盒特性。
所以Reactor真正特別的地方只有兩點:
- 它封裝了非同步的概念,在Reactor模型中只有這樣一個非同步定義,毫無歧義,而且可以說是毫無實現負擔;
- 它顯式有態,但是不復雜;建立顯式狀態的唯一目的是為了實現組合,它與一個Reactor的目的或實現細節無關,應該把它理解為一種語法(Syntax)而不是一種語義(Semantics)。
簡單的說,Reactor表示一個非同步和顯式有態的過程。
程式碼示例
在Node.js裡Reactor通常有兩種程式碼形式:
- 非同步函式
- EventEmitter的繼承類
下面我們看看如何把它們理解成Reactor,其中一些程式碼例子展示了簡單的封裝。
非同步函式
fs.readdir(`some path`, (err, data) => {
...
})
呼叫一個非同步函式可以建立一個Reactor(例項)。
把一個非同步看作一個Reactor的建構函式,這看上去有些古怪。但我們應該這樣理解:雖然這個非同步函式可能沒有返回任何物件引用,但是呼叫之後系統實實在在的啟動了一個過程,這個過程在未來會對系統行為產生影響,呼叫過和沒呼叫過,系統整體的狀態不一樣。如果我們用狀態來描述系統,它是系統狀態的一部分。
這裡有個哲學問題可以說一下,通常我們說狀態的本質是系統的輸入(事件)歷史。假如系統沒有建立任何狀態模型,理論上,如果系統記錄了全部的輸入歷史,不考慮效能,系統總是可以完成同樣行為的;從這個意義上說,狀態的本質是輸入歷史。
但是在呼叫一個非同步函式後,我們說有一個未來(Future)是系統狀態的一部分,這個未來是系統自身的歷史行為的造成的,但它並非是一個輸入的歷史。
換一個角度看,在JavaScript裡呼叫一個非同步函式等價於建立一個promise,處於pending狀態,那麼這個promise可以看作是這個Reactor的一個顯式表達。它符合Reactor的四個定義。
非同步函式的返回,可以看作它產生了output,這個output除了可以返回error/data(也可以不返回任何值),更重要的,它向外部emit了一個finish事件。
在狀態約定上,這種Reactor的狀態數量是最少的,只有兩個狀態:在執行,記為S,和結束,記為0;它的狀態遷移圖可以簡單的寫成:
S -> 0
其中
->
符號用於表示一種自發遷移,即這種遷移不是因為使用者通過input強制的。
Spawn a Child Process
let x = child.spawn(`a command`)
x.on(`error`, err => { /* do something */ })
x.on(`message`, message => { /* do something */ })
x.on(`exit`, (code, signal) => { /* do something */})
// somewhere
x.send(`a message`)
// elsewhere
x.kill()
一個ChildProcess
物件可以看作一個Reactor;呼叫它的send
或kill
方法都視為input,它emit的error
, message
, exit
等事件都應該看作output;
ChildProcess
物件的狀態定義取決於執行的程式(command)和使用者的約定。Node.js提供的ChildProcess
是一個通用物件,它有很多狀態設計的可能。
S -> 0
考慮最簡單的情況:執行的命令會在結束時通過ipc返回正確結果,如果它遇到錯誤,異常退出。在任何情況下我們不試圖kill子程式。在這種情況下,它可以被封裝成S->0
的狀態定義。
const spawnChild1 = (command, opts, callback) => {
let x
const mute = () => {
x.removeAllListeners()
x.on(`error`, () => {})
}
x = child.spawn(command, opts)
x.on(`error`, err => {
mute()
x.kill()
callback(err)
})
x.on(`message`, message => {
mute()
callback(null, message)
})
x.on(`exit`, () => {
mute()
callback(new Error(`unexpected exit`))
})
}
在這裡我們不關心子程式在什麼時間最終結束,即不需要等到exit
事件到來即可返回結果;設計邏輯是error
, message
和exit
是互斥的(exclusive OR),無論誰先到來我們都認為過程結束,first win。
這段程式碼雖然沒有用變數顯式表達狀態,但應該理解為它在spawn後立刻進入S狀態,任何事件到來都向0狀態遷移。
按照狀態機的設計原則,進入一個狀態時應該建立該狀態所需資源,退出一個狀態時應該清理,在這裡應該把event handler看作一種每狀態資源(state-specific resource);在不同狀態下,即使是相同的event名稱也應提供不同的函式物件作為event handler(或不提供)。
所以mute
函式的意思是:清除在S狀態下的event handlers,裝上0狀態下的event handlers。使用Reactor Model程式設計,這種程式碼形式是極推薦的,它有利於分開不同狀態下事件處理邏輯的程式碼路徑,這和在Object Oriented語言裡使用State Pattern分開程式碼路徑是一樣的工程收益。
S => 0
我們使用a ~> b
來表示一個Reactor可以被input強制其從a狀態遷移至b狀態,用a => b
表示這個狀態遷移既可以是input產生的強制遷移,也可以自發遷移。
習慣上我們使用S,或者S0, S1, …表示過程可能成功的正常狀態,用E表示其被銷燬或者發生錯誤但是尚未執行停止。
S => 0
的狀態定義的意思是Reactor可以自發完成,也可能被強制銷燬。
原則上是不應該定義
S ~> 0
或者S => 0
這樣的狀態遷移的,而應該定義成S ~> E -> 0
或者S => E -> 0
,因為大多數過程都不可能同步結束,他們只能同步遷移到一個錯誤狀態,再自發遷移到結束。但常見的情況是一個過程是隻讀操作,出錯時不需要再考慮後續行為,或者在強制銷燬之後,其後續執行不會對系統未來的狀態產生任何影響,那麼我們可以認為它已經結束。
允許這樣做的另一個原因是可以少定義一個狀態,在書寫併發時會相對簡單。
在這種情況下spawnChild2
可以寫成一個class,也可以象下面這樣仍然寫成一個非同步函式,但同步返回一個物件引用;如果使用者只需要destroy方法,返回一個函式也是可以的,但未來要給這個物件增加新方法(input)就麻煩了。
const spawnChild2 = (command, opts, callback) => {
let x
const mute = () => {
x.removeAllListeners()
x.on(`error`, () => {})
}
x = child.spawn(command, opts)
x.on(`error`, err => {
mute()
x.kill()
callback(err)
})
x.on(`message`, message => {
mute()
callback(null, message)
})
x.on(`exit`, () => {
mute()
callback(new Error(`unexpected exit`))
})
return {
destroy: function() {
x.mute()
x.kill()
}
}
}
這裡的設計是如果destroy
被使用者呼叫,callback函式不會返回了,這樣的函式形式設計可能有爭議,後面會討論。
S -> 0 | S => E -> 0
這是一個更為複雜的情況,使用者可能期望等待到子程式真正結束,以確定其不再對系統的未來產生任何影響。這時就不能設計S => 0
這樣的簡化。
S -> 0
和前面一樣,表示子程式可以直接結束;S => E
表示子程式可能發生錯誤,或者被強制銷燬,進入E狀態;E -> 0
表示其最終自發遷移到結束。
在這種情況下,需要使用EventEmitter的繼承類形式了。
class SpawnChild3 extends EventEmitter {
constructor(command, opts) {
super()
let x = child.spawn(command, opts)
x.on(`error`, err => { // S state
// s -> e, we are not interested in message event anymore.
this.mute()
this.on(`exit`, () => {
mute()
this.emit(`finish`)
})
// notify user
this.emit(`error`, err)
})
x.on(`message`, message => { // S state
// stay in s, but we don`t care any further error
this.message = message
this.mute()
this.on(`exit`, () => {
this.mute()
this.emit(`finish`)
})
})
x.on(`exit`, () => { // S state
this.mute()
this.emit(`finish`, new Error(`unexpected exit`))
})
this.x = x
}
// internal function
mute() {
this.x.removeAllListeners()
this.x.on(`error`, () => {})
}
destroy() {
this.mute()
this.x.kill()
}
}
stream
Node.js有非常易用的stream實現,各種stream都可以理解成一個Reactor,只是狀態更多一點。
對Readable stream,通常前面的S -> 0 | S => E -> 0
可以描述其狀態。在Node 8.x版本之後,有destroy
方法可用。
S1 -> S2 -> 0 | (S1 | S2) => E -> 0
對於Writable stream,S狀態可能需要區分S0和S1,區別是S0是end
方法尚未被呼叫的狀態,S1是end
方法已經被呼叫的狀態。
區分這兩種狀態的原因是:在使用者遇到錯誤時,它可能希望尚未end
的Writable Stream需要拋棄,但已經end
的Writable Stream可以等待其結束,不必destroy。
在這種情況下,嚴格的狀態表述可以寫成S1 -> S2 -> 0 | (S1 | S2) => E -> 0
。
Node.js裡的物件設計和Reactor Model的設計要求高度相似,但不是完全一致。一般而言stream不需要再封裝使用。實際上熟悉Reactor Model之後前面的spawn child也沒有封裝的必要,程式碼中稍微寫一下是使用了哪個狀態設計即可。
Summary
在實際工程中,實現一個Reactor應該儘可能提供destroy方法和誠實彙報結束(finish)事件;否則經過組合後的Reactor將無法完成這兩個功能。如果不從最細的粒度做好準備,在粗粒度上銷燬一個複雜元件並等到其全部內部過程都完成清理和結束,就會成為無法完成的工作。
在這一節裡我們看到了四種最基本的顯式狀態定義:
S -> 0 # 可以表示簡單的非同步過程
S => 0 # 可以表示可以取消的只讀操作或網路請求
S -> 0 | S => E -> 0 # 可以表示絕大多數以結果為目標,會發生錯誤和可銷燬的過程
S0 -> S1 -> 0 | (S0 | S1) => E -> 0 # 可以表示Writable Stream等需要操作才可以結束的過程
對於工程實踐中的絕大多數情況,這四種顯式狀態定義就夠用了。
組合(Composition)
在上一節我們把在Node.js程式碼中呼叫非同步函式或new
關鍵子定義為建立Reactor;在實際程式碼中,除了Node.js API之外的非同步函式或者EventEmitter類都是開發者自己定義的,在程式碼上,它們需要呼叫其他的非同步函式或者建立類成員例項來實現;在這一節,我們來理解父函式或者父類建立的Reactor,和它使用的子含說或者子類建立的Reactor物件之間的關係,這種關係也是動態的,我們把它定義為Reactor的組合。
組合指的是兩個Reactor之間的包含關係,借用OO程式設計裡的術語,我們把被包含的Reactor稱為成員。有時候我們也會用父Reactor和子Reactor來表述這種關係。
組合關係不是一種併發關係,這就像一個函式呼叫了另一個函式,我們不會說他們是併發的。
組合關係是一種動態關係,因為Reactor是一個過程,在執行時,它的子過程(即成員)是不斷的被建立和結束的。
例子
在Node.js中書寫一個非同步函式或者EventEmitter,都是在實現組合,這裡給兩個例子。
非同步函式
const lsdir = (dirPath, callback) => {
fs.readdir(dirPath, (err, entries) => {
if (err) return callback(err)
if (entries.length === 0) return callback(null, [])
let count = entries.length
let stats = []
entries.forEach(entry => {
let entryPath = path.join(dirPath, entry)
fs.lstat(entryPath, (err, stat) => {
if (!err) stats.push(Object.assign(stat, { entry }))
if (!--count) callback(null, stats)
})
})
})
}
這段程式碼中的執行分為兩個階段,先是readdir過程,readdir結束後(可能)併發一組lstat過程,每個readdir或者lstat過程都是一個Reactor,他們都和lsdir過程構成了組合關係。在這裡所有的Reactor都採用了非同步函式的形式,也都採用了S -> 0
的狀態定義。
Emitter
class Hash extends EventEmitter {
constructor(rs, filePath) {
super()
this.ws = fs.createWriteStream(filePath)
this.hash = crypto.createHash(`sha256`)
this.destroyed = false
this.finished = false
this.destroy = () => {
if (this.destroy || this.finished) return
this.destroyed = true
rs.removeListener(`error`, this.error)
rs.removeListener(`data`, this.data)
rs.removeListener(`end`, this.end)
this.ws.removeAllListeners()
this.ws.on(`error`, () => {})
this.ws.destroy()
}
this.error = err => (this.destroy(), this.emit(err))
this.data = data => (this.ws.write(data), this.hash.update(data))
this.end = () => (this.ws.end(), this.digest = this.hash.digest(`hex`))
rs.on(`error`, this.error)
rs.on(`data`, this.data)
rs.on(`end`, this.end)
ws.on(`error`, this.error)
ws.on(`finish`, () => (this.finished = true, this.emit(`finish`)))
}
}
這段程式碼中Hash接受兩個引數,一個Readable Stream物件和一個檔案路徑,它把stream寫入檔案,同時計算了sha256,儲存在this.digest
中。
具體的程式碼邏輯不重要,這裡Hash可以構造一個過程物件,它會在內部建立一個write stream過程和一個hash計算過程,對應的Reactor的組合關係和物件和成員的組合關係是一致的,這是使用class語法的好處。
這個例子中組合得到的Hash,採用了S -> 0 | S => E -> 0
的定義。(實際上這段程式碼有bug,在filePath指向了一個目錄而非檔案時,但作為例子這裡暫時忽視這個問題。)
Reactor Tree
上面的例子看起來更象在解釋程式碼,沒有額外邏輯;事實也是如此,我們並不試圖創造新寫法,只是從Reactor組合的角度去看程式執行時過程之間的關係。和Reactor是一個執行時的動態物件一樣,這個組合關係也是執行時動態的。
用Reactor組合去理解整個Node.js應用:
- 整個應用可以理解為“最大”的Reactor;
- Node.js的非同步API可以理解為最小的Reactor,這些Reactor的內部實現是Node.js run-time提供的,在應用中看不到,應用中只能看到它的介面,即輸入輸出;
- 在兩者之間,每一個在執行的非同步函式或建立的EventEmitter物件,都可以用Reactor組合來解釋;
一個在執行的Node.js應用,在任何時刻,都存在這個由過程和過程組合構成的層級結構(Hierarchy),在Reactor模型中我們把這個執行時的過程和過程的組合關係構成的層級結構稱為Reactor Hierarchy,或者Reactor Tree。
併發
在Reactor Tree上,我們可以獲得併發的第一個定義。
如果在任何時刻這個Tree都退化成一個單向連結串列,程式就回到了單執行緒、使用blocking i/o程式設計和執行的方式,在Process/Thread模型中,它被稱為Sequential Process;如果存在時刻,至少有一個Reactor存在兩個或兩個以上Children,或者等價的說,整個tree存在兩個或兩個以上Leaf Node,這個時候我們說它存在併發。
這個併發定義是從現象觀察得到的,它從Reactor(或過程)的組合關係來定義,具有數學意義上的嚴格性;但是它沒有區分一個Reactor所表示的過程到底處於何種狀態,是一個在執行的io,還是一個計算任務;兩者的區別在於前者幾乎不佔用CPU資源,而後者可能產生顯著的計算時間,在排程任務時,後者可能會造成其他任務的starving(長時間拿不到CPU資源),甚至導致系統完全不可用。
但是從概念模型角度說,我們接受這個併發定義,它簡單純粹,沒有歧義。
狀態通訊協議
即使不建立嚴格的模型和術語體系,在直覺上,用樸素的過程和過程組合來理解Reactor Tree的行為,我們也可以預見到在程式的執行時,每個過程在不斷的丟擲事件,它的父過程接受和處理事件,構成執行時的執行流程。
我們建立Reactor模型的目的,就是要顯式表述和充分理解這個執行流程, 它首先是設計的一部分,開發者需要給出其精確和完備的定義;其次,它在組合的過程中應該遵循一些簡單規則,使這個流程儘可能容易理解、容易設計、容易除錯、減少設計和實現的錯誤;這個執行流程不能是模糊的,或者在執行時陷入混沌(Chaos),包括在軟體工程中邏輯單元和系統規模在不斷的增長,邏輯變得越來越複雜時。
這是我們建立Reactor模型和定義Reactor組合關係的初衷;為了讓Reactor之間的互動和整個Reactor Tree的執行流程更加簡單、可靠、和有序,我們需要設計一套在組合Reactor時,Reactor之間的互動和通訊需要遵循的邏輯。我們把這套規則,稱為Reactor模型的狀態通訊協議。
嚴格的說,我們在前面定義的Reactor時,其輸入輸出、非同步、和有態特性,都是這個狀態通訊協議的一部分;但是我們這裡不追求形式化意義上的嚴格,我們把上述特性留給Reactor的介面定義,把其餘的部分作為狀態通訊協議定義。
Reactor的組合模式具有良好的遞迴特性和黑盒特性,即在各個粒度上都可以實現再組合,也可以在各個粒度上把一個Reactor當成(有態)黑盒看待,所以我們只需要定義在一層組合關係下的通訊協議。
在Reactor組合中,父子過程之間的通訊應遵循下述協議要求:
-
子Reactor如果因為內部事件觸發顯式狀態遷移,必須emit事件通知父Reactor;
- 子Reactor必須先完成狀態遷移(reaction),然後才能emit;
- emit必須是同步的;
- emit的事件必須表明子Reactor剛剛遷入的顯式狀態;
-
子Reactor如果因為外部事件觸發顯式狀態遷移,禁止emit事件;
- emit事件違反Reactor的非同步要求;
- 如果外部事件會觸發顯式狀態遷移,必須是一次強制遷移;即父Reactor呼叫子Reactor的方法強制子Reactor遷移至某個狀態,這是該方法承諾實現的,且遷移是同步的;
規則1是子Reactor發生自發(觸發來自內部)的顯式狀態遷移時的行為要求和對對父Reactor做出的狀態承諾;規則2是父Reactor強制子Reactor狀態遷移時子Reactor的行為要求和狀態承諾。這兩種情況中父子Reactor之間的互動,稱為Reactor組合中的狀態通訊。
在第一種情況中的狀態通訊的程式碼形式是非同步函式返回或者emit事件時呼叫(父Reactor提供的)callback函式;在第二種情況中是父Reactor呼叫子Reactor的(同步)方法。
在Reactor模型的組合模式下,父子Reactor的通訊必須是同步的。
連鎖反應
一個Reactor的內部事件可以產生它的內部狀態更新,這是Reaction;如果這個狀態更新導致其顯式狀態遷移,按照通訊協議設計,它應該向父Reactor丟擲事件,這是Communication,這個事件對父Reactor來說是內部事件,父Reactor同樣做出Reaction。
這個過程可以迭代下去,成為連鎖反應(chained reaction)。
在Reactor Tree上,上述連鎖反應是自下而上的;它也可以自上至下,例如在父Reactor接收到data
事件時,它判斷資料有錯誤,因此強制銷燬子Reactor;
這個也可能影響到併發的Reactor;例如父Reactor具有兩個併發成員,a和b,在a丟擲error
事件時,父Reactor決定銷燬併發的b過程(例如前面Hash的例子中,rs的錯誤處理中銷燬ws);
回到整個Reactor Tree上考慮這種連鎖反應;Node.js事件迴圈的一個tick,總是從一個基礎非同步API的callback開始,即最小的和原子的Reactor開始向整個應用丟擲事件。
這個事件可能向上傳播產生連鎖反應,在向上傳播的過程中可能產生向下傳播,但是它不可能在向下傳播的過程中再次產生向上傳播,這是Reactor的非同步特性保證的。換句話說,Reactor的非同步特性保證整個Reactor Tree的Reaction是可以結束的。
如果Reactor沒有這種非同步保證,Reactor Tree上就可能出現迴圈(cyclic)的reaction;在理論意義上說它是livelock,在實踐意義上說它是stack-overflow。
Reactive
在Reactor的基礎特性中我們定義了一個Reactive,它指的是對一個Reactor的內部實現使用狀態機方法,它響應(React)外部事件更新狀態,作為它的行為定義。
在組合模式下我們看到第二個Reactive的含義:在Reactor Tree上,執行流程是以連鎖反應的方式構成的。這和我們在Process模型下,top-down的方式控制執行流程的方式完全相反,它是bottom-up的。
在這個意義上說,不僅僅是一個Reactor單元是Reactive的,整個應用都是用Reactive的方式組成的(Composition)。
同步
我們在Reactor組合的狀態通訊協議中約定了同步通訊,同時Reactor的Reaction過程也是同步的,這導致針對任何原始事件,整個Reactor Tree的Reaction是同步完成的。
要理解為什麼這個同步特性重要,我們對比一下Process模型中的併發程式設計模型。
Process模型中定義的併發是指兩個或兩個以上Process在同時執行;基於Process模型併發程式設計只需要編寫兩個邏輯:Process的執行邏輯和Process之間的通訊邏輯(ipc)。
Process的程式設計在程式碼形式上是同步的(blocking);Process之間的通訊可以通過某種系統或run-time提供的ipc機制實現。
如果所有ipc都是同步的(blocking & unbuffered),這簡化對Process之間互動邏輯的程式設計,就像一個transport層的傳輸協議使用了stop-and-wait(ack)方式實現,但效率上這是無法使用的;而非同步實現ipc會讓編寫併發過程的互動邏輯顯著困難。
在React模型中,React組合關係的Reaction,對應了Process模型中的ipc通訊和Process之間的互動邏輯;在Reactor模型下,我們為每個過程建立了顯式狀態,定義了通訊時的狀態約定,Reactor可以同步獲得成員的狀態,可以同步的建立新過程、銷燬正在執行的過程(強制狀態遷移),那麼Reactor之間的全部互動邏輯,都可以同步完成。這會大大簡化這種邏輯的設計、實現、除錯和測試。
我們在前面的定義裡看到了,Reactor和Process是不同的概念,但是他們表示的都是過程;而併發程式設計的本質(和難點),就是在程式設計併發過程之間的互動和關係。在Reactor模型中的同步通訊,可以給這種程式設計帶來的顯著收益。
這種同步方法的相關理論研究與實踐,參見腳註4。
確定性
Reactor模型下的併發程式設計,其系統行為具有確定性:
- 在事件模型下,單執行緒的執行方式消除了任務排程導致的不確定性;
- Reactor的同步狀態遷移和通訊消除了(非同步)ipc通訊導致的不確定性;
- 雖然在任何一個時刻,整個系統無法預知下一個到來的事件會是誰?但無論是誰,我們都可以獲得
當前狀態+下一事件=下一狀態
意義上的確定性。
在併發系統程式設計中,這種確定性是寶貴的財富。
完備性
經典的狀態機方法無法scale,因為軟體的實際狀態空間太大了,而且是動態的。
在數學上我們解決無限(infinity)問題唯一的工具就是遞迴(recursion),與之等價的是歸納法(induction)。
如果我們定義了併發系統的初始狀態,它的第一個事件到來,因為Reactor模型具有上述的確定性,我們可以得到下一個系統狀態的定義;即使我們無法確切的預知下一個事件是誰,理論上我們可以考慮對所有可能的下一個事件,我們都可以給出一個具有確定性的整體狀態遷移的設計;在這個過程中應用歸納法,我們可以得到如果確定知道系統在第N個事件處理結束後的狀態,我們可以給出它在第N+1個事件到來的確定設計,這構成了在設計上的完備性。
Reactor模型中Reactor具有外部黑盒定義和組合方式保證了這種設計也是可以黑盒組合的,符合我們在軟體工程中構建系統時使用的方法論要求(Divide and Conquer, or Decomposition and Composition)。
缺點
Reactor在模型上的主要缺點是:和Process模型相比,它的reactive composition流程的書寫,不如在Process內書寫if/then/else流程語句來得方便;但它的Reaction具有同步特性,書寫上要更加便利。
Process模型和Reactor模型,或者說Thread模型和Event模型,他們構成了Duality,前者在編寫過程邏輯時簡單,在編寫過程互動邏輯時困難,後者正好相反;所以到底孰優孰劣,取決於需要程式設計的問題,和實際系統實現的種種限制。
在Node.js中使用Reactor模型程式設計,在計算任務上有限制。因為Node.js是單執行緒執行的,所有的Reaction邏輯都執行在一個CPU執行緒下,它有收益,但是在計算密集型任務時,會遇到效能瓶頸。
有這樣一些解決方法:
- Node.js和JavaScript直接支援非同步計算過程;實際執行可以把計算任務拋到其他執行緒去執行,但主執行緒仍然採用非同步函式或EventEmitter的介面形式,即計算任務也是Reactor;這種方式應該是JavaScript的正確進化路線(而不是thread和lock),但是遙遙無期;它需要JavaScript在根本上支援immutable資料結構。
- 用native add-on實現非同步計算;這是目前可用的方式,優點是效率,缺點是需要書寫C/C++程式碼;Node.js中內建的部分crypto函式是這樣實現的;
- 用子程式計算;ipc的效率是較大的問題,子程式的啟動時間和記憶體消耗也是問題,適合某些使用場景但不是好的通用問題解決方案。
小結
至此我們完整闡述完了Reactor模型,也看到了它和Process模型程式設計模式的差異與關係。
Reactor是動態物件,具有可組合特性,組合的Reactor Tree可描述整個應用的執行時狀態。
Reactor的組合特性根本上改變了執行流程的構建方式,它在單一Reactor的Reaction程式碼書寫上仍然使用程式碼控制流程,但是在Reaction級聯時成為Reactive模式;整個應用都是Reactive的。
與Process模型相反,用Reactor為過程建模,過程間的通訊與互動邏輯成為同步邏輯;這既是Reactor模型的特徵,也是這種程式設計模式的最大收益。
Reactor模型理論上具有確定性和設計完備性;實際應用中這需要嚴格遵循Reactor模型的行為、狀態與狀態通訊協議的設計規範。
併發
我們以併發作為命題和出發點,但是到目前位置並沒有深入談併發程式設計。
因為在Reactor模型下,我們已經構建的概念和規則,構成了這個模型的語法(Syntax);但是併發問題,由於Reactor模型的同步特性,它演化成了一個純粹的語義(Semantics)問題(或者說演算法問題)。
在併發中有一些常見的概念,都是在Process模型下建立的,包括fork/join,race/settle,push/pull,併發控制和排程;另外一些概念是屬於併發程式設計模型本身的,不限於是Process模型還是Reactor模型,例如responsive/latency/priority,starving,fairness等等。
這些概念在Reactor模型中可以這樣簡單陳述:
- fork: 建立多個新的Reactor;
- join: 把多個任務從已完成佇列中移除,同時把新任務推入等待佇列,或直接建立Reactor;
- race: 在Reactor的非finish事件中建立下一步邏輯的Reactor;
- settle: 在Reactor的finish事件中建立下一步邏輯的Reactor;
- push: 在Reactor完成時立刻推入下一個任務佇列;
- pull: 在Reactor完成時如果下一個任務佇列已滿,停止排程;等到下一個任務佇列可以填充時向上一任務的已完成任務佇列提取任務,然後用排程器推入執行佇列;
- concurrency control: 限制併發成員的數量,使用等待佇列;
- schedule:根據設計要求把等待佇列中的任務推入執行佇列;
- responsive:使用優先順序和併發較少的執行佇列;
- starving:使用bounded執行佇列和控制任務的單次執行時間;
- fairness:全域性排程;
好訊息是所有這些問題可以用一把錘子解決:數學上的Petri Nets模型,它直接支援併發過程,和Reactor模型的結合美好到天衣無縫。
壞訊息是你可能會感到程式碼是外星人寫的。但是他們仍然簡單、易於理解、和易於除錯。它的古怪不是模型帶來的問題,而是Node.js中事件模型和非同步過程的奇葩結合的結果。
但我們仍然認為Node.js是天賦稟異的,因為無論程式碼能力多強,你永遠不可能打敗數學;在數學上:
Concurrent System === Reactive System
Stay Tuned.
參考資料
一下參考資料中,3和4的部分章節是大力推薦想全面瞭解狀態機方法如何應用到scalable的系統的讀者閱讀的。
[1] UML State Diagram,wikipedia entry
[2] David Harel,1984,paper, Statecharts: A Visual Formalism for Complex Systesm,PDF download
[3] MIT OCW textbook,Ch4, State Machine,PDF download
[4] Edward A. Lee and Sanjit A. Seshia, 2017 book, Introduction to Embedded Systems,PDF download
[5] Albert Benveniste,1991,paper,The synchronous approach to reactive and real-time systems,PDF download
[6] Nicolas Halbwachs,1993,book,Synchronous Programming of Reactive Systems,PDF download
Footnotes
1 概念
在邏輯學家和語言學家那裡是用一種近乎苛刻的方式理解“概念”一詞的。
例如馮小剛是一個人的名字(name),這個名字所指的人,即馮小剛本人,稱為這個名字的denotation (the thing denoted)。
但是我們也可以用其他的名字指馮小剛這個人,例如“徐帆的老公”或“集結號的導演”,這兩個名字是不同的含義(sense),在邏輯學和語言學上把這個含義稱為概念(concept)。
在這裡通用的“過程”一詞,“Process模型中的Process”,和“Reactor模型中的Reactor”,它們所指(denote)的事物都是一樣的,但是它們的概念(concept)不同,取決於上下文,即它和外部其他概念的關係。
2 Explicit State和Super State
一個Reactor的Explicit State是其內部實現的狀態機中的Super State;這裡Super State符合UML State Diagram的定義,其模型來自David Harel定義的Statecharts(Hierarchical State Machine)。
在Graph的意義上說,一個Reactor的Explicit State的狀態遷移圖是內部狀態機的狀態遷移圖經過Graph Contraction操作之後得到的結果。
3 動態狀態機
動態性在軟體領域看起來是一個常識,過程和物件都可以動態建立和銷燬;但是在硬體電路設計、控制系統,最小的嵌入式系統,和類似的狀態機應用領域中並不常見。
經典狀態機模型不是動態的,Harel設計的Hierarchical State Machine所提供的Hierarchy並不是元件模型意義上的組合定義,它只是把複雜的Flat State Machine作抽象形成Hierarchical結構,是一種Graph變換,它無法scale。
經典狀態機能夠Scale的方式是通過io通訊組合(compose)狀態機單元,包括級聯(cascading),並行(parallel),反饋(feedback)或任意有向圖組合;這種狀態機模型稱io automata,它是可以scale的,也是人類能夠設計出具有72億電晶體且可以可靠工作的積體電路晶片的根本;但是它仍然是靜態的,因為晶片上的硬體單元並沒有動態建立和銷燬的可能,單元之間的通訊線路也無法動態增加。
在io automata上繼續擴充出動態能力的模型稱dynamic io automata,這是相對而言在理論研究方面較新的領域,其形式化工作在最近兩年才有相關的學術成果。
Reactor Model完全符合dynamic io automata的定義。
4 同步方法
本文所述的Reactor模型中的同步反應和通訊,在研究領域不是新課題。
最初的研究從法國Inria大學開始,文獻5是我能找到的最早的文章,法國的研究者們(主要來自Inria大學)在這個領域做了廣泛的工作,並設計了多種程式語言直接支援這種同步特性。文獻6是對這些工作全面翔實的介紹。
在文獻4中,作者把這種應用同步方法構成的系統稱為Synchronous-Reactive Models,本文所述Reactor模型和書中所述SRM完全一致。
Reactor模型的主要工作是在SRM基礎上給出了組合過程時的通訊協議約定,把SRM中的概念對應到Node.js的程式設計實踐中,並用於解決實際的併發程式設計問題。