这篇文章大面积重写了,更准确和严格的描述了Node.js的运行时模型,但本文中的部分例子被移除了。node
请阅读:Reactor Model算法
异步(Asynchronous)在不一样的上下文下能够有不少不一样的解释;在Node.js上下文里,它指的是如何对待一个或多个过程,因此咱们先来谈过程。express
这里说的过程(Process)是抽象的概念,实际的实现多是进程(另外一种定义的Process),线程(thread),协程(coroutine/fiber),甚至只是一个函数(function)。编程
过程(Process)是一种解决问题的方法,或者说是解法域模型:把一个复杂问题拆解成多个简单问题,每一个问题用一个过程解决,经过建立和销毁过程,以及过程通信,来完成整个计算任务。segmentfault
计算科学家们(尤为在谈并发编程的时候)喜欢举下面的例子来讲明这种思惟方式:数组
问题是求解整数n以内的全部质数(n > 2)。promise
解法的关键点是为每个已知的质数创建一个Process,算法以下:安全
P2的逻辑是:网络
这个过程继续下去,会建立P5, P7, P11...等等。当全部的数都被处理完以后,这些Process自己就是问题的答案。多线程
这个例子展现了用Process创建模型解决问题的基本要素:
这个例子是否是很优雅不在咱们的讨论之列,即便它很优雅,大部分实际的编程问题没有这种优雅特性。
在这里咱们只强调一点:用Process建模是用divide and conquer的办法解决问题的一种方式,一样的方式也存在于Component-based, Object-Oriented等多种编程模型技术中。
咱们先看最传统的基于blocking i/o编程的程序,若是把每一个function都理解为一个process,这个程序的运行过程如何理解。
在全部的命令式语言中,函数都具备良好的可组合性(Composibility),便可以经过函数调用实现函数的组合;一个函数在使用者看来,不知道它是一个简单的函数仍是复杂的函数组合,即函数和函数组合具备自类似性,或者说在语法上具备递归特性。因此咱们只需回答针对一次函数组合,如何从Process的角度理解便可。
在一个父函数中调用子函数,父函数自己能够看做一个Process,P1,在它调用子函数时,能够理解为建立(fork)了一个新的Process,P2,而后P1被阻塞(blocked),一直等到P2完成并经过通信把执行结果返还给P1(join),而后P1继续执行。
若是这样去理解咱们能够看到,传统的基于blocking i/o的编程:
在上述传统的blocking io编程模式下,整个程序可能被一个i/o访问block住,这不是一个充分利用CPU计算资源的方式,尤为对于网络编程来讲,它几乎是不可接受的。
因此操做系统自己提供了基于socket的回调函数机制,或者文件i/o的non-blocking访问,让应用程序能够充分利用处理器资源,减小执行等待时间;代价是开发者须要书写并发过程。
对于Node.js而言,在代码层面上,建立一个过程,是你们熟知的形式,例如:
fs.readdir('directory path', (err, files) => { })
这里的fs.readdir
是一个Node API里的文件i/o操做,它也能够是开发者本身封装的过程。
这个函数调用后当即同步返回,在返回时,建立了一个过程,这个过程的结果没有同步得到,从这个意义上说,咱们称之为异步函数。
若是连续调用两次这样的函数,在应用内就建立了两个过程,咱们称之为并发。
对事件模型的Node.js而言,这样来实现非阻塞和并发编程有两个显而易见的优点:
同步的意思须要这样理解:假如用线程或者进程来实现process,process A只能经过异步通信通知process B本身发生了某种状态迁移,由于这个通信是异步的,因此process B不能相信收到的消息是它收到消息那个时刻的process A的真实状态(可是能够相信它是一个历史状态),双方的逻辑也必须健壮到对对方的状态假设是历史状态甚至错误状态之上,就像tcp协议那样。
在Node.js里没有这个烦恼,由于这些process的代码都在同一个线程内运行,不管是process A仍是process B遇到事件:
均可以经过方法调用或者消息机制让本身和另外一方实现一次同步和同时的状态迁移
同时的特性被称为(shared transition),能够看做两个LTS(Labelled Transition System)的交互(interaction),也能够用Petri Net描述。它可让过程组合容易具备完备的状态和状态迁移定义。
从上述这个意义上说,在并发模型上,Node领先Go或者任何其余异步过程通信的语言一大截;可是反过来讲,Node的单线程执行模型对于计算而言,没能利用到更多的处理器,是它的显著缺点。可是对于io,Node则彻底不输用底层语言编程的多线程程序。
这是被谈论最多的话题。
如何用Node.js书写异步与并发,和开发者面对的问题有关。若是在写一个微服务程序,fork过程主要发生在http server对象的事件里,或者express的router里;同时fork出来的每个过程,咱们用串行组合过程的方式完成它,即若是使用callback形式的异步函数嵌套下去,最终会获得一个能够利用到Node的异步并发特性,可是形式上很是难读的代码,这被称为Callback Hell。
在ES7中出现的async/await语法一推出就大受欢迎:
在串行组合过程时,开发者最关心的问题是如何让过程连续下去,因此从这个意义上说callback函数,或者对应的promise,async/await,也被一些开发者称为Continuation Passing Style(CPS)。
这样说在概念上和实践上都没有问题。可是这件事情在整个Node并发编程上是很是微不足道的。由于这样的模型没有考虑到一个重要的问题:过程能够是有态的。
当咱们在传统的blocking i/o模式下编程,书写一个表示过程的函数,或者在Node.js里用callback形式或async语法的函数,书写一个表示过程的函数,其状态能够这样表述:
P: s -> 0
它只有两个状态,在运行(s),或者结束了(0)。结束的意思是这个过程不会对程序逻辑和系统状态产生任何将来影响。
咱们优先关心结束,而不是关心它的成功、失败、返回值,由于前者是对任何过程都普适的状态描述,能够理解为语法,后者是针对每一个具体过程都不一样的语义。
固然不是全部的过程都会自发结束,好比用setInterval
建立的周期性fire的时钟,调用了listen方法的http server,或者打开了文件句柄的fs.WriteStream
,若是他们没有遇到严重错误致使自发结束,他们须要使用者的进一步触发(trigger)才能结束。
对于setInterval
而言这个触发是clearInterval
,对于http server而言这个触发是close
,对于fs.WriteStream
而言,这个触发是end
,不管哪一种状况,开发者应该从抽象的角度去理解这个问题:
咱们举两个例子来讲明这种状态更为丰富的过程,即没法用P过程表示的有态过程。
第一个例子,咱们考虑一个有try/catch逻辑的过程,若是async/await函数形式来写它大致是这样的代码:
async function MyProcess () { try { // 1 do some await } catch (e) { // 2 // 3 do some await throw e } }
这个Process开始执行后就进入了s状态,它有可能在try block成功完成任务,即s -> 0
。它也可能遇到错误走向catch block,可是错误并非从2的位置抛出,让使用者能够马上获知和采起行动,它被推迟到3结束。
这样作的好处是这个过程仍然能够用s -> 0
来描述,可是,从并发编程的角度说,使用者的error handler逻辑的执行时间被推迟了。可能不少状况下3的逻辑的时间足够短,并不须要去计较,从practical的角度说,我也会常常这样写代码,由于它容易。
可是从Process状态定义的完备角度说,这是个设计缺陷。
一样的过程咱们能够换成Event Emitter的形式来实现,Emitter的实现不强制同时抛出error(或data)和抛出finish这两个事件,这对于callback形式或者async函数是强制的:返回即中止。
这样就给使用者提供了选择:
race和settle都是join逻辑。但他们没必要是互斥的(exclusive or),使用者也彻底能够在error(或data)的时候触发一个后续动做,在settle的时候触发另外一个动做。这样的模型才是普适的和充分并发的。
更为重要的,不管你采用什么样的模型去封装一个单元,一个重要的设计原则是,这个封装应该提供机制(Mechanism),而不是策略(Policy),选择策略是使用者的自由,不是实现者的决策。
若是分开两次抛出error和finish,使用者有自由选择race,或者settle,甚至both,这是使用者的Policy。
在这种状况下,咱们能够用下述状态描述来表示这个过程:
Q: s -> 0 | s -> e -> 0
约定:咱们用s,或者s1, s2, ...表示可能成功的状态,或者说(意图上)走在走向成功的路上,用e,或者e1, e2, e3...表示明确放弃成功尝试,走在尽量快结束的路上的状态。0只表明结束,对成功失败未置能否。
在这个Q过程定义中,全部的->
状态迁移都是过程的自发迁移,不包含使用者触发的强制迁移。在后面的例子中咱们会加入强制迁移逻辑。
在这里咱们先列出一个重要观点(没有试图去prove它,因此目前不称之为定理或者结论):完整的Q过程是没法简化成s -> 0
的P过程的,因此它也没法应用到串行组合P过程的编程模式中。
在开发早期对Q过程有充分认知是很是必要的,由于开发者可能从很小的逻辑开始写代码,把他们写成P过程,而后层层封装P过程,等到他们发现某个逻辑须要用Q过程来描述时,整个代码结构可能都坍塌了,须要推倒重来。
这是我为何说async/await的CPS实现不那么重要的缘由。在Node中基于P过程构建整个程序是很简单的,若是设计容许这样作,那么恭喜你。若是设计上不容许这样作,你须要仔细理解这篇文章说的Q过程,和对应的代码实现里须要遵循的设计原则。
Q过程的问题自己不限于Node编程,用fiber,coroutine,thread或者process实现并发同样会遇到这个问题。要实现完整的Q过程逻辑必须用特殊的语法建立过程,例如Go语言的Goroutine,或者fibjs里的Coroutine;使用者和实现者之间须要经过Channel或对等的方式实现通信。实现者的s/e/0
等状态对使用者来讲是显式的。
在Node里作这件事情事实上是比其余方式简单的,由于前面说的,inter-process communication是同步的,它比异步通信要简化得多。
Node/JavaScript社区能够说很不重视从语法角度作抽象设计了。
http.ClientRequest
里的取消被称为abort
,Node 8.x以后stream
对象能够被使用者直接销毁,方法名是destroy
,可是 Node 8很新,你不能期望大量的已有代码和第三方库在可见的将来都能遵循一致的设计规则。在Promise的提案里,开发者选择了cancel
来表示这个操做。
我在这篇文档里选择了destroy
,用这个方法名来表示使用者主动和显式销毁(放弃)一个过程。新设计的引入须要小小的修改一下Q过程的定义:
Q: s -> 0 | s => e -> 0
=>
在这里用于表示它能够是一个自发迁移,也能够是一个被使用者强制的迁移;若是是后者,它必须是一个同步迁移,这在实现上没有困难,可以使用同步的时候咱们不必去使用异步,徒然增长状态数量却没有收益。
在Node生态圈里有很多能够取消的过程对象,例如http.ClientRequest
具备abort
方法,所以相应的request
和superagent
等第三方增强版的封装,也都支持了abort
方法。
可是绝大多数状况下它们都不符合上述Q过程定义,而是把他们定义成了:
P: s => 0
即状态s到0的迁移,多是自发的,多是被强制的(同步的)。这些库能这么作是由于:它们处理的是网络通信,Node在底层提供了abort去清理一个socket connection,除此以外没有其余负担,因此在abort函数调用后,即便后续还有一些操做在库里完成,你仍然能够virtually的看成他们都结束了,由于它符合咱们前面对结束的定义“不会对程序逻辑和系统状态产生任何将来影响”。
不少库都选择了这样的简化设计,这个设计在使用者比较当心的前提下也能归入到“用串行Process”来构造逻辑的框架里,由于大部分库都采用了一个callback形式的异步函数同步返回句柄的方式。
let r = request('something', (err, res) => { }) // elsewhere r.abort()
这个写法不是解决destroy问题的银子弹,由于它没办法同时提供race和settle逻辑。并且在r.abort()以后,callback函数是否该被调用,也是一个争议话题,不一样的库或者代码可能处理方式不一致,是一个须要注意的坑。
还有不少更为复杂的场景的例子。不一一列举了。咱们倒回来回顾一下Node API自己,至关多的重要组件都是采用Event Emitter封装的,包括child,stream,fs.stream等等。他们基本上均可以用Q过程描述,但很难归入到P过程和P过程串行组合中去。
在这一部份内容里咱们提出了用Process Model来分析问题的方法,它是一个概念模型,不只限于分析Event Model的Node/JavaScript,一样能够用于多进程,多线程,或者协程编程的场景。
基于过程的分析咱们看出Node的特色在于能够用函数这样简单的语法形式建立并发过程,也指出了Node的一大优点是过程通信是保证同步的。
最后咱们提出了过程能够是有态的,一个相对通用的Q过程状态定义。这是一个难点,但并非Node的特点,任何双方有态通信的编程都困难,例如实现一个tcp协议,可是在并发编程上咱们回避不了这个问题。
Node的异步和并发编程能够简单的分为两部分:
前者是Node独有的特点,它不难,可是要有一套规则;后者不是Node独有的,并且Node写起来只会比多线程编程更容易,若是在Node里写很差,在其余语言里也同样。
一般的状况是开发者在设计上作一点妥协,牺牲一点并发特性,串行一些逻辑,在可接受的状况下这是Practical的,由于它会大大下降错误处理的难度,但遇到设计需求上不能妥协的场景,开发者可能在错误处理上永远也写不对。
这篇文章的后面部分会讲解第一部分,如何封装异步过程。如何组合这些异步过程,包括等待、排队、调度、fork/join、和如何写出极致的并发和健壮的错误处理,是另一篇文章的内容。
异步过程这个名字不是很恰当,过程自己就是过程,没什么同步异步的,同步或者异步指的是使用者的使用方式。但在没有歧义的状况下咱们先用这个称呼。
在Node里通常有两种方式书写一个异步过程:callback形式的异步函数,和Event Emitter。
还有一种不通常的形式是把一个Event Emitter劈成多个callback形式的异步函数,这个作法的一个收益是,和OO里的State Pattern同样,把状态空间劈开到多个代码block以内;很显然它不适合写复杂的状态迁移,会给使用者带来负担,但在只有两三个串行状态的状况下,它可使用,若是你偏心function形式的语法,唾弃Class的话。
异步函数的状态定义是:
P: s -> 0
它没有返回值(或句柄),它惟一的问题是要保证异步。初学者常犯的错误是写出这样的代码:
const doSomething = (args, callback) => { if (!isValid(args)) { return callback(new Error('invalid args')) } }
这是个很严重的错误,由于doSomething
并非保证异步的,它是可能异步可能同步的,取决于args
是否合法。对于使用者而言,若是象下面这样调用,doElse()和doAnotherThing()谁先执行就不知道了。这样的逻辑在Node里是严格禁止的。
doSomething((err, data) => { doAnotherThing() }) doElse()
正确的写法很简单,使用process.nextTick
让callback调用发生在下一个tick里。
const doSomething = (args, callback) => { if (!isValid(args)) { process.nextTick(() => callback(new Error('invalid args'))) return } }
异步函数能够同步返回一个对象句柄或者函数(就像前面说的http.ClientRequest
),若是这样作它是一个Event Emitter形式的等价版本。咱们先讨论完Emitter形式的异步过程,再回头看这个形式,它事实上是一个退化形式。
咱们直接考虑一个Q过程:
P: s -> 0 | s => e -> 0
代码大致上是这样一个样子,:
class QProcess extends EventEmitter { constructor() { super() } doSomething () { } destroy() { } end() { } }
这个过程对象能够emit error
, data
, finish
三个event。
用Process Model来理解它,这个class的方法,至关于使用者过程向实现者过程发送消息,而这个class对象emit的事件,至关于实现者向使用者发送消息。从Process模型思考有益于咱们梳理使用者和实现者之间的各类约定。
使用者调用new QProcess
时建立(fork)了一个新的Process。绝大多数Process不须要有额外的方法,由于大部分参数直接经过constructor提供了。
Builder Pattern在Node和Node的第三方库中很经常使用,例如superagent,它提供了不少的方法,这些方法都是在构造时使用的,他们都是同步方法,并且没有fork过程,直到最后使用者调用end(callback)以后才fork了过程,咱们不仔细讨论这个形式,它很容易和这里的QProcess对应,惟一的区别是end的含义和这里定义的不一样。
这里提供了两个特殊方法,destroy和end,前者是销毁过程使用的,它在错误处理时很常见。在串行组合过程的编程方式下,咱们没有任何理由去destroy其中的一个正在执行的过程;可是在并发编程下,一个过程即便没有发生任何错误,也可能由于并发进行的另外一个过程发生错误而被销毁,从这个角度说,Q过程都应该提供destroy方法。
end在这里是对应stream.Writable
的end逻辑;一个写入流(咱们一样把他当Process看),若是没有end它永远不会结束;这种过程对象和等价于for-loop
的setInterval
不一样,在绝大多数状况下咱们不会但愿它是永远不结束的。
doSomething
是一个通用的写法,若是QProcess封装的过程须要尽早开始工做,可是它也须要不断的接受数据,doSomething
用于完成这个通信,典型的例子是stream.Writable
上的write方法。write方法不一样于end方法的地方在于,write方法不会致使QProcess出现对使用者而言显式的状态迁移,但end方法是的。
error
事件,固然能够argue说一个QProcess能够在抛出error后继续工做,可是咱们不讨论这个场景;咱们假定QProcess在抛出error后没法继续走在可能成功的s路线上;若是它能够继续,那么那个error请使用其余event name,看成一种data类型来处理。
Node的Emitter必须提供error handler,不然它会直接抛出错误致使整个应用退出,因此从这个意义上说,error是critical的。符合咱们说的限制。
data
事件,它相似write,它应该表示QProcess仍然走在可能成功的s路线上。
finish
事件,它符合前面咱们说过的过程结束的定义。
在任何状况下,QProcess都不容许在使用者调用方法时,同步Emit事件。
在Event Emitter形式下的Q过程,仍然要遵循异步保证。它的出发点是一致的,若是没有这个保证,使用者没法知道它调用方法以后的代码,和event handler里的代码哪个先执行。若是遇到同步的错误,QProcess仍然须要象异步函数同样,用process.nextTick()来处理。
若是QProcess须要emit事件,它必须保证本身处于一个对使用者而言,显式且完整的状态下。
QProcess内部的实现也会侦听其余过程的事件,这些事件的到来可能会致使QProcess执行一连串的动做。
例子:若是一个QProcess内部包含一个ChildProcess
对象,在QProcess处于s状态时,它抛出了error,这时过程已经没法继续,QProcess执行一连串的动做向e状态迁移:
transition: s -> action 1 -> action 2 -> action 3 -> e
emit error的时间点在进入e状态以后,emit error的含义是通知使用者发生了致命错误,并且QProcess已经迁移至e状态。
在action过程当中随意的emit error是严格禁止的。
由于:使用者可能在error handler中调用QProcess的方法,不管是同步仍是异步方法。若是严格要求QProcess在有效状态下emit,那么QProcess的实现承诺就是这些方法在有效状态下可用。若是写成在action 1以后也容许emit error,对QProcess的要求就提高到在任何transition action时均可用,这种是无厘头的挑战自我设计,毫无心义。并且即便你成功的挑战了自我,使用者也带来了额外的负担,它的handler里对QProcess的状态假设是什么?是状态迁移中?这明显不合理。
Q过程对使用者是显式有态的,是它的执行逻辑的依据,因此这里应该消除歧义,杜绝错误。这种错误是严重的协议错误。
QProcess在内部的一次event handler中只容许emit一次,并且承诺状态:
有两种常见的错误状况。
第一个例子:假如QProcess的第一个操做,例如经过fs.createReadStream()
建立一个文件输入流,由于文件不存在马上死亡了。这时它有这样一些选择:
正确的作法是4。由于QProcess已经结束,是显式状态0,emit finish通知使用者本身发生了状态迁移(s -> 0
)是正确的作法。至于错误,推荐的作法是直接在finish里提供,在对象上建立标记让使用者去读取也是能够的。
在这样的设计下,emit error的语义被缩减的了,即若是QProcess emit error,说明它必定处于e状态,不是0状态,这有助于使用者使用(代码路径分开原则,后述)。
在这个例子下若是选择1呢?你怎么考虑若是在emit error以后:
第二个例子:假如QProcess处于s状态,抛出了data事件,对QProcess而言它不知道这个data是否非法,可是使用者可能有额外的逻辑认定这个data是错误的,这个时候它调用了QProcess的destroy方法,这个方法要求QProcess的强制状态迁移s -> e
。
若是遵循这一条设计要求,这种设计就是很安全的。不然连续的emit的第二次的emit对状态的假设就无法确认了,难道使用者还须要在第二次emit以前去检查一下本身的状态吗?
error的处理有另一种设计路径。在s状态下emit error,而后使用者调用destroy方法强制其进入e状态;逻辑上是对的,也具备数学美感,由于它没区分s和e的处理方式;但我倾向于不要给使用者制造负担,实现者的代码写一次,使用者的代码写不少次,这样设计须要使用者在每一次都要去调用destroy,相对麻烦。固然若是你有足够的理由局部去作这样的设计,能够的。
回到Process模型上来。
若是一个Process的实现是用操做系统进程、线程来实现,同步destroy的可能性是没有的,只能发送一个message或signal,对应的进程或者线程在将来处理这个消息,对于使用者而言它仍然可能在destroy以后得到data之类的message,固然这也不是很麻烦,使用者只要创建一个状态做为guard,表示Process已经被destroy了,忽略除了exit以外的消息便可。
在Node里面,逻辑上是同样的,可是实现者的destroy的代码能够同步执行,它也是同步迁移到e状态的,使用者不须要创建guard变量来记录实现者的状态;按照Node的stream习惯,实现者应该有一个成员变量,destroyed,设置为bool类型,供使用者检查实现者状态。
逻辑上是和Destroy同样的,不一样之处在于实现者都处于s状态。
在一些错误处理状况下,使用者可能会根据一个过程对象是否end采起不一样的错误处理策略。还没有end的过程通常被抛弃了,一般也没法继续进行;可是已经end的过程可能会等待它完成。即对一组并发的过程可能采用不一样的策略。
可是在设计角度说,仍是前面那句话,要坚持mechanism和policy分离的原则。实现者应该提供这个机制而不是强制使用者必须用某种策略,策略是使用者的逻辑。它能够所有抛弃还没有完成的Process,也能够只抛弃还没有end的,对于大文件传输来讲这能够避免没必要要的再次传输,毕竟网络传输已经完成,只是文件还没有彻底写入文件系统。
下面来看使用者策略和须要注意的问题,其中一些问题的讨论会给使用者和实现者都增长新规则。
Event Handler是一种状态资源。
假如咱们在用状态机方法写一个对象(不须要是上述的过程),这个对象的某个状态有一个时钟,它在进入这个状态时须要创建这个时钟,在超时时向另外一个状态迁移,可是它也能够在计时状态下收到其余事件从而迁出这个状态,这是它必须清除这个时钟,这是状态机编程下的资源处理原则:在enter时建立,在exit时清理。为何要清理呢?即便不考虑浪费了一个系统时钟资源,这个时钟挂上了一个callback,咱们必须阻止它的执行,不然系统逻辑就错了。
因此从这个意义上说,Event Emitter上的全部Listener,也要被看做是资源。须要作相应的清理,不然可能会致使错误。
例子,启动一个子进程,等待它返回一个消息,做为过程须要的结果。
let c = child.spawn('some child process') c.on('error', err => {}) c.on('message', message => {}) c.on('exit', (code, signal) => { })
这段常见的代码形式最终如何封装取决于咱们的设计要求,若是容许省事咱们能够先把它封装成s -> 0
,即便用最简单的回调函数形式。
咱们看一下这三个handler,他们都是咱们须要在开始的时候就挂载上去的,包括exit(finish),由于子进程可能没有完成工做就意外死亡了。可是exit有点特殊,若是error先发生了,咱们就不关心message和exit了,若是message先发生了,咱们也再也不关心error和exit了,这意味着咱们已经拿到了正确的结果,即便在这个结果以后发生了子程序的意外退出,无所谓了,而若是exit先发生了,那么咱们也不用关心error和message了,由于ChildProcess
应该承诺这是finish。
因此看起来很是简单的代码,仔细分析一下才会发现三者是互斥的。无论结果如何,first win。因此代码写成这个样子了:
function doSomething(args, callback) { let c = child.spawn('some child process, with args') c.on('error', err => { c.removeAllListeners() c.on('error', () => {}) callback(err) }) c.on('message', message => { c.removeAllListeners() c.on('error', () => {}) callback(null, message) }) c.on('exit', (code, signal) => { // if ChildProcess is trusted, no need to remove any listeners callback(new Error(`unexpected exit with code ${code} and signal ${signal}`)) }) }
在error和message handler里都清除了与之互斥的代码路径。有一个小小的坑是Emitter的error handler必须提供,不然会形成全局错误,这是Node的设计,因此咱们塞上一个function mute它。另外这里的child对象是咱们本身建立的,这样粗暴的removeAllListeners
就能够了。若是是外部传入的,不能这样暴力,而只能清除本身装上去的handler。
这段代码一般的写法是在function内放一个闭包变量,例如let finished = false
,而后在全部的event handler里面作guard,若是只有一层逻辑,这样写是OK的,可是闭包变量作guard在逻辑多的时候,尤为是出现等待同步逻辑的时候,很无力。它没有清楚的看到全部的状态空间,容易致使错误。
习惯上我把这个设计原则称为mutex
(mutual exclusive),固然有时候不必定是双方互斥,象上面的例子就是多方的。mutex固然在thread model下有其余的含义,可是在Node.js的上下文下没有那个含义的mutex,咱们姑且用这个词。
这里顺便给一个拆除pipe的例子,由于这种状况太场景了,写不对的开发者不在少数。
假定在代码中有ws是write stream,rs是read stream,调用过rs.pipe(ws)
rs.removeAllListeners() ws.removeAllListeners() rs.on('error', () => {}) ws.on('error', () => {}) rs.unpipe() rs.destroy() // node 8.x specific ws.destroy() // node 8.x specific
这个原则表述起来很容易。
好比把上面P过程实现的代码进化成过程,即考虑使用者对finish事件是有兴趣的,它可能须要采起settle逻辑,而不只仅是race。
在这种状况下:
s -> 0
一步完成且获得message的成功路径是没有的。成功的路径只能是s1 -> s2 -> 0
,其中s1没有message,s2有。s -> 0
一步完成未得到message的路径是有的。s -> e -> 0
的路径是有的,先遇到错误,迁移到e状态,而后迁移到完成。s1 -> s2 -> e -> 0
的状况,即错误发生在获取message以后;与之相反的,也可能遇到先获得error而后获得message的可能,那么这两种状况咱们都抛弃了,咱们的设计目的是使用者对finish有兴趣,不是为了穷举状态空间。class DoSomething extends EventEmitter { constructor(args) { super() this.destroyed = false this.finished = false this.c = child.spawn('some child process, with args') c.on('error', err => { // enter e state this.destroyed = true c.removeAllListeners() c.on('error', () => {}) c.on('exit', (code, signal) => { this.finished = true this.emit('finish') }) this.emit('error', err) }) c.on('message', message => { // s1 -> s2 this.message = message c.removeAllListeners() c.on('error', () => {}) c.on('exit', (code, signal) => { this.finished = true this.emit('finish') }) this.emit('data', this.message) }) c.on('exit', (code, signal) => { // -> 0 this.finished = true this.emit('finish', new Error('unexpected exit')) }) } destroy () { if (this.finished || this.destroyed) return this.destroyed = true this.c.removeAllListeners() this.c.on('error', () => {}) this.c.on('exit', () => { this.finished = true this.emit('finished') }) this.c.kill() } }
这个例子不算特别的有表明性,可是它展现了和OO编程模式下的State Pattern同样的代码路径分开原则,收益也是同样的。
destroy
的实现也很容易。
代码中的这种实现方式大部分开发者都会赞成:强制转换状态,且继续emit finish。但咱们有另外的设计方式。
若是考虑error code path和success code path的分开,我推荐另外一种设计方式:
destroy在调用后,过程对象再也不emit finish事件;若是destroy提供了callback,在原有应该emit finish事件的地方,改成调用该callback;若是destroy没有提供callback,do nothing。换句话说,若是使用者提供了callback,它就选择了settle逻辑,若是它不提供callback,就是race了。
按照这样的设计方式,destroy的代码改成:
destroy (callback) { if (this.finished || this.destroyed) return this.destroyed = true this.c.removeAllListeners() this.c.on('error', () => {}) if (callback) { this.c.on('exit', () => { this.finished = true callback() }) } this.c.kill() }
站在使用者角度讲,这样的实现更好。由于使用者能够分开让过程自发运行和强制销毁的finish handler代码。destroy操做如此特殊,它几乎只用于错误处理阶段,让使用者为此独立提供代码块是更方便的,这符合咱们严格分开成功和错误处理的代码路径的原则。
下面来给前面用P过程状态定义实现的异步函数加装destroy方法,粗暴的方式是直接返回函数(而不是对象句柄)。
function doSomething(args, callback) { let destroyed = false let finished = false let c = child.spawn('some child process, with args') c.on('error', err => { c.removeAllListeners() c.on('error', () => {}) finished = true callback(err) }) c.on('message', message => { c.removeAllListeners() c.on('error', () => {}) finished = true callback(null, message) }) c.on('exit', (code, signal) => { // if ChildProcess is trusted, no need to remove any listeners finished = true callback(new Error(`unexpected exit with code ${code} and signal ${signal}`)) }) return (callback) => { if (destroyed || finished) return c.removeAllListeners() c.on('error', () => {}) if (callback) { c.on('exit', () => callback()) } c.kill() } }
这个逻辑和以前是同样的。可是这个作法更容易出现设计形式上的争议:由于doSomething在调用时提供的callback没有保证返回。
咱们说这个原则在doSomething不提供返回的时候确定是须要保证的。可是若是象这样写,这个原则能够修改。
反对这样设计的理由是充分的,任何请求或者Listener在提供者销毁时应该获得通知,这是对的;好比在OO编程时咱们常常会观察一些对象,或者向某个提供服务的组件请求服务,即便被观察对象或者服务组件销毁也应该提供返回或通知。
可是这里的设计原则和上述场景不同。在这里咱们强调Owner原则,使用者建立实现者是给本身使用的,本身是Owner,本身销毁这个实现者而后还要坚持从原有的callback或者finish handler获得一个特定的Error Code判断销毁缘由,这没有必要。
至于上述的Observer Pattern的Listener,或者向Service组件的请求的实现,咱们在下一篇会给出代码例子。
在这里咱们强调的是,这里的callback或者finish handler,不是上述Observer/Service Request意义上的listener/callback,Observer和Service Requester并不是被观察对象或者服务的Owner,他们固然无权修改对象或服务的特性,并且服务质量也必须被保证。
可是在这里,咱们是在用callback或者emitter这种代码形式实现process composition所需的inter-process communication。咱们有权为了使用便利而设计代码形式。
为复杂Process创建显式状态,是解决问题的根本方法。若是设计要求就是具备这些状态的,你用什么方法写都同样,这不是Node特有的问题。
Node特有的问题在于它写异步有态过程须要遵循的设计原则,不少开发者不熟悉状态机方法,因此很难写的健壮。这个话题也可能有它比较独特的地方,由于Node是强制异步过程和事件模型的混合体,可是这种编程模式在嵌入式系统、内核、以及一些系统程序中,是很是常见的(大多数是C语言的,指针实现callback)。
这篇文章我会长期维护。若是须要更多的代码示例,或者有很具体的问题须要讨论,能够提出。
只要能把基础的异步过程写对,如何组合它们实现并发,并发控制,等待,同步(join),以及健壮的错误处理,是易如反掌的,那是咱们下一篇的话题。