摘要:狀態(tài)機狀態(tài)機是模型層面的概念,與編程語言無關(guān)。狀態(tài)機具有良好的可實現(xiàn)性和可測試性。在代碼里,這是一個,但是我們在狀態(tài)機模型中要把他理解為事件。
這一篇是這個系列的開篇,沒有任何高級內(nèi)容,就講講狀態(tài)機。
狀態(tài)機狀態(tài)機是模型層面的概念,與編程語言無關(guān)。它的目的是為對象行為建模,屬于設(shè)計范疇。它的基礎(chǔ)概念是狀態(tài)(state)和事件(event)。
對象的內(nèi)部結(jié)構(gòu)描述為一組狀態(tài)S1, S2, ... Sn,它的行為的trigger,包括內(nèi)部的和外部的,描述成為一組事件E1, E2, ... En,在任何狀態(tài)下,任何事件到來,對象狀態(tài)的改變用Sx -> Sy的狀態(tài)遷移(State Transition)來描述,這個狀態(tài)遷移就是對象的行為(behavior)。
對對象行為的完備定義就是{ S } x { E }的矩陣,如果存在(Sx, Ey)的組合未定義行為,這個對象行為模型在設(shè)計層面上就不完備,當(dāng)然實際的代碼不可能沒有行為,這往往就是錯誤發(fā)生的地方。
狀態(tài)機具有良好的可實現(xiàn)性和可測試性。完備定義的狀態(tài)機很容易寫出對應(yīng)的代碼,也很容易遍歷全部的狀態(tài)遷移過程完成測試,當(dāng)然這只意味著代碼實現(xiàn)和設(shè)計(模型)相符,并不意味著設(shè)計是正確的。
設(shè)計的正確性是一個復(fù)雜的多的話題,嚴(yán)格的定義是設(shè)計符合Specification。什么是符合Specification?要去看Robin Milner, Tony Hoare, Leslie Lamport等人的書了,老實說我也不懂,所以就此打住。
這篇文章不會詳細(xì)介紹狀態(tài)機,網(wǎng)上有非常多的資料,四人幫的書上有State Pattern - OO語言下的狀態(tài)機實現(xiàn),UML有State Diagram,是非常好的圖示工具;這里只給出一個代碼例子,對照這個實例幫助理解狀態(tài)機模型的代碼實現(xiàn)。
一個例子假定我們要解決這樣一個任務(wù):我們有一個模塊是為了存儲(save)一個文件,寫狀態(tài)機的目的是為了解決并發(fā)操作時排隊存儲的請求,因為請求是并發(fā)的,如果寫入文件的io操作也是并發(fā)的話,這個文件可能被損壞。這是一個非常常見的應(yīng)用場景。
這個模塊定義有三種狀態(tài):
Idle, 這是不工作的狀態(tài);
Pending,這是等待工作的狀態(tài),等待的目的是為了,如果在很短的時間內(nèi)出現(xiàn)連續(xù)多次的寫入請求,我們只寫入最后一個,減少io操作的次數(shù);
Working,該狀態(tài)下在執(zhí)行寫入操作,如果在執(zhí)行io操作的時候收到寫入請求,我們把請求內(nèi)容保存在一個臨時的位置;
Idle狀態(tài)沒有任何特殊資源,只有一個save請求事件;當(dāng)有save請求時,它遷移到Pending狀態(tài)。
Pending狀態(tài)具有的特殊資源是一個timer,它可能有兩個事件:來自外部的save請求,和來自內(nèi)部的timeout。在JavaScript代碼里,這是一個callback,但是我們在狀態(tài)機模型中要把他理解為事件。在Pending狀態(tài)中如果有save請求,不發(fā)生狀態(tài)遷移,新的請求數(shù)據(jù)會覆蓋舊的版本,原來的timer會被清除,重新開始新的timer。在timeout發(fā)生時,遷移到Working狀態(tài)。
Working狀態(tài)在進(jìn)入時會啟動存儲文件的操作,它可能有兩個事件:來自外部的save請求,和來自內(nèi)部的保存文件操作的異步返回,同樣的,它也被理解為一個(內(nèi)部)事件。當(dāng)外部的save請求到來時,該請求被保存在內(nèi)部的next變量里;當(dāng)文件操作返回時:
如果操作成功
如果存在next,向Pending狀態(tài)遷移
如果不存在next,向Idle狀態(tài)遷移
如果操作失敗
如果存在next,向Pending狀態(tài)遷移,用next作為數(shù)據(jù)
如果不存在next,也向Pending狀態(tài)遷移,仍然使用當(dāng)前數(shù)據(jù),相當(dāng)于等待后retry
我偷個懶,沒給出圖示,實際上這樣的語言描述不如State Diagram來得直觀。下面的表格是上述語言描述的歸納,史稱狀態(tài)遷移表(State Transition Table),有了State Diagram或者State Transition Table,任何人寫出來的代碼都一樣。為了表述清晰,表中把Working狀態(tài)的文件操作返回分成了兩個事件:success和error。
StateEvent | Save | Timeout | 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)上,標(biāo)志性的方法名稱是setState,它負(fù)責(zé)狀態(tài)遷移;其次是enter和exit,分別對應(yīng)進(jìn)入該狀態(tài)和離開該狀態(tài)。
狀態(tài)機模式(State Pattern)的一個顯著的編程收益是:每個狀態(tài)都有自己的資源,在遷入該狀態(tài)的時候建立資源,在離開該狀態(tài)的時候釋放資源,這很容易保證資源的正確使用。
在上述代碼中,constructor充當(dāng)了enter邏輯的角色,所以沒有提供獨立的enter方法;JavaScript Class是一個語法糖,沒有和constructor相對應(yīng)的destructor,所以我們這里寫一個exit函數(shù),如果繼承類里沒有exit邏輯,這個基類上的方法就是一個fallback。
ctx是一個外部容器,相當(dāng)于所有狀態(tài)對象的上下文(context),它同時具有一個叫做state的成員,該成員是一個Idle,Pending,或者Working類的實例;無論ctx.state是哪個狀態(tài),ctx都把save方法forward到state上,這樣寫是一個很標(biāo)準(zhǔn)的State Pattern。
setState的實現(xiàn)有點tricky,是JavaScript的特色。它首先調(diào)用當(dāng)前類的exit函數(shù)遷出狀態(tài),然后使用new來構(gòu)造下一個類,這意味著第一個參數(shù)nextState是一個構(gòu)造函數(shù);后面的參數(shù)使用了spread operator,把這些參數(shù)傳入了構(gòu)造函數(shù),同時把新對象安裝到了ctx,即把自己替換了;這不是唯一的做法,是比較簡潔的一種寫法。
Idle類的實現(xiàn)非常簡單,在save的時候用data作為參數(shù)構(gòu)造了Pending對象。
class Idle extends State{ save(data) { this.setState(Pending, data) } }
Pending類的save方法里保存了data和啟動timer。它的構(gòu)造函數(shù)重用了save方法。因為JavaScript的clearTimeout方法是安全的,這樣寫沒什么問題。
exit函數(shù)實際上沒有必要,但這樣書寫是推薦的,它確保資源清理,如果未來設(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) else this.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。它初始化時設(shè)置state為Idle狀態(tài);把所有的save操作都forward到內(nèi)部對象state上。
class Persistence { constructor(target, tmpdir, delay) { this.target = target this.tmpdir = tmpdir this.delay = delay || 500 this.state = new Idle(this) } save(data) { this.state.save(data) } }要點
這一篇粗略的講了兩個問題:狀態(tài)機模型和狀態(tài)機模式(State Pattern)。他們兩個不是一回事。
狀態(tài)機模式是一種寫法,上述寫法不唯一;不使用Class,僅僅在Persistence類中使用(枚舉)變量表示狀態(tài)也是可以的,使用Class則相當(dāng)于用變量類型來代表狀態(tài)。
狀態(tài)機模式的顯著優(yōu)點在于:
不同狀態(tài)的資源和行為邏輯分開
在setState, enter, exit等標(biāo)志性方法中不需要使用if / then或switch語句
在對象行為定義發(fā)生變化時,修改容易,不易犯錯誤;感謝enter和exit的封裝,它強制了資源回收邏輯
狀態(tài)機模型的意義對后面的內(nèi)容更為重要。上面的例子具有這樣幾個特征:
狀態(tài)具有顯式定義
事件內(nèi)外有別
外部事件對所有狀態(tài)成立,因此Persistence類的使用非常簡單,從外部其實看不到內(nèi)部有什么狀態(tài)定義,黑盒意義上說,Persistence是無態(tài)的,這對使用便利性來說極為重要;
內(nèi)部事件僅僅對某些狀態(tài)成立,所有異步函數(shù)的返回都理解為事件,而且是唯一的內(nèi)部事件;
從并發(fā)角度說,Persistence類是一個同步器(Synchronizer),即并發(fā)的save操作在這里被排序執(zhí)行了;當(dāng)然我們沒有設(shè)計更復(fù)雜的邏輯,例如任務(wù)隊列,但顯然那不是很難;
問題純粹的狀態(tài)機(automata)對于并發(fā)編程是無力的,這是一種共識,因為并發(fā)帶來的狀態(tài)組合會迅速爆炸狀態(tài)空間,我們要找到辦法對付這個問題,此其一。
其二,實際的程序模塊組合時常見包含關(guān)系,用經(jīng)典的狀態(tài)機模型會產(chǎn)生組合狀態(tài)機(Hierarchical State Machine),它的代碼書寫遠(yuǎn)比上述例子的Flat State Machine難寫,除非在語言一級或者有類庫支持,否則可讀性和可維護(hù)性都很差,設(shè)計變更時代碼改動幅度非常大,不是解決常見問題的好辦法,雖然在一些特殊應(yīng)用領(lǐng)域卓有建樹,例如嵌入式設(shè)備的通訊協(xié)議棧。
事件(Event)和線程(Thread)是形式上對立,但是數(shù)學(xué)上對等,的兩個編程方式。兩者各有利弊,戰(zhàn)爭也是古老的,你在網(wǎng)絡(luò)上很容易搜索到Why Event (Model) is Evil或者Why Thread (Model) is Evil的學(xué)術(shù)文章,都有大量的支持者。
Node.js的與眾不同之處在于它的強制non-blocking i/o設(shè)計。這給習(xí)慣Thread編程的開發(fā)者制造了麻煩,所以在過去的幾年里新的過程原語被發(fā)明出來解決這個問題,包括promise,generator,async/await。bluebird的使用者越來越多,而caolan的曾經(jīng)很流行的async庫用戶越來越少。
但是眾所周知JavaScript語言是事件模型的。在基礎(chǔ)特性上尋求類thread編程形式去解決一切問題本身就是表里不一的,而且promise和async/await的實現(xiàn)本身也有很多不盡人意的地方。
這讓我們倒回來思考兩個問題:
尋求各種CPS(Continuation Passing Style)是解決non-blocking i/o的必經(jīng)之路嗎?
事件和狀態(tài)機模型真的沒有辦法寫規(guī)?;牟l(fā)程序嗎?
Node原作者Ryan Dahl最近在一次訪談里說了他對Node的看法。他說在最初的兩三年中他是狂熱的支持Node的強制non-blocking i/o設(shè)計的,達(dá)到那種認(rèn)為“原來我們都做錯了”的程度,但是慢慢的他的態(tài)度發(fā)生了轉(zhuǎn)變,尤其是在接觸了Go語言之后;現(xiàn)在他的看法是,最初他以為Node可以做到是end-all或者for-all的,但是現(xiàn)在他沒那么有信心了,在并發(fā)編程上他認(rèn)為Go可能是更好的設(shè)計。
我的個人觀點,談Node必談callback hell的開發(fā)者,并不熟悉在Event Model下的并發(fā)編程技術(shù),promise和async/await本質(zhì)上,絕大多數(shù)情況下是在serialize過程,如果只是serialize,那么結(jié)果和blocking i/o的編程并不會有區(qū)別。Promise對parallel的支持很有限,它只是在serial的過程序列上偶爾撒一點parallel的flavor。而且如果你喜歡的就是Thread Model,那么就應(yīng)該選擇對它有良好支持的編程語言或環(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ù)雜的并發(fā)編程問題建模和書寫代碼?
在這里經(jīng)典狀態(tài)機模式可以給我們一個簡單啟迪:我們不僅可以用值來表示狀態(tài),我們也可以用對象類型表示狀態(tài),而且有明顯的收益。同樣的,在事件模型下解決并發(fā)問題的關(guān)鍵,就是把這個設(shè)計繼續(xù)向前推進(jìn)一步,我們還可以用結(jié)構(gòu)來表示狀態(tài)。具體怎么寫和怎么思考建模,則是這個系列文章的主旨。
這在數(shù)學(xué)層面上非常容易理解:所謂并發(fā)編程,它就是在structure過程(Rob Pike)。函數(shù)或者類函數(shù),包括promise,async function,generator,coroutine,他們是Thread Model下的(黑盒)原語和原語組合,對應(yīng)的,我們要找到事件模型下的顯式狀態(tài)方法來應(yīng)對這個問題,如果能做到這一點,我們就可以回到純粹的事件模型下編寫程序。
這個結(jié)果并不難,但是,它也確實有一段路要走,我們需要仔細(xì)梳理過程原語的優(yōu)點缺點,梳理并發(fā)編程的本質(zhì),梳理常見問題的各種編程方式,最后回到我們的事件模型和狀態(tài)機上來。等這個系列寫完,你也讀完,我向你保證,你再次看到callback函數(shù)時會覺得原來它那么簡單且美。
下一篇我們開始談并發(fā)編程,敬請期待。
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://systransis.cn/yun/88452.html
摘要:在這些動作結(jié)束后,所有的隊列變化,就是整個組合任務(wù)狀態(tài)機的下一個狀態(tài)。如果組合狀態(tài)機停止了,向其中的任何一個對象執(zhí)行或者操作都可以讓整個狀態(tài)機繼續(xù)動起來。 預(yù)覽。 先給出一個基礎(chǔ)類代碼。 const EventEmitter = require(events) const debug = require(debug)(transform) class Transform extend...
摘要:前端日報精選譯用搭建探索生命周期中的匿名遞歸瀏覽器端機器智能框架深入理解筆記和屬性中文上海線下活動前端工程化架構(gòu)實踐滬江技術(shù)沙龍掘金周二放送追加視頻知乎專欄第期聊一聊前端自動化測試上雙關(guān)語來自前端的小段子,你看得懂嗎眾成翻 2017-08-10 前端日報 精選 [譯] 用 Node.js 搭建 API Gateway探索 Service Worker 「生命周期」JavaScript ...
摘要:匿名函數(shù)是我們喜歡的一個重要原因,也是,它們分別消除了很多代碼細(xì)節(jié)上需要命名變量名或函數(shù)名的需要。這個匿名函數(shù)內(nèi),有更多的操作,根據(jù)的結(jié)果針對目錄和文件做了不同處理,而且有遞歸。 能和微博上的 @響馬 (fibjs作者)掰扯這個問題是我的榮幸。 事情緣起于知乎上的一個熱貼,諸神都發(fā)表了意見: https://www.zhihu.com/questio... 這一篇不是要說明白什么是as...
摘要:前端日報精選瀏覽器渲染過程與性能優(yōu)化新版采用新引擎,速度是舊版的倍,名字和也變了中文與的使用個人文章在對比中理解掘金白潔血戰(zhàn)并發(fā)編程異步英文 2017-10-05 前端日報 精選 瀏覽器渲染過程與性能優(yōu)化Firefox 新版采用新引擎,速度是舊版的 2 倍,名字和 Logo 也變了8 Key React Component DecisionsThe Intl.PluralRules A...
摘要:的科學(xué)定義是或者,它的標(biāo)志性原語是。能解決一類對語言的實現(xiàn)來說特別無力的狀態(tài)機模型流程即狀態(tài)。容易實現(xiàn)是需要和的一個重要原因。 前面寫了一篇,寫的很粗,這篇講講一些細(xì)節(jié)。實際上Fiber/Coroutine vs Async/Await之爭不是一個簡單的continuation如何實現(xiàn)的問題,而是兩個完全不同的problem和solution domain。 Event Model 我...
閱讀 671·2021-10-09 09:41
閱讀 654·2019-08-30 15:53
閱讀 1082·2019-08-30 15:53
閱讀 1217·2019-08-30 11:01
閱讀 1575·2019-08-29 17:31
閱讀 994·2019-08-29 14:05
閱讀 1722·2019-08-29 12:49
閱讀 417·2019-08-28 18:17