你不懂JS: 异步与性能 附录B: 高级异步模式

官方中文版原文链接

感谢社区中各位的大力支持,译者再次奉上一点点福利:阿里云产品券,享受所有官网优惠,并抽取幸运大奖:点击这里领取

为了了解主要基于 Promise 与 Generator 的面向序列异步流程控制,附录A介绍了 asynquence 库。

现在我们将要探索其他建立在既存理解与功能之上的高级异步模式,并看看 asynquence 是如何在不需要许多分离的库的情况下,使得这些精巧的异步技术与我们的程序进行混合与匹配的。

可迭代序列

我们在前一篇附录中介绍过 asynquence 的可迭代序列,我们将更加详细地重温它们。

为了复习,回忆一下:

var domready = ASQ.iterable();

// ..

domready.val( function(){
    // DOM 准备好了
} );

// ..

document.addEventListener( "DOMContentLoaded", domready.next );

现在,让我们定义将一个多步骤序列定义为一个可迭代序列:

var steps = ASQ.iterable();

steps
.then( function STEP1(x){
    return x * 2;
} )
.steps( function STEP2(x){
    return x + 3;
} )
.steps( function STEP3(x){
    return x * 4;
} );

steps.next( 8 ).value;  // 16
steps.next( 16 ).value; // 19
steps.next( 19 ).value; // 76
steps.next().done;      // true

如你所见,一个可迭代序列是一个标准兼容的 iterator(见第四章)。所以,就像一个 generator(或其他任何 可迭代对象)那样,它是可以使用ES6for..of循环进行迭代的,

var steps = ASQ.iterable();

steps
.then( function STEP1(){ return 2; } )
.then( function STEP2(){ return 4; } )
.then( function STEP3(){ return 6; } )
.then( function STEP4(){ return 8; } )
.then( function STEP5(){ return 10; } );

for (var v of steps) {
    console.log( v );
}
// 2 4 6 8 10

除了在前一篇附录中展示的事件触发的例子之外,可迭代序列的有趣之处还因为它们实质上可以被视为 generator 和 Promise 链的替代品,但具备更多灵活性。

考虑一个多Ajax请求的例子 —— 我们已经在第三章和第四章中看到过同样的场景,分别使用一个 Promise 链和一个 generator —— 表达为一个可迭代序列:

// 兼容序列的 ajax
var request = ASQ.wrap( ajax );

ASQ( "http://some.url.1" )
.runner(
    ASQ.iterable()

    .then( function STEP1(token){
        var url = token.messages[0];
        return request( url );
    } )

    .then( function STEP2(resp){
        return ASQ().gate(
            request( "http://some.url.2/?v=" + resp ),
            request( "http://some.url.3/?v=" + resp )
        );
    } )

    .then( function STEP3(r1,r2){ return r1 + r2; } )
)
.val( function(msg){
    console.log( msg );
} );

可迭代序列表达了一系列顺序的(同步的或异步的)步骤,它看起来与一个 Promise 链极其相似 —— 换言之,它要比单纯嵌套的回调看起来干净的多,但没有 generator 的基于yield的顺序化语法那么好。

但我们将可迭代序列传入ASQ#runner(..),它将可迭代序列像一个 generator 那样运行至完成。由于几个原因,一个可迭代序列的行为实质上与一个 generator 相同的事实是值得注意的:

首先,对于ES6 generator 的特定子集来说,可迭代对象是它的一种前ES6等价物,这意味着你既可以直接编写它们(为了在任何地方都能运行),也可以编写ES6 generator 并将它们转译/转换成可迭代序列(或者 Promise 链!)。

将一个异步运行至完成的 generator 考虑为一个 Promise 链的语法糖,是对它们之间的同构关系的一种重要认识。

在我们继续之前,我们应当注意到,前一个代码段本可以用 asynquence 表达为:

ASQ( "http://some.url.1" )
.seq( /*STEP 1*/ request )
.seq( function STEP2(resp){
    return ASQ().gate(
        request( "http://some.url.2/?v=" + resp ),
        request( "http://some.url.3/?v=" + resp )
    );
} )
.val( function STEP3(r1,r2){ return r1 + r2; } )
.val( function(msg){
    console.log( msg );
} );

进一步,步骤2本可以被表达为:

