当前位置: 首页 > 后端技术 > Node.js

白洁血战Node.js并发编程01状态机

时间:2023-04-04 01:28:15 Node.js

这是本系列的开篇,没有任何进阶内容,只说状态机。状态机状态机是模型层面的概念,与编程语言无关。其目的是对对象的行为进行建模,属于设计范畴。它的基本概念是状态和事件。一个对象的内部结构被描述为一组状态S1、S2、……Sn,它的行为触发器,包括内部的和外部的,被描述为一组事件E1、E2、……En。在任何状态下,当任何事件到来时,对象的状态变化都用Sx->Sy的状态转换(StateTransition)来描述,而这个状态转换就是对象的行为(behavior)。对象行为的完整定义是{S}x{E}的矩阵。如果存在(Sx,Ey)未定义行为的组合,则此对象行为模型在设计级别不完整。当然,实际的代码不可能没有行为,而这往往是错误发生的地方。该状态机具有良好的可实现性和可测试性。定义明确的状态机很容易编写相应的代码,也很容易遍历所有的状态转换过程来完成测试。当然,这仅仅意味着代码实现与设计(模型)匹配,并不意味着设计是正确的。设计的正确性是一个更复杂的话题,严格的定义是设计符合规范。什么是符合规范?去看了RobinMilner、TonyHoare、LeslieLamport等人的书,说实话,我也看不懂,就到此为止吧。本文不会详细介绍状态机。网上有很多资料。四人帮的书里有StatePattern——状态机用OO语言的实现。UML有StateDiagram,这是一个很好的图形化工具;这里仅提供一个代码示例,以帮助理解针对此示例的状态机模型的代码实现。举个例子假设我们要解决这样一个任务:我们有一个存储(保存)文件的模块,写状态机的目的是为了解决并发操作时排队的存储请求,因为请求是并发的,如果文件写入如果io操作也是并发的,文件可能会损坏。这是一个很常见的应用场景。该模块定义了三种状态:Idle,即不工作的状态;Pending,也就是等待工作的状态。等待的目的是如果短时间内有多个连续的写请求,我们只写回车最后一个,减少io操作的次数;Working,在这个状态下,正在执行写操作。如果在io操作过程中收到写请求,我们会将请求内容保存在一个临时位置;Idle状态没有什么特别的资源,只有一个保存请求事件;当有保存请求时,它会迁移到Pending状态。Pending状态的一个特殊资源是定时器,它可能有两个事件:来自外部的保存请求和来自内部的超时。在JavaScript代码中,这是一个回调,但我们需要将其理解为状态机模型中的一个事件。如果在Pending状态下有保存请求,则不会发生状态转换,新的请求数据会覆盖旧版本,原来的定时器会被清空,重新启动一个新的定时器。当超时发生时,转移到Working状态。Working状态进入后会开始存储文件的操作。它可能有两个事件:外部的保存请求,和内部保存文件操作的异步返回。同样,它也被理解为一个(内部)事件。当外部保存请求到来时,将请求存储在内部next变量中;文件操作返回时:如果操作成功,如果有next,则迁移到Pending状态,如果没有next,则迁移到Idle状态,如果操作失败,如果存在next,则迁移到Pending状态,使用next作为数据。如果next不存在,也迁移到Pending状态,仍然使用当前数据,相当于wait后重试。我比较懒,没有举例说明。事实上,这样的语言描述并没有StateDiagram直观的来得好。下表是对上述语言描述的总结。历史上称之为StateTransitionTable。有了StateDiagram或StateTransitionTable,谁写的代码都是一样的。为了清楚起见,该表将Working状态的文件操作返回分为成功和错误两个事件。StateEventSaveTimeoutSuccessErrorIdle->Pendingn/an/an/aPending覆盖数据,重启定时器->Workingn/an/aWorkingsetnextn/aifnext,->Pending;else->Idle->Pending(next?next:data)代码如下是代码,首先有一个基类继承了所有三种状态:classState{constructor(ctx){this.ctx=ctx}setState(nextState,...args){this.exit()this.ctx.state=newnextState(this.ctx,...args)}exit(){}}在状态机的代码实现中,标志性的方法名为setState,负责状态转换;其次是enter和exit,对应进入状态和离开状态。状态模式的一个显着的编程好处是每个状态都有自己的资源,进入状态时创建,离开状态时释放,容易保证资源使用的正确性。上面代码中,构造函数起到了enter逻辑的作用,所以没有提供独立的enter方法;JavaScriptClass是一个语法糖,没有构造函数对应的析构函数,所以我们在这里写一个退出函数,如果继承类中没有退出函数,从逻辑上讲,这个基类上的方法就是fallback。ctx是一个外部容器,相当于所有状态对象的上下文。它还有一个叫做state的成员,它是Idle、Pending或Working类的一个实例;不管ctx.state是哪个状态,ctx都是一个很标准的StatePattern把save方法写到状态前。setState的实现有点棘手,这是JavaScript的特性。它首先调用当前类的exit函数将状态移出,然后使用new构造下一个类,也就是说第一个参数nextState是一个构造函数;后面的参数使用展开运算符将这些参数传递给constructor,同时将新对象安装到ctx中,即替换自身;这不是唯一的方式,而是一种相对简洁的写法。Idle类的实现非常简单。保存时,以数据为参数构造Pending对象。classIdleextendsState{save(data){this.setState(Pending,data)}}Pending类的save方法保存数据并启动定时器。它的构造函数重用了save方法。因为JavaScript的clearTimeout方法是安全的,所以这样写是没有错的。exit函数其实不是必须的,但是建议这样写,保证资源被清理干净,如果以后设计变更还有其他状态转换逻辑,这段代码很有用。暂停期间到工作状态的转换。classPendingextendsState{constructor(ctx,data){super(ctx)this.save(data)}save(data){clearTimeout(this.timer)this.data=datathis.timer=setTimeout(()=>{this.setState(Working,this.data)},this.ctx.delay)}exit(){clearTimeout(this.timer)}}工作代码有点多,但是根据state比较容易阅读理解过渡表。不再重复每个方法。保存文件的操作采用先写入临时文件再重命名的做法。这是确保文件完整性的常见做法。即使系统断电,磁盘文件也不会损坏。classWorkingextendsState{constructor(ctx,data){super(ctx)this.data=data//console.log('开始保存数据',data)lettmpfile=path.join(this.ctx.tmpdir,UUID.v4())fs.writeFile(tmpfile,JSON.stringify(this.data),err=>{if(err)returnthis.error(err)fs.rename(tmpfile,this.ctx.target,err=>{//console.log('数据保存完成',data,err)if(err)returnthis.error(err)this.success()})})}error(e){//console.log('error写入持久文件',e)if(this.next)this.setState(Pending,this.next)elsethis.setState(Pending,this.data)}success(){if(this.next)this.setState(Pending,this.next)elsethis.setState(Idle)}save(data){//console.log('Workingsave',data)this.next=data}}最后是ctx,我们在实际项目中调用Persistence.初始化时,状态设置为Idle状态;所有保存操作都转发到内部对象状态。classPersistence{constructor(target,tmpdir,delay){this.target=targetthis.tmpdir=tmpdirthis.delay=delay||}500this.state=newIdle(this)}save(data){this.state.save(data)}}要点这篇文章大致讲了两个问题:状态机模型和状态机模式(StatePattern)。他们两个不是一回事。状态机模式是一种写法,上面的写法不是唯一的;除了使用Class,还可以使用Persistence类中的(枚举)变量来表示状态。使用Class相当于使用变量类型来表示状态。状态机模型的显着优点是不同状态的资源和行为逻辑是分离的。无需在setState、enter和exit等标志性方法中使用if/then或switch语句。当对象行为定义发生变化时,修改容易,出错的可能性小。;由于对enter和exit的封装,强制说明资源回收逻辑状态机模型的意义对后面的内容更加重要。上面的例子有以下特点:状态有明确定义的内部事件和外部事件的区别,外部事件是为所有状态建立的,所以Persistence类的使用非常简单。事实上,从外部是不可能看到任何内部状态定义的。在黑盒的意义上,Persistence是无状态的,这对于使用的便利性是极其重要的;内部事件只对某些状态有效,所有异步函数的返回都理解为一个事件,并且是唯一的内部事件;从并发的角度来看,Persistence类是一个同步器(Synchronizer),即并发的保存操作是顺序执行的;当然,我们没有设计更复杂的逻辑,比如任务队列,但显然难度不是很大;问题的纯状态机(自动机)对于并发是编程无能为力的共识,因为并发带来的状态组合会迅速炸开状态空间。我们必须想办法处理这个问题,这是第一。第二,包含关系在实际的程序模块组合中经常见到。使用经典的状态机模型会生成一个组合状态机(HierarchicalStateMachine)。它的代码编写难度远比上面例子中的FlatStateMachine要难,除非是在语言层面。或者有类库支持,否则可读性和可维护性差,设计变更时代码改动非常大,不是解决常见问题的好办法,虽然在一些特殊的应用上取得了很大的成绩领域,例如嵌入式设备。通信协议栈。事件(Event)和线程(Thread)是形式上对立,但在数学上等价的两种编程方式。两者各有优缺点,战争也由来已久。你可以很容易地在网上搜索到WhyEvent(Model)isEvil或者WhyThread(Model)isEvil的学术文章,它们都有大量的支持者。Node.js的不同之处在于其强制性的非阻塞i/o设计。这给习惯了Thread编程的开发者带来了麻烦,所以在过去几年里发明了新的过程原语来解决这个问题,包括promise、generator、async/await。bluebird的用户越来越多,而caolan曾经火爆的async库的用户却越来越少。但是我们都知道JavaScript语言是一种事件模型。寻求类线程的编程形式来解决基于基本特性的所有问题是不一致的,promise和async/await的实现也有很多不尽如人意的地方。这让我们回头思考两个问题:寻求各种CPS(ContinuationPassingStyle)是解决非阻塞i/o的唯一途径吗?真的没有办法用事件和状态机模型写出大规模的并发程序吗?Node的原作者RyanDahl最近在接受采访时表达了他对Node的看法。他说,在最初的两三年里,他狂热地支持Node的强制非阻塞i/o设计,以至于他认为“我们都做错了”,但慢慢地他的态度发生了变化。改变,尤其是在接触了Go语言之后;现在他的看法是,一开始他认为Node可以是end-all或for-all,但现在他没那么自信了,在并发编程方面,他认为Go可能是更好的设计。以我个人的观点,讲Node的开发者必然讲回调地狱,对EventModel下的并发编程技术并不熟悉。大多数情况下,Promise和async/await本质上都在serialize过程中。如果只是serialize,那么结果和blockingi/o的编程没什么区别。Promise对并行的支持非常有限,它只是偶尔在串行进程序列上洒上一点并行的味道。而如果你喜欢的是ThreadModel,那你就应该选择对它有很好支持的编程语言或者环境,比如Go或者fibjs。如果你和我一样,喜欢JavaScript语言的简洁和EventModel的简洁,而不是因为Node有很好的生态系统和大量的npm包可用就选择了Node——如果你选择了Node就因为这两点,你肯定会后悔的——那么摆在我们面前的问题就是:事件模型,显式状态,非阻塞i/o,我们能不能找到一种方式,一种end-all和for-all的方式,最好是直接体现在代码形式上,或者至少体现在保持经典状态机模型完整性的简单、直观、抗错的MentalModel上,能够为复杂的并发编程问题建模和编写代码?在这里,经典的状态机模式可以给我们一个简单的启发:我们不仅可以用值来表示状态,还可以用对象类型来表示状态,而且有明显的好处。同样,在事件模型下解决并发问题的关键是将这个设计更进一步,我们也可以用结构体来表示状态。如何写,如何思考建模,是本系列文章的主要目的。这在数学层面是非常容易理解的:所谓并发编程,就是在结构过程中(RobPike)。函数或类函数,包括promise、asyncfunction、generator、coroutine,它们是ThreadModel下的(黑盒)原语和原语组合,相应的,我们需要在事件模型下找到一个显式的状态方法来处理这个问题,如果我们能做到这一点,我们就可以回到纯事件模型下编写程序。这个结果并不难,但确实任重而道远。我们需要仔细梳理流程原语的优缺点,梳理并发编程的本质,梳理常见问题的各种编程方式,最后回到我们的事件模型和状态机上来。当您读完本系列文章时,我向您保证,当您再次看到回调函数时,您会发现它是如此简单和美丽。在下一篇文章中,我们将开始讲并发编程,敬请期待。