白洁血战Node.js并发编程 01 状态机
這一篇是這個系列的開篇,沒有任何高級內容,就講講狀態(tài)機。
狀態(tài)機
狀態(tài)機是模型層面的概念,與編程語言無關。它的目的是為對象行為建模,屬于設計范疇。它的基礎概念是狀態(tài)(state)和事件(event)。
對象的內部結構描述為一組狀態(tài)S1, S2, ... Sn,它的行為的trigger,包括內部的和外部的,描述成為一組事件E1, E2, ... En,在任何狀態(tài)下,任何事件到來,對象狀態(tài)的改變用Sx -> Sy的狀態(tài)遷移(State Transition)來描述,這個狀態(tài)遷移就是對象的行為(behavior)。
對對象行為的完備定義就是{ S } x { E }的矩陣,如果存在(Sx, Ey)的組合未定義行為,這個對象行為模型在設計層面上就不完備,當然實際的代碼不可能沒有行為,這往往就是錯誤發(fā)生的地方。
狀態(tài)機具有良好的可實現(xiàn)性和可測試性。完備定義的狀態(tài)機很容易寫出對應的代碼,也很容易遍歷全部的狀態(tài)遷移過程完成測試,當然這只意味著代碼實現(xiàn)和設計(模型)相符,并不意味著設計是正確的。
設計的正確性是一個復雜的多的話題,嚴格的定義是設計符合Specification。什么是符合Specification?要去看Robin Milner, Tony Hoare, Leslie Lamport等人的書了,老實說我也不懂,所以就此打住。
這篇文章不會詳細介紹狀態(tài)機,網(wǎng)上有非常多的資料,四人幫的書上有State Pattern - OO語言下的狀態(tài)機實現(xiàn),UML有State Diagram,是非常好的圖示工具;這里只給出一個代碼例子,對照這個實例幫助理解狀態(tài)機模型的代碼實現(xiàn)。
一個例子
假定我們要解決這樣一個任務:我們有一個模塊是為了存儲(save)一個文件,寫狀態(tài)機的目的是為了解決并發(fā)操作時排隊存儲的請求,因為請求是并發(fā)的,如果寫入文件的io操作也是并發(fā)的話,這個文件可能被損壞。這是一個非常常見的應用場景。
這個模塊定義有三種狀態(tài):
Idle, 這是不工作的狀態(tài);
Pending,這是等待工作的狀態(tài),等待的目的是為了,如果在很短的時間內出現(xiàn)連續(xù)多次的寫入請求,我們只寫入最后一個,減少io操作的次數(shù);
Working,該狀態(tài)下在執(zhí)行寫入操作,如果在執(zhí)行io操作的時候收到寫入請求,我們把請求內容保存在一個臨時的位置;
Idle狀態(tài)沒有任何特殊資源,只有一個save請求事件;當有save請求時,它遷移到Pending狀態(tài)。
Pending狀態(tài)具有的特殊資源是一個timer,它可能有兩個事件:來自外部的save請求,和來自內部的timeout。在JavaScript代碼里,這是一個callback,但是我們在狀態(tài)機模型中要把他理解為事件。在Pending狀態(tài)中如果有save請求,不發(fā)生狀態(tài)遷移,新的請求數(shù)據(jù)會覆蓋舊的版本,原來的timer會被清除,重新開始新的timer。在timeout發(fā)生時,遷移到Working狀態(tài)。
Working狀態(tài)在進入時會啟動存儲文件的操作,它可能有兩個事件:來自外部的save請求,和來自內部的保存文件操作的異步返回,同樣的,它也被理解為一個(內部)事件。當外部的save請求到來時,該請求被保存在內部的next變量里;當文件操作返回時:
-
如果操作成功
如果存在next,向Pending狀態(tài)遷移
如果不存在next,向Idle狀態(tài)遷移
-
如果操作失敗
如果存在next,向Pending狀態(tài)遷移,用next作為數(shù)據(jù)
如果不存在next,也向Pending狀態(tài)遷移,仍然使用當前數(shù)據(jù),相當于等待后retry
我偷個懶,沒給出圖示,實際上這樣的語言描述不如State Diagram來得直觀。下面的表格是上述語言描述的歸納,史稱狀態(tài)遷移表(State Transition Table),有了State Diagram或者State Transition Table,任何人寫出來的代碼都一樣。為了表述清晰,表中把Working狀態(tài)的文件操作返回分成了兩個事件:success和error。
| Idle | -> Pending | n/a | n/a | n/a |
| Pending | overwrite data, restart timer | -> Working | n/a | n/a |
| Working | set next | n/a | if next, -> Pending; else -> Idle | -> Pending(next ? next : data) |
代碼
下面是代碼,首先有個base class,三個狀態(tài)都繼承自這個base class:
class State {constructor(ctx) {this.ctx = ctx}setState(nextState, ...args) {this.exit()this.ctx.state = new nextState(this.ctx, ...args)}exit() {} }在狀態(tài)機的代碼實現(xiàn)上,標志性的方法名稱是setState,它負責狀態(tài)遷移;其次是enter和exit,分別對應進入該狀態(tài)和離開該狀態(tài)。
狀態(tài)機模式(State Pattern)的一個顯著的編程收益是:每個狀態(tài)都有自己的資源,在遷入該狀態(tài)的時候建立資源,在離開該狀態(tài)的時候釋放資源,這很容易保證資源的正確使用。
在上述代碼中,constructor充當了enter邏輯的角色,所以沒有提供獨立的enter方法;JavaScript Class是一個語法糖,沒有和constructor相對應的destructor,所以我們這里寫一個exit函數(shù),如果繼承類里沒有exit邏輯,這個基類上的方法就是一個fallback。
ctx是一個外部容器,相當于所有狀態(tài)對象的上下文(context),它同時具有一個叫做state的成員,該成員是一個Idle,Pending,或者Working類的實例;無論ctx.state是哪個狀態(tài),ctx都把save方法forward到state上,這樣寫是一個很標準的State Pattern。
setState的實現(xiàn)有點tricky,是JavaScript的特色。它首先調用當前類的exit函數(shù)遷出狀態(tài),然后使用new來構造下一個類,這意味著第一個參數(shù)nextState是一個構造函數(shù);后面的參數(shù)使用了spread operator,把這些參數(shù)傳入了構造函數(shù),同時把新對象安裝到了ctx,即把自己替換了;這不是唯一的做法,是比較簡潔的一種寫法。
Idle類的實現(xiàn)非常簡單,在save的時候用data作為參數(shù)構造了Pending對象。
class Idle extends State{save(data) {this.setState(Pending, data)} }Pending類的save方法里保存了data和啟動timer。它的構造函數(shù)重用了save方法。因為JavaScript的clearTimeout方法是安全的,這樣寫沒什么問題。
exit函數(shù)實際上沒有必要,但這樣書寫是推薦的,它確保資源清理,如果未來設計變更出現(xiàn)其他的狀態(tài)遷出邏輯,這個代碼就有用了。
timeout時Pending向Working狀態(tài)遷移。
class Pending extends State {constructor(ctx, data) {super(ctx)this.save(data)}save(data) {clearTimeout(this.timer)this.data = data this.timer = setTimeout(() => {this.setState(Working, this.data) }, this.ctx.delay)}exit() {clearTimeout(this.timer)} }Working代碼稍微多點,但是對照狀態(tài)遷移表很容易讀懂。不贅述每個方法了。保存文件的操作采用了先寫入臨時文件然后重命名的做法,這是保證文件完整性的常規(guī)做法,系統(tǒng)即使斷電也不會損壞磁盤文件。
class Working extends State {constructor(ctx, data) { super(ctx)this.data = data // console.log('start saving data', data)let tmpfile = path.join(this.ctx.tmpdir, UUID.v4())fs.writeFile(tmpfile, JSON.stringify(this.data), err => {if (err) return this.error(err)fs.rename(tmpfile, this.ctx.target, err => {// console.log('finished saving data', data, err)if (err) return this.error(err)this.success()}) })} error(e) {// console.log('error writing persistent file', e)if (this.next) this.setState(Pending, this.next)elsethis.setState(Pending, this.data)}success() {if (this.next)this.setState(Pending, this.next)else this.setState(Idle)}save(data) {// console.log('Working save', data)this.next = data} }最后是ctx,我們在實際項目中稱之為Persistence。它初始化時設置state為Idle狀態(tài);把所有的save操作都forward到內部對象state上。
class Persistence {constructor(target, tmpdir, delay) {this.target = target this.tmpdir = tmpdirthis.delay = delay || 500this.state = new Idle(this) }save(data) {this.state.save(data)} }要點
這一篇粗略的講了兩個問題:狀態(tài)機模型和狀態(tài)機模式(State Pattern)。他們兩個不是一回事。
狀態(tài)機模式是一種寫法,上述寫法不唯一;不使用Class,僅僅在Persistence類中使用(枚舉)變量表示狀態(tài)也是可以的,使用Class則相當于用變量類型來代表狀態(tài)。
狀態(tài)機模式的顯著優(yōu)點在于:
不同狀態(tài)的資源和行為邏輯分開
在setState, enter, exit等標志性方法中不需要使用if / then或switch語句
在對象行為定義發(fā)生變化時,修改容易,不易犯錯誤;感謝enter和exit的封裝,它強制了資源回收邏輯
狀態(tài)機模型的意義對后面的內容更為重要。上面的例子具有這樣幾個特征:
狀態(tài)具有顯式定義
事件內外有別
外部事件對所有狀態(tài)成立,因此Persistence類的使用非常簡單,從外部其實看不到內部有什么狀態(tài)定義,黑盒意義上說,Persistence是無態(tài)的,這對使用便利性來說極為重要;
內部事件僅僅對某些狀態(tài)成立,所有異步函數(shù)的返回都理解為事件,而且是唯一的內部事件;
從并發(fā)角度說,Persistence類是一個同步器(Synchronizer),即并發(fā)的save操作在這里被排序執(zhí)行了;當然我們沒有設計更復雜的邏輯,例如任務隊列,但顯然那不是很難;
問題
純粹的狀態(tài)機(automata)對于并發(fā)編程是無力的,這是一種共識,因為并發(fā)帶來的狀態(tài)組合會迅速爆炸狀態(tài)空間,我們要找到辦法對付這個問題,此其一。
其二,實際的程序模塊組合時常見包含關系,用經(jīng)典的狀態(tài)機模型會產生組合狀態(tài)機(Hierarchical State Machine),它的代碼書寫遠比上述例子的Flat State Machine難寫,除非在語言一級或者有類庫支持,否則可讀性和可維護性都很差,設計變更時代碼改動幅度非常大,不是解決常見問題的好辦法,雖然在一些特殊應用領域卓有建樹,例如嵌入式設備的通訊協(xié)議棧。
事件(Event)和線程(Thread)是形式上對立,但是數(shù)學上對等,的兩個編程方式。兩者各有利弊,戰(zhàn)爭也是古老的,你在網(wǎng)絡上很容易搜索到Why Event (Model) is Evil或者Why Thread (Model) is Evil的學術文章,都有大量的支持者。
Node.js的與眾不同之處在于它的強制non-blocking i/o設計。這給習慣Thread編程的開發(fā)者制造了麻煩,所以在過去的幾年里新的過程原語被發(fā)明出來解決這個問題,包括promise,generator,async/await。bluebird的使用者越來越多,而caolan的曾經(jīng)很流行的async庫用戶越來越少。
但是眾所周知JavaScript語言是事件模型的。在基礎特性上尋求類thread編程形式去解決一切問題本身就是表里不一的,而且promise和async/await的實現(xiàn)本身也有很多不盡人意的地方。
這讓我們倒回來思考兩個問題:
尋求各種CPS(Continuation Passing Style)是解決non-blocking i/o的必經(jīng)之路嗎?
事件和狀態(tài)機模型真的沒有辦法寫規(guī)模化的并發(fā)程序嗎?
Node原作者Ryan Dahl最近在一次訪談里說了他對Node的看法。他說在最初的兩三年中他是狂熱的支持Node的強制non-blocking i/o設計的,達到那種認為“原來我們都做錯了”的程度,但是慢慢的他的態(tài)度發(fā)生了轉變,尤其是在接觸了Go語言之后;現(xiàn)在他的看法是,最初他以為Node可以做到是end-all或者for-all的,但是現(xiàn)在他沒那么有信心了,在并發(fā)編程上他認為Go可能是更好的設計。
我的個人觀點,談Node必談callback hell的開發(fā)者,并不熟悉在Event Model下的并發(fā)編程技術,promise和async/await本質上,絕大多數(shù)情況下是在serialize過程,如果只是serialize,那么結果和blocking i/o的編程并不會有區(qū)別。Promise對parallel的支持很有限,它只是在serial的過程序列上偶爾撒一點parallel的flavor。而且如果你喜歡的就是Thread Model,那么就應該選擇對它有良好支持的編程語言或環(huán)境,例如Go或者fibjs。
如果你像我一樣,喜歡JavaScript語言的簡單,喜歡Event Model的簡單,而不只是因為Node有良好的生態(tài)圈和海量的npm包可用而選擇了Node——如果你只是因為這兩點選擇了Node,你肯定會后悔的——那么擺在我們面前的問題就是:事件模型,顯式狀態(tài),non-blocking i/o,我們能不能找到一種辦法,一種end-all和for-all的辦法,最好能夠直接體現(xiàn)在代碼形式上,或者至少體現(xiàn)在一個簡單、直覺、不易錯、同時保持經(jīng)典狀態(tài)機模型的完備性的Mental Model上,能夠為復雜的并發(fā)編程問題建模和書寫代碼?
在這里經(jīng)典狀態(tài)機模式可以給我們一個簡單啟迪:我們不僅可以用值來表示狀態(tài),我們也可以用對象類型表示狀態(tài),而且有明顯的收益。同樣的,在事件模型下解決并發(fā)問題的關鍵,就是把這個設計繼續(xù)向前推進一步,我們還可以用結構來表示狀態(tài)。具體怎么寫和怎么思考建模,則是這個系列文章的主旨。
這在數(shù)學層面上非常容易理解:所謂并發(fā)編程,它就是在structure過程(Rob Pike)。函數(shù)或者類函數(shù),包括promise,async function,generator,coroutine,他們是Thread Model下的(黑盒)原語和原語組合,對應的,我們要找到事件模型下的顯式狀態(tài)方法來應對這個問題,如果能做到這一點,我們就可以回到純粹的事件模型下編寫程序。
這個結果并不難,但是,它也確實有一段路要走,我們需要仔細梳理過程原語的優(yōu)點缺點,梳理并發(fā)編程的本質,梳理常見問題的各種編程方式,最后回到我們的事件模型和狀態(tài)機上來。等這個系列寫完,你也讀完,我向你保證,你再次看到callback函數(shù)時會覺得原來它那么簡單且美。
下一篇我們開始談并發(fā)編程,敬請期待。
與50位技術專家面對面20年技術見證,附贈技術全景圖總結
以上是生活随笔為你收集整理的白洁血战Node.js并发编程 01 状态机的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 《大数据、小数据、无数据:网络世界的数据
- 下一篇: 敏捷与DevOps整合之道