.gate(
    function STEP2a(done,resp) {
        request( "http://some.url.2/?v=" + resp )
        .pipe( done );
    },
    function STEP2b(done,resp) {
        request( "http://some.url.3/?v=" + resp )
        .pipe( done );
    }
)

那么,为什么我们要在一个简单/扁平的 asyquence 链看起来可以很好地工作的情况下,很麻烦地将自己的控制流在一个ASQ#runner(..)步骤中表达为一个可迭代序列呢?

因为可迭代序列的形式有一种重要的技巧可以给我们更多的力量。继续读。

扩展可迭代序列

Generator,普通的 asynquence 序列,和 Promise 链,都是被 急切求值 的 —— 控制流程最初要表达的的内容 就是 紧跟在后面的固定流程。

然而,可迭代序列是 懒惰求值 的,这意味着在可迭代序列执行期间,如果有需要的话你可以用更多的步骤扩展这个序列。

注意: 你只能在一个可迭代序列的末尾连接,而不是在序列的中间插入。

为了熟悉这种能力,首先让我们看一个比较简单(同步)的例子:

function double(x) {
    x *= 2;

    // 我们应当继续扩展吗?
    if (x < 500) {
        isq.then( double );
    }

    return x;
}

// 建立单步可迭代序列
var isq = ASQ.iterable().then( double );

for (var v = 10, ret;
    (ret = isq.next( v )) && !ret.done;
) {
    v = ret.value;
    console.log( v );
}

这个可迭代序列开始时只有一个定义好的步骤(isq.then(double)),但是这个序列会在特定条件下(x < 500)持续扩展自己。asynquence 序列和 Promise 链在技术上都 可以 做相似的事情,但是我们将看到它们的这种能力不足的一些原因。

这个例子意义不大,而且本可以使用一个 generator 中的while循环来表达,所以我们将考虑更精巧的情况。

例如,你可以检查一个Ajax请求的应答,看它是否指示需要更多的数据,你可以条件性地向可迭代序列插入更多的步骤来发起更多的请求。或者你可以条件性地在Ajax处理器的末尾加入一个格式化步骤。

考虑如下代码:

var steps = ASQ.iterable()

.then( function STEP1(token){
    var url = token.messages[0].url;

    // 有额外的格式化步骤被提供吗?
    if (token.messages[0].format) {
        steps.then( token.messages[0].format );
    }

    return request( url );
} )

.then( function STEP2(resp){
    // 要为序列增加另一个Ajax请求吗?
    if (/x1/.test( resp )) {
        steps.then( function STEP5(text){
            return request(
                "http://some.url.4/?v=" + text
            );
        } );
    }

    return ASQ().gate(
        request( "http://some.url.2/?v=" + resp ),
        request( "http://some.url.3/?v=" + resp )
    );
} )

.then( function STEP3(r1,r2){ return r1 + r2; } );

你可以在两个地方看到我们使用steps.then(..)条件性地扩展了step。为了运行这个steps可迭代序列,我们只要使用ASQ#runner(..)将它与一个 asynquence 序列(这里称为main)链接进我们的主程序流程中:

var main = ASQ( {
    url: "http://some.url.1",
    format: function STEP4(text){
        return text.toUpperCase();
    }
} )
.runner( steps )
.val( function(msg){
    console.log( msg );
} );

steps可迭代序列的灵活性可以使用一个 generator 来表达吗?某种意义上可以,但我们不得不以一种有些尴尬的方式重新安排逻辑:

function *steps(token) {
    // **步骤 1**
    var resp = yield request( token.messages[0].url );

    // **步骤 2**
    var rvals = yield ASQ().gate(
        request( "http://some.url.2/?v=" + resp ),
        request( "http://some.url.3/?v=" + resp )
    );

    // **步骤 3**
    var text = rvals[0] + rvals[1];

    // **步骤 4**
    // 有额外的格式化步骤被提供吗?
    if (token.messages[0].format) {
        text = yield token.messages[0].format( text );
    }

    // **步骤 5**
    // 要为序列增加另一个Ajax请求吗?
    if (/foobar/.test( resp )) {
        text = yield request(
            "http://some.url.4/?v=" + text
        );
    }

    return text;
}

