ES6 Generators系列:
如果你已经读过这个系列的前三篇文章,那么你肯定对ES6 generators非常了解了。希望你能从中有所收获并让generator发挥它真正的作用。最后我们要探讨的这个主题可能会让你血脉喷张,让你绞尽脑汁(说实话,写这篇文章让我很费脑子)。花点时间看下文章中的这些例子,相信对你还是很有帮助的。在学习上的投资会让你将来受益无穷。我完全相信,在未来,JS中那些复杂的异步能力将起源于我这里的一些想法。
CSP(Communicating Sequential Processes)
首先,我写这一系列文章完全是受Nolen @swannodette出色工作的启发。说真的,他写的所有文章都值得去读一读。我这里有一些链接可以分享给你:
好了,让我们正式开始对这个主题的探讨。我不是一个从具有Clojure(Clojure是一种运行在Java平台上的 Lisp 方言)背景转投到JS阵营的程序员,而且我也没有任何Go或者ClojureScript的经验。我发现自己在读这些文章的时候很快就会失去兴趣,因此我不得不做很多的实验并从中了解到一些有用的东西。
在这个过程中,我觉得我已经有了一些相同的思想,并追求同样的目标,而这些都源自于一个不那么古板的思维方式。
我尝试创建了一个更简单的Go风格的CSP(以及ClojureScript core.async)APIs,同时我希望能保留大部分的底层功能。也许有大神会看到我文章中遗漏的地方,这完全有可能。如果真是这样的话,我希望我的探索能够得到进一步的发展和演变,而我也将和大家一起来分享这个过程!
详解CSP原理(一点点)
到底什么是CSP?说它是"communicating","Sequential","processes"到底是什么意思呢?
首先,CSP一词源自于Tony Hoare所著的“Communicating Sequential Processes”一书。里面全是有关CS的理论,如果你对学术方面的东西感兴趣的话,这本书绝对值得一读。我决不打算以一种让人难以理解的,深奥的,计算机科学的方式来阐述这个主题,而是会以一种轻松的非正式的方式来进行。
那我们就从"Sequential"开始吧!这部分你应该已经很熟悉了。这是另外一种谈论有关单线程和ES6 generators异步风格代码的方式。我们来回忆一下generators的语法:
function *main() { var x = yield 1; var y = yield x; var z = yield (y * 2); }
上面代码中的每一条语句都会按顺序一个一个地执行。Yield关键字标明了代码中被阻塞的点(只能被generator函数自己阻塞,外部代码不能阻塞generator函数的执行),但是不会改变*main()函数中代码的执行顺序。这段代码很简单!
接下来我们来讨论一下"processes"。这个是什么呢?
基本上,generator函数有点像一个虚拟的"process",它是我们程序的一个独立的部分,如果JavaScript允许,它完全可以与程序的其它部分并行执行。这听起来似乎有点儿荒唐!如果generator函数访问共享内存(即,如果它访问除了自己内部定义的局部变量之外的“自由变量”),那么它就不是一个独立的部分。现在我们假设有一个不访问外部变量的generator函数(在FP(Functional Programming函数式编程)的理论中我们将它称之为一个"combinator"),因此从理论上来说它可以在自己的process中运行,或者说作为自己的process来运行。
但是我们说的是"processes",注意这个单词用的是复数,这是因为会存在两个或多个process在同一时间运行。换句话说,两个或多个generators函数会被放到一起来协同工作,通常是为了完成一项较大的任务。
为什么要用多个单独的generator函数,而不是把它们都放到一个generator函数里呢?一个最重要的原因就是:功能和关注点的分离。对于一个任务XYZ来说,如果你将它分解成子任务X,Y和Z,那么在每个子任务自己的generator函数中来实现功能将会使代码更容易理解和维护。这和将函数XYZ()拆分成X(),Y(),和Z(),然后在X()中调用Y(),在Y()中调用Z()是一样的道理。我们将函数分解成一个个独立的子函数,降低代码的耦合度,从而使程序更加容易维护。
对于多个generators函数来说我们也可以做到这一点
这就要说到"communicating"了。这个又是什么呢?就是合作。如果我们将多个generators函数放在一些协同工作,它们彼此之间需要一个通信信道(不仅仅是访问共享的作用域,而是一个真正的可以被它们访问的独占式共享通信信道)。这个通信信道是什么呢?不管你发送什么内容(数字,字符串等),事实上你都不需要通过信道发送消息来进行通信。通信会像合作那样简单,就像将程序的控制权从一个地方转移到另外一个地方。
为什么需要转移控制?这主要是因为JS是单线程的,意思是说在任意给定的一个时间片段内只会有一个程序在运行,而其它程序都处在暂停状态。也就是说其它程序都处在它们各自任务的中间状态,不过只是被暂停执行,必要时会恢复并继续运行。
任意独立的"processes"之间可以神奇地进行通信和合作,这听起来有点不靠谱。这种解耦的想法是好的,但是有点不切实际。相反,似乎任何一个成功的CSP的实现都是对那些问题领域中已存在的、众所周知的逻辑集的有意分解,其中每个部分都被特殊设计过从而使得各部分之间都能良好工作。
或许我的理解完全是错的,但是我还没有看到任何一个切实可行的方法,能够让两个随机给定的generator函数可以以某种方式轻易地聚合在一起形成CSP对。它们都需要被设计成能够与其它部分一起工作,需要遵照彼此间的通信协议等等。
JS中的CSP
在将CSP的理论应用到JS中,有一些非常有趣的探索。前面提到的David Nolen,他有几个很有趣的项目,包括Om,以及core.async。Koa库(node.js)主要通过它的use(..)方法体现了这一点。而另外一个对core.async/Go CSP API十分忠实的库是js-csp。
你确实应该去看看这些伟大的项目,看看其中的各种方法和例子,了解它们是如何在JS中实现CSP的。
异步的runner(..):设计CSP
因为我一直在努力探索将并行的CSP模式应用到我自己的JS代码中,所以对于使用CSP来扩展我自己的异步流程控制库asynquence来说就是一件顺理成章的事。我写过的runner(..)插件(看上一篇文章:ES6 Generators的异步应用)就是用来处理generators函数的异步运行的,我发现它可以很容易被扩展用来处理多generators函数在同一时间运行,就像CSP的方式那样。
我要解决的第一个设计问题是:如何才能知道哪个generator函数将获得下一个控制权?
要解决各个generators函数之间的消息或控制权的传递,每个generator函数都必须拥有一个能让其它generators函数知道的ID,这看起来似乎过于笨拙。经过各种尝试,我设定了一个简单的循环调度方法。如果你匹配了三个generators函数A,B和C,那么A将先获得控制权,当A yield时B将接管A的控制权,然后当B yield时C将接管B,然后又是A,以此类推。
但是如何才能实际转移generator函数的控制权呢?应该有一个显式的API吗?我再次进行了各种尝试,然后设定了一个更加隐式的方法,看起来和Koa有点类似(完全是以外):每个generator函数都获得一个共享"token"的引用,当yield时就表示要将控制权进行转移。
另一个问题是消息通道应该长什么样。一种是非常正式的通信API如core.async和js-csp(put(..)和take(..))。但是在我经过各种尝试之后,我比较倾向于另一种不太正式的方法(甚至都谈不上API,而只是一个共享的数据结构,例如数组),它看起来似乎是比较靠谱的。
我决定使用数组(称之为消息),你可以根据需要决定如何填充和清空数组的内容。你可以push()消息到数组中,从数组中pop()消息,按照约定将不同的消息存放到数组中特定的位置,并在这些位置存放更复杂的数据结构等。
我的疑惑是有些任务需要传递简单的消息,而有些则需要传递复杂的消息,因此不要在一些简单的情况下强制这种复杂度,我选择不拘泥于消息通道的形式而使用数组(除数组本身外这里没有任何API)。在某些情况下它很容易在额外的形式上对消息传递机制进行分层,这对我们来说很有用(参见下面的状态机示例)。
最终,我发现这些generator "processes"仍然得益于那些独立的generators可以使用的异步功能。也就是说,如果不yield控制token,而yield一个Promise(或者一个异步队列),则runner(..)的确会暂停以等待返回值,但不会转移控制权,它会将结果返回给当前的process(generator)而保留控制权。
最后一点也许是最有争议或与本文中其它库差别最大的(如果我解释正确的话)。也许真正的CSP对这些方法不屑一顾,但是我发现我的选择还是很有用的。
一个愚蠢的FooBar示例
好了,理论的东西讲得差不多了。我们来看看具体的代码:
// 注意:为了简洁,省略了虚构的`multBy20(..)`和`addTo2(..)`异步数学函数 function *foo(token) { // 从通道的顶部获取消息 var value = token.messages.pop(); // 2 // 将另一个消息存入通道 // `multBy20(..)`是一个promise-generating函数,它会延迟返回给定值乘以`20`的计算结果 token.messages.push( yield multBy20( value ) ); // 转移控制权 yield token; // 从CSP运行中的最后的消息 yield "meaning of life: " + token.messages[0]; } function *bar(token) { // 从通道的顶部获取消息 var value = token.messages.pop(); // 40 // 将另一个消息存入通道 // `addTo2(..)` 是一个promise-generating函数,它会延迟返回给定值加上`2`的计算结果 token.messages.push( yield addTo2( value ) ); // 转移控制权 yield token; }
上面的代码中有两个generator "processes",*foo()和*bar()。它们都接收并处理一个令牌(当然,如果你愿意你可以随意叫什么都行)。令牌上的属性messages就是我们的共享消息通道,当CSP运行时它会获取初始化传入的消息值进行填充(后面会讲到)。
yield token显式地将控制权转移到“下一个”generator函数(循环顺序)。但是,yield multBy20(value)和yield addTo2(value)都是yield一个promises(从这两个虚构的延迟计算函数中返回的),这表示generator函数此时是处于暂停状态直到promise完成。一旦promise完成,当前处于控制中的generator函数会恢复并继续运行。
无论最终yield会返回什么,上面的例子中yield返回的是一个表达式,都表示我们的CSP运行完成的消息(见下文)。
现在我们有两个CSP process generators,我们来看看如何运行它们?使用asynquence:
// 开始一个sequence,初始message的值是2 ASQ( 2 ) // 将两个CSP processes进行配对一起运行 .runner( foo, bar ) // 无论接收到的message是什么,都将它传入sequence中的下一步 .val( function(msg){ console.log( msg ); // 最终返回42 } );
这只是一个很简单的例子,但我觉得它能很好地用来解释上面的这些概念。你可以尝试一下(试着改变一些值),这有助于你理解这些概念并自己动手编写代码!
另一个例子Toy Demo
让我们来看一个经典的CSP例子,但只是从我们目前已有的一些简单的发现开始,而不是从我们通常所说的纯粹学术的角度来展开讨论。
Ping-pong。一个很有趣的游戏,对吗?也是我最喜欢的运动。
让我们来想象一下你已经完成了这个乒乓球游戏的代码,你通过一个循环来运行游戏,然后有两部分代码(例如在if或switch语句中的分支),每一部分代表一个对应的玩家。代码运行正常,你的游戏运行起来就像是一个乒乓球冠军!
但是按照我们上面讨论过的,CSP在这里起到了什么样的作用呢?就是功能和关注点的分离。那么具体到我们的乒乓球游戏中,这个分离指的就是两个不同的玩家!
那么,我们可以在一个非常高的层面上用两个"processes"(generators)来模拟我们的游戏,每个玩家一个"process"。当我们实现代码细节的时候,我们会发现在两个玩家之家存在控制的切换,我们称之为"glue code"(胶水代码(译:在计算机编程领域,胶水代码也叫粘合代码,用途是粘合那些可能不兼容的代码。可以使用与胶合在一起的代码相同的语言编写,也可以用单独的胶水语言编写。胶水代码不实现程序要求的任何功能,它通常出现在代码中,使现有的库或者程序在外部函数接口(如Java本地接口)中进行互操作。胶水代码在快速原型开发环境中非常高效,可以让几个组件被快速集成到单个语言或者框架中。)),这个任务本身可能需要第三个generator的代码,我们可以将它模拟成游戏的裁判。
我们打算跳过各种特定领域的问题,如计分、游戏机制、物理原理、游戏策略、人工智能、操作控制等。这里我们唯一需要关心的部分就是模拟打乒乓球的往复过程(这实际上也代表了我们CSP的控制转移)。
想看demo的话可以在这里运行(注意:在支持ES6 JavaScript的最新版的FireFox nightly或Chrome中查看generators是如何工作的)。现在,让我们一起来看看代码。首先,来看看asynquence sequence长什么样?
ASQ( ["ping","pong"], // 玩家姓名 { hits: 0 } // 球 ) .runner( referee, player, player ) .val( function(msg){ message( "referee", msg );
我们初始化了一个messages sequence:["ping", "pong"]和{hits: 0}。一会儿会用到。然后,我们设置了一个包含3个processes运行的CSP(相互协同工作):一个*referee()和两个*player()实例。在游戏结束时最终的message会被传递给sequence中的下一步,作为referee的输出message。下面是referee的实现代码:
function *referee(table){ var alarm = false; // referee通过秒表(10秒)为游戏设置了一个计时器 setTimeout( function(){ alarm = true; }, 10000 ); // 当计时器警报响起时游戏停止 while (!alarm) { // 玩家继续游戏 yield table; } // 通知玩家游戏已结束 table.messages[2] = "CLOSED"; // 裁判宣布时间到了 yield "Time's up!"; } } );
这里我们用table来模拟控制令牌以解决我们上面说的那些特定领域的问题,这样就能很好地来描述当一个玩家将球打回去的时候控制权被yield给另一个玩家。*referee()中的while循环表示只要秒表没有停,程序就会一直yield table(将控制权转移给另一个玩家)。当计时器结束时退出while循环,referee将会接管控制权并宣布"Time's up!"游戏结束了。
再来看看*player() generator的实现代码(我们使用两个实例):
function *player(table) { var name = table.messages[0].shift(); var ball = table.messages[1]; while (table.messages[2] !== "CLOSED") { // 击球 ball.hits++; message( name, ball.hits ); // 模拟将球打回给另一个玩家中间的延迟 yield ASQ.after( 500 ); // 游戏继续? if (table.messages[2] !== "CLOSED") { // 球现在回到另一个玩家那里 yield table; } } message( name, "Game over!" ); }
第一个玩家将他的名字从message数组的第一个元素中移除("ping"),然后第二个玩家取他的名字("pong"),以便他们都能正确地识别自己(译:注意这里是两个*player()的实例,在两个不同的实例中,通过table.messages[0].shift()可以获取各自不同的玩家名字)。同时两个玩家都保持对共享球的引用(使用hits计数器)。
当玩家还没有听到裁判说结束,就“击球”并累加计数器(并输出一个message来通知它),然后等待500毫秒(假设球以光速运行不占用任何时间)。如果游戏还在继续,他们就yield table到另一个玩家那里。就是这样。
在这里可以查看完整代码,从而了解代码的各部分是如何工作的。
状态机:Generator协同程序
最后一个例子:将一个状态机定义为由一个简单的helper驱动的一组generator协同程序。Demo(注意:在支持ES6 JavaScript的最新版的FireFox nightly或Chrome中查看generators是如何工作的)。
首先,我们定义一个helper来控制有限的状态处理程序。
function state(val,handler) { // 管理状态的协同处理程序(包装器) return function*(token) { // 状态转换处理程序 function transition(to) { token.messages[0] = to; } // 默认初始状态(如果还没有设置) if (token.messages.length < 1) { token.messages[0] = val; } // 继续运行直到最终的状态为true while (token.messages[0] !== false) { // 判断当前状态是否和处理程序匹配 if (token.messages[0] === val) { // 委托给状态处理程序 yield *handler( transition ); } // 将控制权转移给另一个状态处理程序 if (token.messages[0] !== false) { yield token; } } }; }
state(..) helper为特定的状态值创建了一个delegating-generator包装器,这个包装器会自动运行状态机,并在每个状态切换时转移控制权。
依照惯例,我决定使用共享token.messages[0]的位置来保存我们状态机的当前状态。这意味着你可以通过从序列中前一步传入的message来设定初始状态。但是如果没有传入初始值的话,我们会简单地将第一个状态作为默认的初始值。同样,依照惯例,最终的状态会被假设为false。这很容易修改以适合你自己的需要。
状态值可以是任何你想要的值:numbers,strings等。只要该值可以被===运算符严格测试通过,你就可以使用它作为你的状态。
在下面的示例中,我展示了一个状态机,它可以按照特定的顺序在四个数值状态间进行转换:1->4->3->2。为了演示,这里使用了一个计数器,因此可以实现多次循环转换。当我们的generator状态机到达最终状态时(false),asynquence序列就会像你所期望的那样移动到下一步。
// 计数器(仅用作演示) var counter = 0; ASQ( /* 可选:初始状态值 */ ) // 运行状态机,转换顺序:1 -> 4 -> 3 -> 2 .runner( // 状态`1`处理程序 state( 1, function*(transition){ console.log( "in state 1" ); yield ASQ.after( 1000 ); // 暂停1s yield transition( 4 ); // 跳到状态`4` } ), // 状态`2`处理程序 state( 2, function*(transition){ console.log( "in state 2" ); yield ASQ.after( 1000 ); // 暂停1s // 仅用作演示,在状态循环中保持运行 if (++counter < 2) { yield transition( 1 ); // 跳转到状态`1` } // 全部完成! else { yield "That's all folks!"; yield transition( false ); // 跳转到最终状态 } } ), // 状态`3`处理程序 state( 3, function*(transition){ console.log( "in state 3" ); yield ASQ.after( 1000 ); // 暂停1s yield transition( 2 ); // 跳转到状态`2` } ), // 状态`4`处理程序 state( 4, function*(transition){ console.log( "in state 4" ); yield ASQ.after( 1000 ); // 暂停1s yield transition( 3 ); // 跳转到状态`3` } ) ) // 状态机完成,移动到下一步 .val(function(msg){ console.log( msg ); });
应该很容易地跟踪上面的代码来查看到底发生了什么。yield ASQ.after(1000)显示了这些generators可以根据需要做任何类型的基于promise/sequence的异步工作,就像我们在前面所看到的一样。yield transition(...)表示如何转换到一个新的状态。上面代码中的state(..) helper完成了处理yield* delegation和状态转换的主要工作,然后整个程序的主要流程看起来十分简单,表述也很清晰流畅。
总结
CSP的关键是将两个或更多的generator "processes"连接在一起,给它们一个共享的通信信道,以及一种可以在彼此间传输控制的方法。
JS中有很多的库都或多或少地采用了相当正式的方法来与Go和Clojure/ClojureScript APIs或语义相匹配。这些库的背后都有着非常棒的开发者,对于进一步探索CSP来说他们都是非常好的资源。
asynquence试图采用一种不太正式而又希望仍然能够保留主要结构的方法。如果没有别的 ,asynquence的runner(..) 可以作为你实验和学习CSP-like generators的入门。
最好的部分是asynquence CSP与其它异步功能(promises,generators,流程控制等)在一起工作。如此一来,你便可以掌控一切,使用任何你手头上合适的工具来完成任务,而所有的这一切都只在一个小小的lib中。
现在我们已经在这四篇文章中详细探讨了generators,我希望你能够从中受益并获得灵感以探索如何革新自己的异步JS代码!你将用generators来创造什么呢?