白洁血战Node.js并发编程 01 状态机
这一篇是这个系列的开篇,没有任何高级内容,就讲讲状态机。
状态机
状态机是模型层面的概念,与编程语言无关。它的目的是为对象行为建模,属于设计范畴。它的基础概念是状态(state)和事件(event)。
对象的内部结构描述为一组状态S1, S2, ... Sn,它的行为的trigger,包括内部的和外部的,描述成为一组事件E1, E2, ... En,在任何状态下,任何事件到来,对象状态的改变用Sx -> Sy的状态迁移(State Transition)来描述,这个状态迁移就是对象的行为(behavior)。
对对象行为的完备定义就是{ S } x { E }的矩阵,如果存在(Sx, Ey)的组合未定义行为,这个对象行为模型在设计层面上就不完备,当然实际的代码不可能没有行为,这往往就是错误发生的地方。
状态机具有良好的可实现性和可测试性。完备定义的状态机很容易写出对应的代码,也很容易遍历全部的状态迁移过程完成测试,当然这只意味着代码实现和设计(模型)相符,并不意味着设计是正确的。
设计的正确性是一个复杂的多的话题,严格的定义是设计符合Specification。什么是符合Specification?要去看Robin Milner, Tony Hoare, Leslie Lamport等人的书了,老实说我也不懂,所以就此打住。
这篇文章不会详细介绍状态机,网上有非常多的资料,四人帮的书上有State Pattern - OO语言下的状态机实现,UML有State Diagram,是非常好的图示工具;这里只给出一个代码例子,对照这个实例帮助理解状态机模型的代码实现。
一个例子
假定我们要解决这样一个任务:我们有一个模块是为了存储(save)一个文件,写状态机的目的是为了解决并发操作时排队存储的请求,因为请求是并发的,如果写入文件的io操作也是并发的话,这个文件可能被损坏。这是一个非常常见的应用场景。
这个模块定义有三种状态:
Idle
, 这是不工作的状态;Pending
,这是等待工作的状态,等待的目的是为了,如果在很短的时间内出现连续多次的写入请求,我们只写入最后一个,减少io操作的次数;Working
,该状态下在执行写入操作,如果在执行io操作的时候收到写入请求,我们把请求内容保存在一个临时的位置;
Idle
状态没有任何特殊资源,只有一个save请求事件;当有save请求时,它迁移到Pending
状态。
Pending
状态具有的特殊资源是一个timer,它可能有两个事件:来自外部的save请求,和来自内部的timeout。在JavaScript代码里,这是一个callback,但是我们在状态机模型中要把他理解为事件。在Pending
状态中如果有save请求,不发生状态迁移,新的请求数据会覆盖旧的版本,原来的timer会被清除,重新开始新的timer。在timeout发生时,迁移到Working
状态。
Working
状态在进入时会启动存储文件的操作,它可能有两个事件:来自外部的save请求,和来自内部的保存文件操作的异步返回,同样的,它也被理解为一个(内部)事件。当外部的save请求到来时,该请求被保存在内部的next
变量里;当文件操作返回时:
如果操作成功
如果存在
next
,向Pending
状态迁移如果不存在
next
,向Idle
状态迁移
如果操作失败
如果存在
next
,向Pending
状态迁移,用next
作为数据如果不存在
next
,也向Pending
状态迁移,仍然使用当前数据,相当于等待后retry
我偷个懒,没给出图示,实际上这样的语言描述不如State Diagram来得直观。下面的表格是上述语言描述的归纳,史称状态迁移表(State Transition Table),有了State Diagram或者State Transition Table,任何人写出来的代码都一样。为了表述清晰,表中把Working
状态的文件操作返回分成了两个事件: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,三个状态都继承自这个base class:
class State {constructor(ctx) {this.ctx = ctx}setState(nextState, ...args) {this.exit()this.ctx.state = new nextState(this.ctx, ...args)}exit() {}
}
在状态机的代码实现上,标志性的方法名称是setState
,它负责状态迁移;其次是enter
和exit
,分别对应进入该状态和离开该状态。
状态机模式(State Pattern)的一个显著的编程收益是:每个状态都有自己的资源,在迁入该状态的时候建立资源,在离开该状态的时候释放资源,这很容易保证资源的正确使用。
在上述代码中,constructor
充当了enter
逻辑的角色,所以没有提供独立的enter
方法;JavaScript Class是一个语法糖,没有和constructor相对应的destructor,所以我们这里写一个exit
函数,如果继承类里没有exit
逻辑,这个基类上的方法就是一个fallback。
ctx
是一个外部容器,相当于所有状态对象的上下文(context),它同时具有一个叫做state
的成员,该成员是一个Idle
,Pending
,或者Working
类的实例;无论ctx.state
是哪个状态,ctx
都把save
方法forward到state
上,这样写是一个很标准的State Pattern。
setState
的实现有点tricky,是JavaScript的特色。它首先调用当前类的exit
函数迁出状态,然后使用new来构造下一个类,这意味着第一个参数nextState
是一个构造函数;后面的参数使用了spread operator,把这些参数传入了构造函数,同时把新对象安装到了ctx
,即把自己替换了;这不是唯一的做法,是比较简洁的一种写法。
Idle
类的实现非常简单,在save的时候用data作为参数构造了Pending
对象。
class Idle extends State{save(data) {this.setState(Pending, data)}
}
Pending
类的save
方法里保存了data
和启动timer。它的构造函数重用了save
方法。因为JavaScript的clearTimeout方法是安全的,这样写没什么问题。
exit
函数实际上没有必要,但这样书写是推荐的,它确保资源清理,如果未来设计变更出现其他的状态迁出逻辑,这个代码就有用了。
timeout时Pending
向Working
状态迁移。
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
代码稍微多点,但是对照状态迁移表很容易读懂。不赘述每个方法了。保存文件的操作采用了先写入临时文件然后重命名的做法,这是保证文件完整性的常规做法,系统即使断电也不会损坏磁盘文件。
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
状态;把所有的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)}
}
要点
这一篇粗略的讲了两个问题:状态机模型和状态机模式(State Pattern)。他们两个不是一回事。
状态机模式是一种写法,上述写法不唯一;不使用Class,仅仅在Persistence
类中使用(枚举)变量表示状态也是可以的,使用Class则相当于用变量类型来代表状态。
状态机模式的显著优点在于:
不同状态的资源和行为逻辑分开
在
setState
,enter
,exit
等标志性方法中不需要使用if / then或switch语句在对象行为定义发生变化时,修改容易,不易犯错误;感谢enter和exit的封装,它强制了资源回收逻辑
状态机模型的意义对后面的内容更为重要。上面的例子具有这样几个特征:
状态具有显式定义
事件内外有别
外部事件对所有状态成立,因此
Persistence
类的使用非常简单,从外部其实看不到内部有什么状态定义,黑盒意义上说,Persistence
是无态的,这对使用便利性来说极为重要;内部事件仅仅对某些状态成立,所有异步函数的返回都理解为事件,而且是唯一的内部事件;
从并发角度说,
Persistence
类是一个同步器(Synchronizer),即并发的save操作在这里被排序执行了;当然我们没有设计更复杂的逻辑,例如任务队列,但显然那不是很难;
问题
纯粹的状态机(automata)对于并发编程是无力的,这是一种共识,因为并发带来的状态组合会迅速爆炸状态空间,我们要找到办法对付这个问题,此其一。
其二,实际的程序模块组合时常见包含关系,用经典的状态机模型会产生组合状态机(Hierarchical State Machine),它的代码书写远比上述例子的Flat State Machine难写,除非在语言一级或者有类库支持,否则可读性和可维护性都很差,设计变更时代码改动幅度非常大,不是解决常见问题的好办法,虽然在一些特殊应用领域卓有建树,例如嵌入式设备的通讯协议栈。
事件(Event)和线程(Thread)是形式上对立,但是数学上对等,的两个编程方式。两者各有利弊,战争也是古老的,你在网络上很容易搜索到Why Event (Model) is Evil或者Why Thread (Model) is Evil的学术文章,都有大量的支持者。
Node.js的与众不同之处在于它的强制non-blocking i/o设计。这给习惯Thread编程的开发者制造了麻烦,所以在过去的几年里新的过程原语被发明出来解决这个问题,包括promise,generator,async/await。bluebird的使用者越来越多,而caolan的曾经很流行的async库用户越来越少。
但是众所周知JavaScript语言是事件模型的。在基础特性上寻求类thread编程形式去解决一切问题本身就是表里不一的,而且promise和async/await的实现本身也有很多不尽人意的地方。
这让我们倒回来思考两个问题:
寻求各种CPS(Continuation Passing Style)是解决non-blocking i/o的必经之路吗?
事件和状态机模型真的没有办法写规模化的并发程序吗?
Node原作者Ryan Dahl最近在一次访谈里说了他对Node的看法。他说在最初的两三年中他是狂热的支持Node的强制non-blocking i/o设计的,达到那种认为“原来我们都做错了”的程度,但是慢慢的他的态度发生了转变,尤其是在接触了Go语言之后;现在他的看法是,最初他以为Node可以做到是end-all或者for-all的,但是现在他没那么有信心了,在并发编程上他认为Go可能是更好的设计。
我的个人观点,谈Node必谈callback hell的开发者,并不熟悉在Event Model下的并发编程技术,promise和async/await本质上,绝大多数情况下是在serialize过程,如果只是serialize,那么结果和blocking i/o的编程并不会有区别。Promise对parallel的支持很有限,它只是在serial的过程序列上偶尔撒一点parallel的flavor。而且如果你喜欢的就是Thread Model,那么就应该选择对它有良好支持的编程语言或环境,例如Go或者fibjs。
如果你像我一样,喜欢JavaScript语言的简单,喜欢Event Model的简单,而不只是因为Node有良好的生态圈和海量的npm包可用而选择了Node——如果你只是因为这两点选择了Node,你肯定会后悔的——那么摆在我们面前的问题就是:事件模型,显式状态,non-blocking i/o,我们能不能找到一种办法,一种end-all和for-all的办法,最好能够直接体现在代码形式上,或者至少体现在一个简单、直觉、不易错、同时保持经典状态机模型的完备性的Mental Model上,能够为复杂的并发编程问题建模和书写代码?
在这里经典状态机模式可以给我们一个简单启迪:我们不仅可以用值来表示状态,我们也可以用对象类型表示状态,而且有明显的收益。同样的,在事件模型下解决并发问题的关键,就是把这个设计继续向前推进一步,我们还可以用结构来表示状态。具体怎么写和怎么思考建模,则是这个系列文章的主旨。
这在数学层面上非常容易理解:所谓并发编程,它就是在structure过程(Rob Pike)。函数或者类函数,包括promise,async function,generator,coroutine,他们是Thread Model下的(黑盒)原语和原语组合,对应的,我们要找到事件模型下的显式状态方法来应对这个问题,如果能做到这一点,我们就可以回到纯粹的事件模型下编写程序。
这个结果并不难,但是,它也确实有一段路要走,我们需要仔细梳理过程原语的优点缺点,梳理并发编程的本质,梳理常见问题的各种编程方式,最后回到我们的事件模型和状态机上来。等这个系列写完,你也读完,我向你保证,你再次看到callback函数时会觉得原来它那么简单且美。
下一篇我们开始谈并发编程,敬请期待。
白洁血战Node.js并发编程 01 状态机相关推荐
- 白洁血战Node并发编程 - 预览
预览. 先给出一个基础类代码. const EventEmitter = require('events') const debug = require('debug')('transform')cl ...
- Node.js高级编程【一】node 基础
目录 一.Node 基础 1.课程概述 2.Node.js 架构 3.为什么是Node.js ? 4.Node.js 的 异步IO 5.Node.js 主线程是单线程 6.Node.js 应用场景 7 ...
- 57 Node.js异步编程
技术交流QQ群:1027579432,欢迎你的加入! 欢迎关注我的微信公众号:CurryCoder的程序人生 1.Node.js异步编程 1.1 Node.js中的异步API 如果异步API后面的代码 ...
- Node.js异步编程~超级详细哦
下面是对Node.js异步编程的整理,希望可以帮助到有需要的小伙伴~ 文章目录 同步API,异步API 同步API,异步API的区别 获取返回值的方式不同 代码执行顺序不同 Node.js中的异步AP ...
- nodejs学习巩固笔记-nodejs基础,Node.js 高级编程(核心模块、模块加载机制)
目录 Nodejs 基础 大前端开发过程中的必备技能 nodejs 的架构 为什么是 Nodejs Nodejs 异步 IO Nodejs 事件驱动架构 全局对象 全局变量之 process 核心模块 ...
- node.js异步编程
目录 1.同步API 2.异步API 回调地狱 用promise解决回调地狱 异步函数 Node服务器端编程 1.同步API 只有在当前的API执行完成后,才执行下一个API.代码的执行方式是按照代码 ...
- Node.js 异步编程(附几个小练习题学会分析代码执行顺序)
1. 同步API,异步API 同步API:只有当前API执行完成后,才能继续执行下一个API console.log('before'); console.log('after'); 异步API:当前 ...
- 并发编程-01并发初窥
文章目录 引言 思维导图 基础知识构建 涉及的知识点一览 高并发处理思路与手段一览 并发初窥 概念 并发问题模拟 代码 引言 说来惭愧,一直没有系统的梳理过并发编程的知识,这次借着学习_Jimin_老 ...
- 前端学习(1319):node.js异步编程
test,js function getMsg(callback) {setTimeout(function() {callback({msg: 'hello node js'})}, 2000) } ...
最新文章
- Exchange日常管理之二十一:管理邮件归档
- shell初级-----控制脚本
- delphi 打印指定地点文件_2020年度电脑、打印机耗材及相关配件采购招标公告
- TCC事务补偿机制实现分布式事务控制介绍
- HALCON示例程序measure_circuit_width_lines_gauss.hdev电路板线宽检测
- Information Retrieval 倒排索引 学习笔记
- .NET Core中间件的注册和管道的构建(1)---- 注册和构建原理
- 小米荣耀互怼:头部高管们神仙打架 到底谁是谁非?
- Luogu 2296 寻找道路
- yolov3从头实现(五)-- yolov3网络块
- gispython定义查询_定义查询方法
- oracle时间去掉时分秒的时间_超详细的oracle修改AWR采样时间间隔和快照保留时间教程...
- JavaScript:学习笔记(7)——VAR、LET、CONST三种变量声明的区别
- 常用的SEO工具都有哪些呢?5个SEO必备优化工具推荐
- 基于OMAPL138 + Xilinx spartan6的电力数据采集与传输设计
- 中国IT风险投资机构
- 剖析kubernetes集群内部DNS解析原理
- kotlin中使用软引用
- python之生成器(~函数,列表推导式,生成器表达式)
- 可解释的机器学习(XML)概览
热门文章
- lepus监控oracle数据库_一文看懂lepus天兔数据库监控系统如何搭建
- 21.3.3 原子性与易变性 21.3.4 原子类
- xp系统蓝屏代码7b_遇到系统问题,三种常见处理方法你更pick谁
- 纯券过户(free of payment)
- cvs update 的输出标志/update常用几个参
- 【经典问题】maximum subset sum of vectors
- 如何创建Kafka客户端:Avro Producer和Consumer Client
- Python由于目标计算机积极拒绝,无法连接。错误解决
- leecode_二叉树中序遍历
- 思考题目,仔细检查,外加一个ceil函数