// 注意:`*steps()`可以向先前的`step`一样被相同的`ASQ`序列运行

先把我们已经知道的序列的好处,以及看起来同步的 generator 语法(见第四章)放在一边,steps逻辑不得不在*steps() generator 形式中重排,来假冒可扩展的可迭代序列steps的动态机制。

那么,使用 Promise 或者序列如何表达这种功能呢?你 可以 这么做:

var steps = something( .. )
.then( .. )
.then( function(..){
    // ..

    // 扩展这个链条,对吧?
    steps = steps.then( .. );

    // ..
})
.then( .. );

这里要抓住的问题很微妙但很重要。那么,考虑试着将我们的stpes Promise 链连接到我们的主程序流程中 —— 这次使用 Promise 代替 asynquence 来表达:

var main = Promise.resolve( {
    url: "http://some.url.1",
    format: function STEP4(text){
        return text.toUpperCase();
    }
} )
.then( function(..){
    return steps;           // 提示!
} )
.val( function(msg){
    console.log( msg );
} );

现在你能发现问题吗?仔细观察!

对于序列步骤的顺序来说,这里有一个竞合状态。当你return steps时,steps在那个时刻 可能 是原本定义好的 promise 链了,或者它现在可能通过steps = steps.then(..)调用正指向扩张的 promise 链,这要看事情以什么顺序发生。

这里有两种可能的结果:

  • 如果steps仍然是原来的 Promise 链,一旦它稍后通过steps = steps.then(..)“扩展”,这个位于链条末尾的扩展过的 promise 是 不会main流程考虑的,因为它已经通过这个steps链了。这就是不幸的 急切求值 限制。
  • 如果steps已经是扩展过的 promise 链了,那么由于这个扩展过的 promise 正是main要通过的东西,所以它会如我们期望的那样工作。

第一种情况除了展示竞合状态不可容忍的明显事实,它还展示了 promise 链的 急切求值。相比之下,我们可以很容易地扩展可迭代序列而没有这样的问题,因为可迭代序列是 懒惰求值 的。

你越需要自己的流程控制动态,可迭代序列就越显得强大。

