預覽。
先給出一個基礎類程式碼。
const EventEmitter = require(`events`)
const debug = require(`debug`)(`transform`)
class Transform extends EventEmitter {
constructor (options) {
super()
this.concurrency = 1
Object.assign(this, options)
this.pending = []
this.working = []
this.finished = []
this.failed = []
this.ins = []
this.outs = []
}
push (x) {
this.pending.push(x)
this.schedule()
}
pull () {
let xs = this.finished
this.finished = []
this.schedule()
return xs
}
isBlocked () {
return !!this.failed.length || // blocked by failed
!!this.finished.length || // blocked by output buffer (lazy)
this.outs.some(t => t.isBlocked()) // blocked by outputs transform
}
isStopped () {
return !this.working.length && this.outs.every(t => t.isStopped())
}
root () {
return this.ins.length === 0 ? this : this.ins[0].root()
}
pipe (next) {
this.outs.push(next)
next.ins.push(this)
return next
}
print () {
debug(this.name,
this.pending.map(x => x.name),
this.working.map(x => x.name),
this.finished.map(x => x.name),
this.failed.map(x => x.name),
this.isStopped())
this.outs.forEach(t => t.print())
}
schedule () {
// stop working if blocked
if (this.isBlocked()) return
this.pending = this.ins.reduce((acc, t) => [...acc, ...t.pull()], this.pending)
while (this.working.length < this.concurrency && this.pending.length) {
let x = this.pending.shift()
this.working.push(x)
this.transform(x, (err, y) => {
this.working.splice(this.working.indexOf(x), 1)
if (err) {
x.error = err
this.failed.push(x)
} else {
if (this.outs.length) {
this.outs.forEach(t => t.push(y))
} else {
if (this.root().listenerCount(`data`)) {
this.root().emit(`data`, y)
} else {
this.finished.push(y)
}
}
}
this.schedule()
this.root().emit(`step`, this.name, x.name)
})
}
}
}
module.exports = Transform
這段程式碼目前還是雛形。
Transform
類的設計類似node裡的stream.Transform
,但是它的設計目的不是buffering或流效能,而是作為併發程式設計的基礎模組。
如果你熟悉流式程式設計,Transform
的設計就很容易理解;在內部,Transform
維護四個佇列:
-
pending
是input buffer -
working
是當前正在執行的任務 -
finished
是output buffer,它的目的不是為了buffer輸出,而是在沒有其他輸出辦法的時候作一下buffer。 -
failed
是失敗的任務
Transform
可以組合成DAG(Directed Acyclic Graph)使用,ins
和outs
用來儲存前置和後置Transform
的引用,pipe
方法負責設定這種雙向連結;最常見的情況是雙向連結串列,即ins
和outs
都只有一個物件。但把他們設計成陣列就可以允許fan-in, fan-out的結構。
push
和pull
是write和read的等價物。
schedule
是核心函式,它的任務是填充working
佇列。在建構函式的引數裡應該提供一個名字為transform
的非同步函式,schedule
使用這個函式執行任務,在執行結束後,根據結果把任務推到failed
佇列、推到下一個Transformer
、用root節點的emit輸出、或者推到自己的finished
佇列裡。
Transform
設計的核心思想,就是把併發任務的狀態,不使用物件屬性來編碼,只使用佇列位置來編碼;任何一個子任務,在任何時刻,僅存在於一個Transform
物件的某個佇列中。換句話說,它等於把併發任務用資源來建模。如果你熟悉restful api對過程或狀態的建模方式就很容易理解這一點。
在
Transform
中,任何transform
非同步函式的返回,都是一個step
;step
是用Transform
實現併發組合的最重要概念;
每一次transform
函式返回,都會發生改變自己的佇列或向後續的Transform
物件push
任務的動作,這個push
動作會觸發後續Transform
的schedule
方法;step
結束時自己的schedule
方法也會被呼叫,它會重新填充任務。在這些動作結束後,所有Transform
的佇列變化,就是整個組合任務狀態機的下一個狀態。
這個狀態是顯式的,可以列印出來看,對debug非常有幫助;雖然非同步i/o會讓這種狀態具有不確定性,但至少這裡堅持了組合狀態機模型在處理併發問題時的同步原則,每個step
結束時整體做一次狀態遷移,這個狀態遷移可以良好定義和觀察,這是Event模型下併發程式設計和Thread模型的重要區別。後者遇到併發邏輯引起的微妙錯誤時,很難捕捉現場分析,因為每一個Thread是黑盒。
從transform
返回開始到emit(step)
之間的一連串連鎖動作都是中間過程,最終實現一次完整的狀態遷移,這個過程必須是同步的。不應在這裡出現非同步、setImmediate或者process.nextTick等呼叫,這會帶來額外的不確定因素和極難發現和修復的bug。
在前面很長一段時間的併發程式設計實踐中,我指出過Promise的race/settle和錯誤處理邏輯在一些場景下的困難。Promise的過程邏輯不完備。我也花了很多力氣試圖在Process代數層面上把error, success, finish, race, settle, abort, pause, resume, 和他們的組合邏輯定義出來,但最終發現這很困難,因為實際程式設計中各種處理情況太多了。
所以在Transform
的設計中,這些邏輯全部被拋棄了,因為事實上它們都不是真正的基礎併發邏輯。
Transform
試圖實現組合的基礎併發邏輯只有一個:stopped
。stopped
的定義非常簡單:在一次step
結束時,所有的Transform
的working
佇列為空,就是(整體的)stopped
。這裡要再次強調前述的step
結束時同步方法的必要性,如果你在schedule
裡使用了非同步方法呼叫,那麼這個stopped
的判斷就可能是錯的,因為schedule
可能會在event loop裡放置了一個馬上就會產生新的working
任務的動作,而isStopped()
的判斷就錯了。
stopped
時,整體組合狀態可能是success, error, paused, 等等,都不難判斷,但目前程式碼尚未穩定,我不打算加入語法糖。
在blocking i/o和同步的程式設計模式下,因果鏈和程式碼書寫形式是一致的,但是在非同步程式設計下,因果是非同步和併發的,你只能去改變因,然後去觀察果,這是很多程式設計師不適應非同步程式設計的根本原因,因為它要改變思維的習慣。
使用Transform
來處理併發程式設計,仍然是在試圖重建這個因果鏈,即使他們是併發的,但是我們要有一個辦法把他們串起來;
前面說到的isStopped()
是觀察到的果,能夠影響它的因,是isBlocked()
函式,這個函式在schedule
中被呼叫,如果估值為true
,就會阻止schedule
繼續向working
佇列排程任務。
這裡寫的isBlocked()
的程式碼實現只是一個例子;可以阻止schedule
的原因可能有很多,比如出現錯誤,或者輸出buffer滿了,這些可以由實現者自己去定義。他們是policy,isBlocked()
本身是mechanism。這個策略的粒度是每個Transform
物件都可以有自己的策略。比如一個刪除臨時檔案的操作,結果是無關痛癢的,那麼它不該因為error就block。
isBlocked()
邏輯可以象示例程式碼裡那樣向下chain起來,即只要有後續任務block了,前置任務就該停下來;這在絕大多數情況下都是合理的邏輯。因為雖然我們寫的是流式處理辦法,但是我們不是在處理octet-stream,追求效能的buffering和flow control都沒什麼意義,如果前面任務在copy檔案後面的任務要移動到目標資料夾,如果目標資料夾出了問題前面快速移動了大量檔案最終也無法成功。
如果組合狀態機停止了,向其中的任何一個Transform
物件執行push或者pull操作都可以讓整個狀態機繼續動起來。從root節點push
是常見情況,從leaf節點pull
也是,向中間節點push
也是可能的;
資源建模的一個好處是你可以把狀態呈現給使用者,如果一個複製檔案的任務因為檔名衝突而fail,你還可以讓使用者選擇處理策略,例如覆蓋或者重新命名,在使用者選擇了操作之後,程式碼會從某個Transform
物件的failed
佇列中取走一個物件,修改策略引數後重新push進去,那麼這個狀態機可以繼續執行下去;這種可處理的錯誤不該成為block整個狀態機工作(複製其他檔案和資料夾)的原因,除非他們積累到可觀的數量,在Transform
模式下這些都非常容易實現,開發者可以很簡單的編寫isBlocked()
的策略;
和node的stream一樣,Transform
是lazy的,純粹的push machine可能會在中間節點buffer大量的任務,這對把任務作為流處理來說是不合適的;同時,Lazy對於停下來的組合狀態機能繼續run起來很重要,pull
方法就是這個設計目的,它的schedule
邏輯和push
一樣,只是方向相反;如果設定了Leaf節點會因為輸出緩衝而block,它就可以block整個狀態機(或者其中的一部分),這在某些情況下也是有用的功能,如果整個狀態機的輸出因為某種原因暫時無法被立刻消費掉。
abort
邏輯沒有在程式碼中實現,但它很容易,可以遍歷所有的Transform
,如果working
佇列中的物件有abort
方法,就呼叫它;這不是個立即的中止,該物件仍然要通過callback返回才能stop。如果要全域性的block,可以把所有的Leaf Node都pipe到一個sink節點去,把這個sink節點強制設定成isBlocked,可以block全部。pause
和resume
也是非常類似的邏輯。
當然你可能會遇到類似finally的邏輯是必須去執行的,即使在發生錯誤的時候,它意味著這個Transform
要向前傳遞isBlocked
資訊,但是它的Schedule方法不必停止工作。它可以一直執行到把所有佇列任務都處理完為止。
過載schedule
方法也是可能的;例如你的任務之間有前後依賴的邏輯,你就可以過載schedule
方法實現自己的排程方式。另外這裡的schedule
程式碼只基於transform函式,很顯然如果transform本身是一個Transform
物件它也應該工作,實現組合過程,包括Sequencer,Parallel等等,這些都是需要實現的。
總而言之,isBlocked
和schedule
是分開的邏輯,它們有各自不同的設計目的和使命,你可以過載它們獲得自己想要的結果。所以寫在這裡的程式碼,重要的不是他們的實現,而是其機制設計和介面設計,以及介面承諾;所有邏輯都是足夠原子化的,每個函式只做一件事,isBlocked
是因,可以根據需要選擇策略,isStopped
是果,通過step觀察和實現後續邏輯。應該避免通過向基類新增新方法來擴充套件能力,因為Transform
使用佇列和任務描述狀態,這個描述是完備的,機制也是完善的。
就像我在另一篇介紹JavaScript語言的文章裡寫的一樣,如果針對問題的模型具有完備性,即使抽象,也可以通過組合基本操作和概念獲得更多的特性,而不是在模型上增加概念,除非你認為模型不夠完備。
軟體工程中不是什麼地方都要上狀態機(automaton)這麼嚴格的模型工具,專案軟體裡寫到bug數量足夠低就可以了,但是如果你要寫系統軟體或者對正確性有苛刻要求的東西,如果你沒有用狀態機建模,那麼實際上你沒有完備設計。
當然有了完備設計也不意味著軟體沒bug了,但一個好的設計可以讓你對問題的理解、遇到問題時找到原因,有極大的幫助。
在複雜系統中,上述的同步方法狀態機組合,和Hierarchical的狀態機組合,是我們目前已知的兩種具有完備性的模型方法。但是兩者不同。雖然Transform
的組合看起來是一個Hierarchy,但是它就像你在紙上畫一棵樹,它仍然是二維的,每個step
的整體狀態聯動的遷移只是在populate一次狀態遷移的範圍,並不是幾何級數的增加狀態組合;所以我們仍然可以構築一個線性的因果鏈,每個step
因果因果這樣的繼續下去,和沒有併發的狀態機是一樣。
本質上這是數學歸納法:如果我們能證明如果n正確,那麼n+1是正確的,這就可以證明chain下去的狀態組合即使是無窮也是正確的。
第二段程式碼是使用的一個示例,這個class沒有必要,是為了保證和老程式碼介面相容,因為有一些專案內其他程式碼的依賴性就不解釋了,很容易看明白大概邏輯;列在這裡只是展示一下Transform
使用時pipe過程的程式碼樣子。
const Promise = require(`bluebird`)
const path = require(`path`)
const fs = Promise.promisifyAll(require(`fs`))
const EventEmitter = require(`events`)
const debug = require(`debug`)(`dircopy`)
const rimraf = require(`rimraf`)
const Transform = require(`../lib/transform`)
const { forceXstat } = require(`../lib/xstat`)
const fileCopy = require(`./filecopy`)
class DirCopy extends EventEmitter {
constructor (src, tmp, files, getDirPath) {
super()
let dst = getDirPath()
let pipe = new Transform({
name: `copy`,
concurrency: 4,
transform: (x, callback) =>
(x.abort = fileCopy(path.join(src, x.name), path.join(tmp, x.name),
(err, fingerprint) => {
delete x.abort
if (err) {
callback(err)
} else {
callback(null, (x.fingerprint = fingerprint, x))
}
}))
}).pipe(new Transform({
name: `stamp`,
transform: (x, callback) =>
forceXstat(path.join(tmp, x.name), { hash: x.fingerprint },
(err, xstat) => err
? callback(err)
: callback(null, (x.uuid = xstat.uuid, x)))
})).pipe(new Transform({
name: `move`,
transform: (x, callback) =>
fs.link(path.join(tmp, x.name), path.join(dst, x.name), err => err
? callback(err)
: callback(null, x))
})).pipe(new Transform({
name: `remove`,
transform: (x, callback) => rimraf(path.join(tmp, x.name), () => callback(null))
})).root()
let count = 0
// drain data
pipe.on(`data`, data => this.emit(`data`, data))
pipe.on(`step`, (tname, xname) => {
debug(`------------------------------------------`)
debug(`step ${count++}`, tname, xname)
pipe.print()
if (pipe.isStopped()) this.emit(`stopped`)
})
files.forEach(name => pipe.push({ name }))
pipe.print()
this.pipe = pipe
}
}
module.exports = DirCopy