提示:asynquence 的网站(https://github.com/getify/asynquence/blob/master/README.md#iterable-sequences)上可以看到更多关于可迭代序列的信息与示例。

事件响应式

(至少!)从第三章看来这应当很明显:Promise 是你异步工具箱中的一种非常强大的工具。但它们明显缺乏处理事件流的能力,因为一个 Promise 只能被解析一次。而且坦白地讲,对于 asynquence 序列来说这也正是它的一个弱点。

考虑这样一个场景:你想要在一个特定事件每次被触发时触发一系列步骤。一个单独的 Promise 或序列不能表示这个事件全部的发生状况。所以,你不得不为每一个事件的发生创建一个全新的 Promise 链(或序列),比如:

listener.on( "foobar", function(data){

    // 创建一个新的事件处理 Promise 链
    new Promise( function(resolve,reject){
        // ..
    } )
    .then( .. )
    .then( .. );

} );

在这种方式拥有我们需要的基本功能,但是对于表达我们意图中的逻辑来说远不能使人满意。两种分离的能力混杂在这个范例中:事件监听,与事件应答;而关注点分离原则恳求我们将这些能力分开。

细心的读者会发现,这个问题与我们在第二章中详细讲解过的问题是有些对称的;它是一种控制反转问题。

想象一下非反转这个范例,就像这样:

var observable = listener.on( "foobar" );

// 稍后
observable
.then( .. )
.then( .. );

// 在其他的地方
observable
.then( .. )
.then( .. );

observable不是一个真正的 Promise,但你可以像监听一个 Promise 那样 监听 它,所以它们是有密切关联的。事实上,它可以被监听很多次,而且它会在每次事件("foobar")发生时都发送通知。

提示: 我刚刚展示过的这个模式,是响应式编程(reactive programming,也称为 RP)背后的概念和动机的 大幅度简化,响应式编程已经由好几种了不起的项目和语言实现/详细论述过了。RP 的一个变种是函数响应式编程(functional reactive programming,FRP),它指的是在数据流之上实施函数式编程技术(不可变性,参照完整性,等等)。“响应式”指的是随着事件的推移散布这种功能,以对事件进行应答。对此感兴趣的读者应当考虑学习“响应式可监听对象”,它源于由微软开发的神奇的“响应式扩展”库(对于 JavaScript 来说是 “RxJS”,http://rxjs.codeplex.com/);它可要比我刚刚展示过的东西精巧和强大太多了。另外,Andre Staltz 写过一篇出色的文章(https://gist.github.com/staltz/868e7e9bc2a7b8c1f754),用具体的例子高效地讲解了 RP。

ES7 可监听对象

在本书写作时,有一个早期ES7提案,一种称为“Observable(可监听对象)”的新数据类型(https://github.com/jhusain/asyncgenerator#introducing-observable),它在精神上与我们在这里讲解过的相似,但是绝对更精巧。

这种可监听对象的概念是,你在一个流上“监听”事件的方法是传入一个 generator —— 其实 迭代器 才是有趣的部分 —— 它的next(..)方法会为每一个事件而调用。

你可以想象它是这样一种东西:

// `someEventStream` 是一个事件流,来自于鼠标点击之类

var observer = new Observer( someEventStream, function*(){
    while (var evt = yield) {
        console.log( evt );
    }
} );

你传入的 generator 将会yield而暂停while循环,来等待下一个事件。添附在 generator 实例上的 迭代器next(..)将会在每次someEventStream发布一个新事件时被调用,因此这个事件将会使用evt数据推进你的 generator/迭代器

在这里的监听事件功能中,重要的是 迭代器 的部分,而不是 generator。所以从概念上讲,你实质上可以传入任何可迭代对象,包括ASQ.iterable()可迭代序列。

有趣的是,还存在一些被提案的适配方案,使得从特定类型的流中构建可监听对象变得容易,例如为DOM事件提案的fromEvent(..)。如果你去看看fromEvent(..)在早期ES7提案中推荐的实现方式,你会发现它与我们将要在下一节中看到的ASQ.react(..)极其相似。

当然,这些都是早期提案,所以最终脱颖而出的东西可能会在外观/行为上与这里展示的有很大的不同。但是看到在不同的库与语言提案在概念上的早期统一还是很激动人心的!

响应式序列

将这种可监听对象(和F/RP)的超级简要的概览作为我们的启发与动机,我们现在将展示一种“响应式可监听对象”的很小的子集的适配方案,我称之为“响应式序列”。

首先,让我们从如何创建一个可监听对象开始,使用一个称为react(..)asynquence 插件工具:

var observable = ASQ.react( function setup(next){
    listener.on( "foobar", next );
} );

现在,让我们看看如何为这个observable定义一个“响应的”序列 —— 在F/RP中,这通常称为“监听”:

observable
.seq( .. )
.then( .. )
.val( .. );

所以,你只需要通过在这个可监听对象后面进行链接就可以了。很容易,是吧?

在F/RP中,事件流经常会通过一组函数式的变形,比如scan(..)map(..)reduce(..),等等。使用响应式序列,每个事件会通过一个序列的新的实例。让我们看一个更具体的例子:

ASQ.react( function setup(next){
    document.getElementById( "mybtn" )
    .addEventListener( "click", next, false );
} )
.seq( function(evt){
    var btnID = evt.target.id;
    return request(
        "http://some.url.1/?id=" + btnID
    );
} )
.val( function(text){
    console.log( text );
} );

响应式序列的“响应式”部分来源于分配一个或多个事件处理器来调用事件触发器(调用next(..))。

响应式序列的“序列”部分正是我们已经探索过的:每一个步骤都可以是任何合理的异步技术 —— 延续回调,Promise 或者 generator。

一旦拟建立了一个响应式序列,只要事件被持续地触发,它就会一直初始化序列的实例。如果你想停止一个响应式序列,你可以调用stop()

如果一个响应式序列被stop()了,你可能还想注销事件处理器;为此你可以注册一个拆卸处理器:

var sq = ASQ.react( function setup(next,registerTeardown){
    var btn = document.getElementById( "mybtn" );

    btn.addEventListener( "click", next, false );

    // 只要`sq.stop()`被调用,它就会被调用
    registerTeardown( function(){
        btn.removeEventListener( "click", next, false );
    } );
} )
.seq( .. )
.then( .. )
.val( .. );

// 稍后
sq.stop();

注意:setup(..)处理器内部的this绑定引用是sq响应式序列,所以你可以在响应式序列的定义中使用this引用,比如调用stop()之类的方法,等等。

这是一个来自 Node.js 世界的例子,使用响应式序列处理到来的HTTP请求:

var server = http.createServer();
server.listen(8000);

// 响应式监听
var request = ASQ.react( function setup(next,registerTeardown){
    server.addListener( "request", next );
    server.addListener( "close", this.stop );

    registerTeardown( function(){
        server.removeListener( "request", next );
        server.removeListener( "close", request.stop );
    } );
});

// 应答请求
request
.seq( pullFromDatabase )
.val( function(data,res){
    res.end( data );
} );

// 关闭 node
process.on( "SIGINT", request.stop );

next(..)触发器还可以很容易地适配 node 流,使用onStream(..)unStream(..)

ASQ.react( function setup(next){
    var fstream = fs.createReadStream( "/some/file" );

    // 将流的 "data" 事件导向 `next(..)`
    next.onStream( fstream );

    // 监听流的结束
    fstream.on( "end", function(){
        next.unStream( fstream );
    } );
} )
.seq( .. )
.then( .. )
.val( .. );

你还可以使用序列组合来构成多个响应式序列流:

var sq1 = ASQ.react( .. ).seq( .. ).then( .. );
var sq2 = ASQ.react( .. ).seq( .. ).then( .. );

var sq3 = ASQ.react(..)
.gate(
    sq1,
    sq2
)
.then( .. );

这里的要点是,ASQ.react(..)是一个F/RP概念的轻量级适配,使得将一个事件流与一个序列的连接成为可能,因此得名“响应式序列”。对于基本的响应式用法,响应式序列的能力通常是足够的。

注意: 这里有一个使用ASQ.react(..)来管理UI状态的例子(http://jsbin.com/rozipaki/6/edit?js,output),和另一个使用ASQ.react(..)来处理HTTP请求/应答流的例子(https://gist.github.com/getify/bba5ec0de9d6047b720e)。

Generator 协程

希望第四章帮助你很好地熟悉了ES6 generator。特别地,我们将重温并更加深入“Generator 并发性”的讨论。

我们想象了一个runAll(..)工具,它可以接收两个或更多的 generator 并且并发地运行它们,让它们协作地将控制权从一个yield到下一个,并带有可选的消息传递。

除了能够将一个 generator 运行至完成之外,我们在附录A中谈论过的AQS#runner(..)是一个runAll(..)概念的近似实现,它可以将多个 generator 并发地运行至完成。

那么让我们看看如何实现第四章的并发Ajax场景:

ASQ(
    "http://some.url.2"
)
.runner(
    function*(token){
        // 转移控制权
        yield token;

        var url1 = token.messages[0]; // "http://some.url.1"

        // 清空消息重新开始
        token.messages = [];

        var p1 = request( url1 );

        // 转移控制权
        yield token;

        token.messages.push( yield p1 );
    },
    function*(token){
        var url2 = token.messages[0]; // "http://some.url.2"

        // 传递消息并转移控制权
        token.messages[0] = "http://some.url.1";
        yield token;

        var p2 = request( url2 );

        // 移控制权
        yield token;

        token.messages.push( yield p2 );

        // 讲结果传递给下一个序列步骤
        return token.messages;
    }
)
.val( function(res){
    // `res[0]` comes from "http://some.url.1"
    // `res[1]` comes from "http://some.url.2"
} );

以下是ASQ#runner(..)runAll(..)之间的主要不同:

  • 每个 generator(协程)都被提供了一个称为token的参数值,它是一个当你想要明确地将控制权传递给下一个协程时yield用的特殊值。
  • token.messages是一个数组,持有从前一个序列步骤中传入的任何消息。它也是一种数据结构,你可以用来在协程之间分享消息。
  • yield一个 Promise(或序列)值不会传递控制权,但会暂停这个协程处理直到这个值准备好。
  • 这个协程处理运行到最后returnyield的值将会传递给序列中的下一个步骤。

为了适应不同的用法,在ASQ#runner(..)功能的基础上包装一层帮助函数也很容易。

状态机

许多程序员可能很熟悉的一个例子是状态机。在一个简单包装工具的帮助下,你可以创一个易于表达的状态机处理器。

让我们想象一个这样的工具。我们称之为state(..),我们将传递给它两个参数值:一个状态值和一个处理这个状态的 generator。state(..)将担负起创建并返回一个适配器 generator 的脏活,并把它传递给ASQ#runner(..)

考虑如下代码:

function state(val,handler) {
    // 为这个状态制造一个协程处理器
    return function*(token) {
        // 状态转换处理器
        function transition(to) {
            token.messages[0] = to;
        }

        // 设置初始状态(如果还没有设置的话)
        if (token.messages.length < 1) {
            token.messages[0] = val;
        }

        // 持续运行直到最终状态(false)
        while (token.messages[0] !== false) {
            // 当前的状态匹配这个处理器吗?
            if (token.messages[0] === val) {
                // 委托到状态处理器
                yield *handler( transition );
            }

            // 要把控制权转移给另一个状态处理器吗?
            if (token.messages[0] !== false) {
                yield token;
            }
        }
    };
}

如果你仔细观察,你会发现state(..)返回了一个接收token的 generator,然后它建立一个while循环,这个循环会运行到状态机直到到达它的最终状态(我们随意地将它选定为false值)为止;这正是我们想要传递给ASQ#runner(..)的那种 generator!

我们还随意地保留了token.messages[0]值槽,放置我们的状态机将要追踪的当前状态,这意味着我们甚至可以指定初始状态,作为序列中前一个步骤传递来的值。

我们如何将state(..)帮助函数与ASQ#runner(..)一起使用呢?

var prevState;

ASQ(
    /* 可选的:初始状态值 */
    2
)
// 运行我们的状态机
// 转换是:2 -> 3 -> 1 -> 3 -> false
.runner(
    // 状态 `1` 处理器
    state( 1, function *stateOne(transition){
        console.log( "in state 1" );

        prevState = 1;
        yield transition( 3 );  // 前往状态 `3`
    } ),

    // 状态 `2` 处理器
    state( 2, function *stateTwo(transition){
        console.log( "in state 2" );

        prevState = 2;
        yield transition( 3 );  // 前往状态 `3`
    } ),

    // 状态 `3` 处理器
    state( 3, function *stateThree(transition){
        console.log( "in state 3" );

        if (prevState === 2) {
            prevState = 3;
            yield transition( 1 ); // 前往状态 `1`
        }
        // 完成了!
        else {
            yield "That's all folks!";

            prevState = 3;
            yield transition( false ); // 终止状态
        }
    } )
)
// 状态机运行完成,所以继续
.val( function(msg){
    console.log( msg ); // That's all folks!
} );

重要的是,*stateOne(..)*stateTwo(..),和*stateThree(..) generator 本身会在每次进入那种状态时被调用,它们会在你transition(..)到另一个值时完成。虽然没有在这里展示,但是这些状态 generator 处理器理所当然地可以通过yield Promise/序列/thunk 来异步地暂停。

隐藏在底层的 generator 是由state(..)帮助函数产生的,实际上被传递给ASQ#runner(..)的 generator 是持续并发运行至状态机长度的那一个,它们的每一个都协作地将控制权yield给下一个,如此类推。

注意: 看看这个“乒乓”的例子(http://jsbin.com/qutabu/1/edit?js,output),它展示了由ASQ#runner(..)驱动的 generator 的协作并发的用法。

通信序列化处理(CSP)

“通信序列化处理(Communicating Sequential Processes —— CSP)”是由 C. A. R. Hoare 在1978年的一篇学术论文(http://dl.acm.org/citation.cfm?doid=359576.359585)中首先被提出的,后来在1985年的一本同名书籍中被描述过。CSP描述了一种并发“进程”在处理期间进行互动(也就是“通信”)的形式方法。

你可能会回忆起我们在第一章检视过的并发“进程”,所以我们对CSP的探索将会建立在那种理解之上。

就像大多数计算机科学中的伟大概念一样,CSP深深地沉浸在学术形式主意中,被表达为一种代数处理。然而,我怀疑满是符号的代数定理不会给读者带来太多实际意义,所以我们将找其他的方法将CSP带进我们的大脑。

我会将很多CSP的形式描述和证明留给 Hoare 的文章,与其他许多美妙的相关作品。取而代之的是,我们将尽可能以一种非学院派的、但愿是可以直接理解的方法,来试着简要地讲解CSP的思想。

消息传递

CSP的核心原则是,在独立进程之间的通信/互动都必须通过正式的消息传递。也许与你的期望背道而驰,CSP的消息传递是作为同步行为进行描述的,发送进程与接收进程都不得不为消息的传递做好准备。

这样的同步消息怎么会与 JavaScript 中的异步编程有联系?

这种联系具体来自于 ES6 generator 的性质 —— generator 被用于生产看似同步的行为,而这些行为的内部既可以是同步的也可以(更可能)是异步的。

换言之,两个或更多并发运行的 generator 可能看起来像是在互相同步地传递消息,而同时保留了系统的异步性基础,因为每个 generator 的代码都会被暂停(也就是“阻塞”)来等待一个异步动作的运行。

这是如何工作的?

想象一个称为“A”的 generator,它想要给 generator “B” 发送一个消息。首先,“A” yield出要发送给“B”的消息(因此暂停了“A”)。当“B”准备好并拿走这个消息时,“A”才会继续(解除阻塞)。

与此对称的,想象一个 generator “A”想要 “B”接收一个消息。“A” yield出一个从“B”取得消息的请求(因此暂停了“A”),一旦“B”发送了一个消息,“A”就拿来这个消息并继续。

对于这种CSP消息传递理论来说,一个更广为人知的表达形式是 ClojureScript 的 core.async 库,以及 go 语言。它们将CSP中描述的通信语义实现为一种在进程之间打开的管道,称为“频道(channel)”。

注意: 频道 这个术语描述了问题的一部分,因为存在一种模式,会有多于一个的值被一次性发送到这个频道的“缓冲”中;这与你对流的认识相似。我们不会在这里深入这个问题,但是对于数据流的管理来说它可能是一个非常强大的技术。

在CSP最简单的概念中,一个我们在“A”和“B”之间建立的频道会有一个称为take(..)的阻塞方法来接收一个值,以及一个称为put(..)的阻塞方法来发送一个值。

它看起来可能像这样:

var ch = channel();

function *foo() {
    var msg = yield take( ch );

    console.log( msg );
}

function *bar() {
    yield put( ch, "Hello World" );

    console.log( "message sent" );
}

run( foo );
run( bar );
// Hello World
// "message sent"

将这种结构化的、(看似)同步的消息传递互动,与ASQ#runner(..)通过token.messages数组与协作的yield提供的、非形式化与非结构化的消息共享相比较。实质上,yield put(..)是一种可以同时发送值并为了传递控制权而暂停执行的单一操作,而前一个例子中我们将这两个步骤分开实施。

另外CSP强调,你不会真正明确地“传递控制权”,而是这样设计你的并发过程:要么为了从频道中接收值而阻塞,要么为了试着向这个频道中发送值而阻塞。这种围绕着消息的发送或接收的阻塞,就是你如何在协程之间协调行为序列的方法。

注意: 预先奉告:这种模式非常强大,但要习惯它有些烧脑。你可能会需要实践它一下,来习惯这种协调并发性的新的思考方式。

有好几个了不起的库已经用 JavaScript 实现了这种风格的CSP,最引人注目的是“js-csp”(https://github.com/ubolonton/js-csp),由 James Long (http://twitter.com/jlongster)开出的分支(https://github.com/jlongster/js-csp),以及他特意撰写的作品(http://jlongster.com/Taming-the-Asynchronous-Beast-with-CSP-in-JavaScript)。另外,关于将 ClojureScript 中 go 风格的 core.async CSP 适配到 JS generator 的话题,无论怎么夸赞 David Nolen 的许多作品很精彩都不为过。

asynquence 的 CSP 模拟

因为我们是在我的 asynquence 库的上下文环境中讨论异步模式的,你可能会对这个话题很感兴趣:我们可以很容易地在ASQ#runner(..) generator 处理上增加一个模拟层,来近乎完美地移植CSP的API和行为。这个模拟层放在与 asynquence 一起发放的 “asynquence-contrib”包的可选部分。

与早先的state(..)帮助函数非常类似,ASQ.csp.go(..)接收一个 generator —— 用 go/core.async 的术语来讲,它称为一个 goroutine —— 并将它适配为一个可以与ASQ#runner(..)一起使用的新 generator。

与被传入一个token不同,你的 goroutine 接收一个创建好的频道(下面的ch),这个频道会被本次运行的所有 goroutine 共享。你可以使用ASQ.csp.chan(..)创建更多频道(这通常十分有用)。

在CSP中,我们使用频道消息传递上的阻塞作为所有异步性的模型,而不是为了等待 Promise/序列/thunk 的完成而发生的阻塞。

所以,与yieldrequest(..)中返回的 Promise 不同的是,request(..)应当返回一个频道,你从它那里take(..)一个值。换句话说,一个单值频道在这种上下文环境/用法上大致上与一个 Promise/序列是等价的。

让我们先制造一个兼容频道版本的request(..)

function request(url) {
    var ch = ASQ.csp.channel();
    ajax( url ).then( function(content){
        // `putAsync(..)` 是 `put(..)` 的另一个版本,
        // 它可以在一个 generator 的外部使用。它为操作
        // 的完成返回一个 promise。我们不在这里使用这个
        // promise,但如果有需要的话我们可以在值被
        // `taken(..)` 之后收到通知。
        ASQ.csp.putAsync( ch, content );
    } );
    return ch;
}

在第三章中,“promisory”是一个生产 Promise 的工具,第四章中“thunkory”是一个生产thunk的工具,最后,在附录A中我们发明了“sequory”表示一个生产序列的工具。

很自然地,我们需要为一个生产频道的工具杜撰一个对称的术语。所以就让我们不出意料地称它为“chanory”(“channel” + “factory”)吧。作为一个留给读者的练习,请试着亲手定义一个channelify(..)的工具,就像 Promise.wrap(..)/promisify(..)(第三章),thunkify(..)(第四章),和ASQ.wrap(..)(附录A)一样。

先考虑这个使用 asyquence 风格CSP的并发Ajax的例子:

ASQ()
.runner(
    ASQ.csp.go( function*(ch){
        yield ASQ.csp.put( ch, "http://some.url.2" );

        var url1 = yield ASQ.csp.take( ch );
        // "http://some.url.1"

        var res1 = yield ASQ.csp.take( request( url1 ) );

        yield ASQ.csp.put( ch, res1 );
    } ),
    ASQ.csp.go( function*(ch){
        var url2 = yield ASQ.csp.take( ch );
        // "http://some.url.2"

        yield ASQ.csp.put( ch, "http://some.url.1" );

        var res2 = yield ASQ.csp.take( request( url2 ) );
        var res1 = yield ASQ.csp.take( ch );

        // 讲结果传递给序列的下一个步骤
        ch.buffer_size = 2;
        ASQ.csp.put( ch, res1 );
        ASQ.csp.put( ch, res2 );
    } )
)
.val( function(res1,res2){
    // `res1` comes from "http://some.url.1"
    // `res2` comes from "http://some.url.2"
} );

消息传递在两个 goroutines 之间进行的 URL 字符串交换是非常直接的。第一个 goroutine 向第一个URL发起一个Ajax请求,它的应答被放进ch频道。第二个 goroutine 想第二个URL发起一个Ajax请求,然后从ch频道取下第一个应答res1。在这个时刻,应答res1res2都被完成且准备好了。

如果在 goroutine 运行的末尾ch频道还有什么剩余价值的话,它们将被传递进序列的下一个步骤中。所以,为了从最后的 goroutine 中传出消息,把它们put(..)ch。就像展示的那样,为了避免最后的那些put(..)阻塞,我们通过把chbuffer_size设置为2(默认是0)来将它切换到缓冲模式。

注意: 更多使用 asynquence 风格CSP的例子可以参见这里(https://gist.github.com/getify/e0d04f1f5aa24b1947ae)。

复习

Promise 和 generator 为我们能够创建更加精巧和强大的异步性提供了基础构建块。

asynquence 拥有许多工具,用于实现 的迭代序列响应式序列(也就是“可监听对象”),并发协程,甚至 CSP goroutines

将这些模式,与延续回调和 Promise 能力相组合,使得 asynquence 拥有了混合不同异步处理的强大功能,一切都整合进一个干净的异步流程控制抽象:序列。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容