你不懂JS: 异步与性能

 主页   资讯   文章   代码   电子书 

你不懂JS: 异步与性能

第一章: 异步: 现在与稍后

在像JavaScript这样的语言中最重要但经常被误解的编程技术之一,就是如何表达和操作跨越一段时间的程序行为。

这不仅仅是关于从for循环开始到for循环结束之间发生的事情,当然它确实要花 一些时间(几微秒到几毫秒)才能完成。它是关于你的程序 现在 运行的部分,和你的程序 稍后 运行的另一部分之间发生的事情——现在稍后 之间有一个间隙,在这个间隙中你的程序没有活跃地执行。

几乎所有被编写过的(特别是用JS)大型程序都不得不用这样或那样的方法来管理这个间隙,不管是等待用户输入,从数据库或文件系统请求数据,通过网络发送数据并等待应答,还是在规定的时间间隔重复某些任务(比如动画)。在所有这些各种方法中,你的程序都不得不跨越时间间隙管理状态。就像在伦敦众所周知的一句话(地铁门与月台间的缝隙):“小心间隙。”

实际上,你程序中 现在稍后 的部分之间的关系,就是异步编程的核心。

可以确定的是,异步编程在JS的最开始就出现了。但是大多数开发者从没认真地考虑过它到底是如何,为什么出现在他们的程序中的,也没有探索过 其他 处理异步的方式。足够好 的方法总是老实巴交的回调函数。今天还有许多人坚持认为回调就绰绰有余了。

但是JS在使用范围和复杂性上不停地生长,作为运行在浏览器,服务器和每种可能的设备上的头等编程语言,为了适应它不断扩大的要求,我们在管理异步上感受到的痛苦日趋严重,人们迫切地需要一种更强大更合理的处理方法。

虽然眼前这一切看起来很抽象,但我保证,随着我们通读这本书你会更完整且坚实地解决它。在接下来的几章中我们将会探索各种异步JavaScript编程的新兴技术。

但在接触它们之前,我们将不得不更深刻地理解异步是什么,以及它在JS中如何运行。

块儿(Chunks)中的程序

你可能将你的JS程序写在一个 .js 文件中,但几乎可以确定你的程序是由几个代码块儿构成的,仅有其中的一个将会在 现在 执行,而其他的将会在 稍后 执行。最常见的 代码块儿 单位是function

大多数刚接触JS的开发者都可能会有的问题是,稍后 并不严格且立即地在 现在 之后发生。换句话说,根据定义,现在 不能完成的任务将会异步地完成,而且我们因此不会有你可能在直觉上期望或想要的阻塞行为。

考虑这段代码:

// ajax(..)是某个包中任意的Ajax函数
var data = ajax( "http://some.url.1" );

console.log( data );
// 噢!`data`一般不会有Ajax的结果

你可能意识到Ajax请求不会同步地完成,这意味着ajax(..)函数还没有任何返回的值可以赋值给变量data。如果ajax(..)在应答返回之前 能够 阻塞,那么data = ..赋值将会正常工作。

但那不是我们使用Ajax的方式。我们 现在 制造一个异步的Ajax请求,直到 稍后 我们才会得到结果。

现在 “等到” 稍后 最简单的(但绝对不是唯一的,或最好的)方法,通常称为回调函数:

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", function myCallbackFunction(data){

    console.log( data ); // Yay, 我得到了一些`data`!

} );

警告: 你可能听说过发起同步的Ajax请求是可能的。虽然在技术上是这样的,但你永远,永远不应该在任何情况下这样做,因为它将锁定浏览器的UI(按钮,菜单,滚动条,等等)而且阻止用户与任何东西互动。这是一个非常差劲的主意,你应当永远回避它。

在你提出抗议之前,不,你渴望避免混乱的回调不是使用阻塞的,同步的Ajax的正当理由。

举个例子,考虑下面的代码:

function now() {
    return 21;
}

function later() {
    answer = answer * 2;
    console.log( "Meaning of life:", answer );
}

var answer = now();

setTimeout( later, 1000 ); // Meaning of life: 42

这个程序中有两个代码块儿:现在 将会运行的东西,和 稍后 将会运行的东西。这两个代码块分别是什么应当十分明显,但还是让我们以最明确的方式指出来:

现在:

function now() {
    return 21;
}

function later() { .. }

var answer = now();

setTimeout( later, 1000 );

稍后:

answer = answer * 2;
console.log( "Meaning of life:", answer );

你的程序一执行,现在 代码块儿就会立即运行。但setTimeout(..)还设置了一个 稍后 会发生的事件(一个超时事件),所以later()函数的内容将会在一段时间后(从现在开始1000毫秒)被执行。

每当你将一部分代码包进function并且规定它应当为了响应某些事件而执行(定时器,鼠标点击,Ajax应答等等),你就创建了一个 稍后 代码块儿,也因此在你的程序中引入了异步。

异步控制台

关于console.*方法如何工作,没有相应的语言规范或一组需求——它们不是JavaScript官方的一部分,而是由 宿主环境 添加到JS上的(见本丛书的 类型与文法)。

所以,不同的浏览器和JS环境各自为战,这有时会导致令人困惑的行为。

特别地,有些浏览器和某些条件下,console.log(..)实际上不会立即输出它得到的东西。这个现象的主要原因可能是因为I/O处理很慢,而且是许多程序的阻塞部分(不仅是JS)。所以,对一个浏览器来说,可能的性能更好的处理方式是(从网页/UI的角度看),在后台异步地处理consoleI/O,而你也许根本不知道它发生了。

虽然不是很常见,但是一种可能被观察到(不是从代码本身,而是从外部)的场景是:

var a = {
    index: 1
};

// 稍后
console.log( a ); // ??

// 再稍后
a.index++;

我们一般希望看到的是,就在console.log(..)语句被执行的那一刻,对象a被取得一个快照,打印出如{ index: 1 }的内容,如此在下一个语句a.index++执行时,它修改不同于a的输出,或者严格的在a的输出之后的某些东西。

大多数时候,上面的代码将会在你的开发者工具控制台中产生一个你期望的对象表现形式。但是同样的代码也可能运行在这样的情况下:浏览器告诉后台它需要推迟控制台I/O,这时,在对象在控制台中被表示的那个时间点,a.index++已经执行了,所以它将显示{ index: 2 }

到底在什么条件下consoleI/O将被推迟是不确定的,甚至它能不能被观察到都是不确定的。只能当你在调试过程中遇到问题时——对象在console.log(..)语句之后被修改,但你却意外地看到了修改后的内容——意识到I/O的这种可能的异步性。

注意: 如果你遇到了这种罕见的情况,最好的选择是使用JS调试器的断点,而不是依赖console的输出。第二好的选择是通过将目标对象序列化为一个string强制取得一个它的快照,比如用JSON.stringify(..)

事件轮询(Event Loop)

让我们来做一个(也许是令人震惊的)声明:尽管明确地允许异步JS代码(就像我们刚看到的超时),但是实际上,直到最近(ES6)为止,JavaScript本身从来没有任何内建的异步概念。

什么!? 这听起来简直是疯了,对吧?事实上,它是真的。JS引擎本身除了在某个在被要求的时刻执行你程序的一个单独的代码块外,没有做过任何其他的事情。

“被'谁'要求”?这才是重要的部分!

JS引擎没有运行在隔离的区域。它运行在一个 宿主环境 中,对大多数开发者来说这个宿主环境就是浏览器。在过去的几年中(但不特指这几年),JS超越了浏览器的界限进入到了其他环境中,比如服务器,通过Node.js这样的东西。其实,今天JavaScript已经被嵌入到所有种类的设备中,从机器人到电灯泡儿。

所有这些环境的一个共通的“线程”(一个“不那么微妙”的异步玩笑,不管怎样)是,他们都有一种机制:在每次调用JS引擎时,可以 随着时间的推移 执行你的程序的多个代码块儿,这称为“事件轮询(Event Loop)”。

换句话说,JS引擎对 时间 没有天生的感觉,反而是一个任意JS代码段的按需执行环境。是它周围的环境在不停地安排“事件”(JS代码的执行)。

那么,举例来说,当你的JS程序发起一个从服务器取得数据的Ajax请求时,你在一个函数(通常称为回调)中建立好“应答”代码,然后JS引擎就会告诉宿主环境,“嘿,我就要暂时停止执行了,但不管你什么时候完成了这个网络请求,而且你还得到一些数据的话,请 回来调 这个函数。”

然后浏览器就会为网络的应答设置一个监听器,当它有东西要交给你的时候,它会通过将回调函数插入 事件轮询 来安排它的执行。

那么什么是 事件轮询

让我们先通过一些假想代码来对它形成一个概念:

// `eventLoop`是一个像队列一样的数组(先进先出)
var eventLoop = [ ];
var event;

// “永远”执行
while (true) {
    // 执行一个"tick"
    if (eventLoop.length > 0) {
        // 在队列中取得下一个事件
        event = eventLoop.shift();

        // 现在执行下一个事件
        try {
            event();
        }
        catch (err) {
            reportError(err);
        }
    }
}

当然,这只是一个用来展示概念的大幅简化的假想代码。但是对于帮助我们建立更好的理解来说应该够了。

如你所见,有一个通过while循环来表现的持续不断的循环,这个循环的每一次迭代称为一个“tick”。在每一个“tick”中,如果队列中有一个事件在等待,它就会被取出执行。这些事件就是你的函数回调。

很重要并需要注意的是,setTimeout(..)不会将你的回调放在事件轮询队列上。它设置一个定时器;当这个定时器超时的时候,环境才会把你的回调放进事件轮询,这样在某个未来的tick中它将会被取出执行。

如果在那时事件轮询队列中已经有了20个事件会怎么样?你的回调要等待。它会排到队列最后——没有一般的方法可以插队和跳到队列的最前方。这就解释了为什么setTimeout(..)计时器可能不会完美地按照预计时间触发。你得到一个保证(粗略地说):你的回调不会再你指定的时间间隔之前被触发,但是可能会在这个时间间隔之后被触发,具体要看事件队列的状态。

换句话说,你的程序通常被打断成许多小的代码块儿,它们一个接一个地在事件轮询队列中执行。而且从技术上说,其他与你的程序没有直接关系的事件也可以穿插在队列中。

注意: 我们提到了“直到最近”,暗示着ES6改变了事件轮询队列在何处被管理的性质。这主要是一个正式的技术规范,ES6现在明确地指出了事件轮询应当如何工作,这意味着它技术上属于JS引擎应当关心的范畴内,而不仅仅是 宿主环境。这么做的一个主要原因是为了引入ES6的Promises(我们将在第三章讨论),因为人们需要有能力对事件轮询队列的排队操作进行直接,细粒度的控制(参见“协作”一节中关于setTimeout(..0)的讨论)。

并行线程

“异步”与“并行”两个词经常被混为一谈,但它们实际上是十分不同的。记住,异步是关于 现在稍后 之间的间隙。但并行是关于可以同时发生的事情。

关于并行计算最常见的工具就是进程与线程。进程和线程独立地,可能同时地执行:在不同的处理器上,甚至在不同的计算机上,而多个线程可以共享一个进程的内存资源。

相比之下,一个事件轮询将它的工作打碎成一系列任务并串行地执行它们,不允许并行访问和更改共享的内存。并行与“串行”可能以在不同线程上的事件轮询协作的形式共存。

并行线程执行的穿插,与异步事件的穿插发生在完全不同的粒度等级上:

比如:

function later() {
    answer = answer * 2;
    console.log( "Meaning of life:", answer );
}

虽然later()的整个内容将被当做一个事件轮询队列的实体,但当考虑到将要执行这段代码的线程时,实际上也许会有许多不同的底层操作。比如,answer = answer * 2首先需要读取当前answer的值,再把2放在某个地方,然后进行乘法计算,最后把结果存回到answer

在一个单线程环境中,线程队列中的内容都是底层操作真的无关紧要,因为没有什么可以打断线程。但如果你有一个并行系统,在同一个程序中有两个不同的线程,你很可能会得到无法预测的行为:

考虑这段代码:

var a = 20;

function foo() {
    a = a + 1;
}

function bar() {
    a = a * 2;
}

// ajax(..) 是一个给定的库中的随意Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

在JavaScript的单线程行为下,如果foo()bar()之前执行,结果a42,但如果bar()foo()之前执行,结果a将是41

如果JS事件共享相同的并列执行数据,问题将会变得微妙得多。考虑这两个假想代码段,它们分别描述了运行foo()bar()中代码的线程将要执行的任务,并考虑如果它们在完全相同的时刻运行会发生什么:

线程1(XY是临时的内存位置):

foo():
  a. 将`a`的值读取到`X`
  b. 将`1`存入`Y`
  c. 把`X`和`Y`相加,将结果存入`X`
  d. 将`X`的值存入`a`

线程2(XY是临时的内存位置):

bar():
  a. 将`a`的值读取到`X`
  b. 将`2`存入`Y`
  c. 把`X`和`Y`相乘,将结果存入`X`
  d. 将`X`的值存入`a`

现在,让我们假定这两个线程在并行执行。你可能发现了问题,对吧?它们在临时的步骤中使用共享的内存位置XY

如果步骤像这样发生,a的最终结果什么?

1a  (将`a`的值读取到`X`   ==> `20`)
2a  (将`a`的值读取到`X`   ==> `20`)
1b  (将`1`存入`Y`   ==> `1`)
2b  (将`2`存入`Y`   ==> `2`)
1c  (把`X`和`Y`相加,将结果存入`X`   ==> `22`)
1d  (将`X`的值存入`a`   ==> `22`)
2c  (把`X`和`Y`相乘,将结果存入`X`   ==> `44`)
2d  (将`X`的值存入`a`   ==> `44`)

a中的结果将是44。那么这种顺序呢?

1a  (将`a`的值读取到`X`   ==> `20`)
2a  (将`a`的值读取到`X`   ==> `20`)
2b  (将`2`存入`Y`   ==> `2`)
1b  (将`1`存入`Y`   ==> `1`)
2c  (把`X`和`Y`相乘,将结果存入`X`   ==> `20`)
1c  (把`X`和`Y`相加,将结果存入`X`   ==> `21`)
1d  (将`X`的值存入`a`   ==> `21`)
2d  (将`X`的值存入`a`   ==> `21`)

a中的结果将是21

所以,关于线程的编程十分刁钻,因为如果你不采取特殊的步骤来防止这样的干扰/穿插,你会得到令人非常诧异的,不确定的行为。这通常让人头疼。

JavaScript从不跨线程共享数据,这意味着不必关心这一层的不确定性。但这并不意味着JS总是确定性的。记得前面foo()bar()的相对顺序产生两个不同的结果吗(4142)?

注意: 可能还不明显,但不是所有的不确定性都是坏的。有时候它无关紧要,有时候它是故意的。我们会在本章和后续几章中看到更多的例子。

运行至完成

因为JavaScript是单线程的,foo()(和bar())中的代码是原子性的,这意味着一旦foo()开始运行,它的全部代码都会在bar()中的任何代码可以运行之前执行完成,反之亦然。这称为“运行至完成”行为。

事实上,运行至完成的语义会在foo()bar()中有更多的代码时更明显,比如:

var a = 1;
var b = 2;

function foo() {
    a++;
    b = b * a;
    a = b + 3;
}

function bar() {
    b--;
    a = 8 + b;
    b = a * 2;
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

因为foo()不能被bar()打断,而且bar()不能被foo()打断,所以这个程序根据哪一个先执行只有两种可能的结果——如果线程存在,foo()bar()中的每一个语句都可能被穿插,可能的结果数量将会极大地增长!

代码块儿1是同步的(现在 发生),但代码块儿2和3是异步的(稍后 发生),这意味着它们的执行将会被时间的间隙分开。

代码块儿1:

var a = 1;
var b = 2;

代码块儿2 (foo()):

a++;
b = b * a;
a = b + 3;

代码块儿3 (bar()):

b--;
a = 8 + b;
b = a * 2;

代码块儿2和3哪一个都有可能先执行,所以这个程序有两个可能的结果,正如这里展示的:

结果1:

var a = 1;
var b = 2;

// foo()
a++;
b = b * a;
a = b + 3;

// bar()
b--;
a = 8 + b;
b = a * 2;

a; // 11
b; // 22

结果2:

var a = 1;
var b = 2;

// bar()
b--;
a = 8 + b;
b = a * 2;

// foo()
a++;
b = b * a;
a = b + 3;

a; // 183
b; // 180

同一段代码有两种结果仍然意味着不确定性!但是这是在函数(事件)顺序的水平上,而不是在使用线程时语句顺序的水平上(或者说,实际上是表达式操作的顺序上)。换句话说,他比线程更具有 确定性

当套用到JavaScript行为时,这种函数顺序的不确定性通常称为“竞合状态”,因为foo()bar()在互相竞争看谁会先运行。明确地说,它是一个“竞合状态”因为你不能可靠地预测ab将如何产生。

注意: 如果在JS中不知怎的有一个函数没有运行至完成的行为,我们会有更多可能的结果,对吧?ES6中引入一个这样的东西(见第四章“生成器”),但现在不要担心,我们会回头讨论它。

并发

让我们想象一个网站,它显示一个随着用户向下滚动而逐步加载的状态更新列表(就像社交网络的新消息)。要使这样的特性正确工作,(至少)需要两个分离的“进程” 同时 执行(在同一个时间跨度内,但没必要是同一个时间点)。

注意: 我们在这里使用带引号的“进程”,因为它们不是计算机科学意义上的真正的操作系统级别的进程。它们是虚拟进程,或者说任务,表示一组逻辑上关联,串行顺序的操作。我们将简单地使用“进程”而非“任务”,因为在术语层面它与我们讨论的概念的定义相匹配。

第一个“进程”将响应当用户向下滚动页面时触发的onscroll事件(发起取得新内容的Ajax请求)。第二个“进程”将接收返回的Ajax应答(将内容绘制在页面上)。

显然,如果用户向下滚动的足够快,你也许会看到在第一个应答返回并处理期间,有两个或更多的onscroll事件被触发,因此你将使onscroll事件和Ajax应答事件迅速触发,互相穿插在一起。

并发是当两个或多个“进程”在同一时间段内同时执行,无论构成它们的各个操作是否 并行地(在同一时刻不同的处理器或内核)发生。你可以认为并发是“进程”级别的(或任务级别)的并行机制,而不是操作级别的并行机制(分割进程的线程)。

注意: 并发还引入了这些“进程”间彼此互动的概念。我们稍后会讨论它。

在一个给定的时间跨度内(用户可以滚动的那几秒),让我们将每个独立的“进程”作为一系列事件/操作描绘出来:

“线程”1 (onscroll事件):

onscroll, request 1
onscroll, request 2
onscroll, request 3
onscroll, request 4
onscroll, request 5
onscroll, request 6
onscroll, request 7

“线程”2 (Ajax应答事件):

response 1
response 2
response 3
response 4
response 5
response 6
response 7

一个onscroll事件与一个Ajax应答事件很有可能在同一个 时刻 都准备好被处理了。比如我们在一个时间线上描绘一下这些事件的话:

onscroll, request 1
onscroll, request 2          response 1
onscroll, request 3          response 2
response 3
onscroll, request 4
onscroll, request 5
onscroll, request 6          response 4
onscroll, request 7
response 6
response 5
response 7

但是,回到本章前面的事件轮询概念,JS一次只能处理一个事件,所以不是onscroll, request 2首先发生就是response 1首先发生,但是他们不可能完全在同一时刻发生。就像学校食堂的孩子们一样,不管他们在门口挤成什么样,他们最后都不得不排成一个队来打饭!

让我们来描绘一下所有这些事件在事件轮询队列上穿插的情况:

事件轮询队列:

onscroll, request 1   <--- 进程1开始
onscroll, request 2
response 1            <--- 进程2开始
onscroll, request 3
response 2
response 3
onscroll, request 4
onscroll, request 5
onscroll, request 6
response 4
onscroll, request 7   <--- 进程1结束
response 6
response 5
response 7            <--- 进程2结束

“进程1”和“进程2”并发地运行(任务级别的并行),但是它们的个别事件在事件轮询队列上顺序地运行。

顺便说一句,注意到response 6response 5没有按照预想的顺序应答吗?

单线程事件轮询是并发的一种表达(当然还有其他的表达,我们稍后讨论)。

非互动

在同一个程序中两个或更多的“进程”在穿插它们的步骤/事件时,如果它们的任务之间没有联系,那么他们就没必要互动。如果它们不互动,不确定性就是完全可以接受的。

举个例子:

var res = {};

function foo(results) {
    res.foo = results;
}

function bar(results) {
    res.bar = results;
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

foo()bar()是两个并发的“进程”,而且它们被触发的顺序是不确定的。但对我们的程序的结构来讲它们的触发顺序无关紧要,因为它们的行为相互独立所以不需要互动。

这不是一个“竞合状态”Bug,因为这段代码总能够正确工作,与顺序无关。

互动

更常见的是,通过作用域和/或DOM,并发的“进程”将有必要间接地互动。当这样的互动将要发生时,你需要协调这些互动行为来防止前面讲述的“竞合状态”。

这里是两个由于隐含的顺序而互动的并发“进程”的例子,它 有时会出错

var res = [];

function response(data) {
    res.push( data );
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

并发的“进程”是那两个将要处理Ajax应答的response()调用。它们谁都有可能先发生。

假定我们期望的行为是res[0]拥有"http://some.url.1"调用的结果,而res[1]拥有"http://some.url.2"调用的结果。有时候结果确实是这样,而有时候则相反,要看哪一个调用首先完成。很有可能,这种不确定性是一个“竞合状态”Bug。

注意: 在这些情况下要极其警惕你可能做出的主观臆测。比如这样的情况就没什么不寻常:一个开发者观察到"http://some.url.2"的应答“总是”比"http://some.url.1"要慢得多,也许有赖于它们所做的任务(比如,一个执行数据库任务而另一个只是取得静态文件),所以观察到的顺序看起来总是所期望的。就算两个请求都发到同一个服务器,而且它故意以确定的顺序应答,也不能 真正 保证应答回到浏览器的顺序。

所以,为了解决这样的竞合状态,你可以协调互动的顺序:

var res = [];

function response(data) {
    if (data.url == "http://some.url.1") {
        res[0] = data;
    }
    else if (data.url == "http://some.url.2") {
        res[1] = data;
    }
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

无论哪个Ajax应答首先返回,我们都考察它的data.url(当然,假设这样的数据会从服务器返回)来找到应答数据应当在res数组中占有的位置。res[0]将总是持有"http://some.url.1"的结果,而res[1]将总是持有"http://some.url.2"的结果。通过简单的协调,我们消除了“竞合状态”的不确定性。

这个场景的同样道理可以适用于这样的情况:多个并发的函数调用通过共享的DOM互动,比如一个在更新<div>的内容而另一个在更新<div>的样式或属性(比如一旦DOM元素拥有内容就使它变得可见)。你可能不想在DOM元素拥有内容之前显示它,所以协调工作就必须保证正确顺序的互动。

没有协调的互动,有些并发的场景 总是出错(不仅仅是 有时)。考虑下面的代码:

var a, b;

function foo(x) {
    a = x * 2;
    baz();
}

function bar(y) {
    b = y * 2;
    baz();
}

function baz() {
    console.log(a + b);
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

在这个例子中,不管foo()bar()谁先触发,总是会使baz()运行的太早了(ab之一还是空的时候),但是第二个baz()调用将可以工作,因为ab将都是可用的。

有许多不同的方法可以解决这个状态。这是简单的一种:

var a, b;

function foo(x) {
    a = x * 2;
    if (a && b) {
        baz();
    }
}

function bar(y) {
    b = y * 2;
    if (a && b) {
        baz();
    }
}

function baz() {
    console.log( a + b );
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

baz()调用周围的if (a && b)条件通常称为“大门”,因为我们不能确定ab到来的顺序,但在打开大门(调用baz())之前我们等待它们全部到达。

另一种你可能会遇到的并发互动状态有时称为“竞争”,但更准确地说应该叫“门闩”。它的行为特点是“先到者胜”。在这里不确定性是可以接受的,因为你明确指出“竞争”的终点线上只有一个胜利者。

考虑这段有问题的代码:

var a;

function foo(x) {
    a = x * 2;
    baz();
}

function bar(x) {
    a = x / 2;
    baz();
}

function baz() {
    console.log( a );
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

不管哪一个函数最后触发(foo()bar()),它不仅会覆盖前一个函数对a的赋值,还会重复调用baz()(不太可能是期望的)。

所以,我们可以用一个简单的门闩来协调互动,仅让第一个过去:

var a;

function foo(x) {
    if (a == undefined) {
        a = x * 2;
        baz();
    }
}

function bar(x) {
    if (a == undefined) {
        a = x / 2;
        baz();
    }
}

function baz() {
    console.log( a );
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

if (a == undefined)条件仅会让foo()bar()中的第一个通过,而第二个(以及后续所有的)调用将会被忽略。第二名什么也得不到!

注意: 在所有这些场景中,为了简化说明的目的我们都用了全局变量,这里我们没有任何理由需要这么做。只要我们讨论中的函数可以访问变量(通过作用域),它们就可以正常工作。依赖于词法作用域变量(参见本丛书的 作用域与闭包 ),和这些例子中实质上的全局变量,是这种并发协调形式的一个明显的缺点。在以后的几章中,我们会看到其他的在这方面干净得多的协调方法。

协作

另一种并发协调的表达称为“协作并发”,它并不那么看重在作用域中通过共享值互动(虽然这依然是允许的!)。它的目标是将一个长时间运行的“进程”打断为许多步骤或批处理,以至于其他的并发“进程”有机会将它们的操作穿插进事件轮询队列。

举个例子,考虑一个Ajax应答处理器,它需要遍历一个很长的结果列表来将值变形。我们将使用Array#map(..)来让代码短一些:

var res = [];

// `response(..)`从Ajax调用收到一个结果数组
function response(data) {
    // 连接到既存的`res`数组上
    res = res.concat(
        // 制造一个新的变形过的数组,所有的`data`值都翻倍
        data.map( function(val){
            return val * 2;
        } )
    );
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

如果"http://some.url.1"首先返回它的结果,整个结果列表将会一次性映射进res。如果只有几千或更少的结果记录,一般来说不是什么大事。但假如有1千万个记录,那么就可能会花一段时间运行(在强大的笔记本电脑上花几秒钟,在移动设备上花的时间长得多,等等)。

当这样的“处理”运行时,页面上没有任何事情可以发生,包括不能有另一个response(..)调用,不能有UI更新,甚至不能有用户事件比如滚动,打字,按钮点击等。非常痛苦。

所以,为了制造协作性更强、更友好而且不独占事件轮询队列的并发系统,你可以在一个异步批处理中处理这些结果,在批处理的每一步都“让出”事件轮询来让其他等待的事件发生。

这是一个非常简单的方法:

var res = [];

// `response(..)`从Ajax调用收到一个结果数组
function response(data) {
    // 我们一次只处理1000件
    var chunk = data.splice( 0, 1000 );

    // 连接到既存的`res`数组上
    res = res.concat(
        // 制造一个新的变形过的数组,所有的`data`值都翻倍
        chunk.map( function(val){
            return val * 2;
        } )
    );

    // 还有东西要处理吗?
    if (data.length > 0) {
        // 异步规划下一个批处理
        setTimeout( function(){
            response( data );
        }, 0 );
    }
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

我们以每次最大1000件作为一个块儿处理数据。这样,我们保证每个“进程”都是短时间运行的,即便这意味着会有许多后续的“进程”,在事件轮询队列上的穿插将会给我们一个响应性(性能)强得多的网站/应用程序。

当然,我们没有对任何这些“进程”的顺序进行互动协调,所以在res中的结果的顺序是不可预知的。如果要求顺序,你需要使用我们之前讨论的互动技术,或者在本书后续章节中介绍的其他技术。

我们使用setTimeout(..0)(黑科技)来异步排程,基本上它的意思是“将这个函数贴在事件轮询队列的末尾”。

注意: 从技术上讲,setTimeout(..0)没有直接将一条记录插入事件轮询队列。计时器将会在下一个运行机会将事件插入。比如,两个连续的setTimeout(..0)调用不会严格保证以调用的顺序被处理,所以我们可能看到各种时间偏移的情况,使这样的事件的顺序是不可预知的。在Node.js中,一个相似的方式是process.nextTick(..)。不管那将会有多方便(而且通常性能更好),(还)没有一个直接的方法可以横跨所有环境来保证异步事件顺序。我们会在下一节详细讨论这个话题。

Jobs

在ES6中,在事件轮询队列之上引入了一层新概念,称为“工作队列(Job queue)”。你最有可能接触它的地方是在Promises(见第三章)的异步行为中。

不幸的是,它目前是一个没有公开API的机制,因此要演示它有些兜圈子。我们不得不仅仅在概念上描述它,这样当我们在第三章中讨论异步行为时,你将会理解那些动作行为是如何排程与处理的。

那么,我能找到的考虑它的最佳方式是:“工作队列”是一个挂靠在事件轮询队列的每个tick末尾的队列。在事件轮询的一个tick期间内,某些可能发生的隐含异步动作的行为将不会导致一个全新的事件加入事件轮询队列,而是在当前tick的工作队列的末尾加入一个新的记录(也就是一个Job)。

它好像是在说,“哦,另一件需要我 稍后 去做的事儿,但是保证它在其他任何事情发生之前发生。”

或者,用一个比喻:事件轮询队列就像一个游乐园项目,一旦你乘坐完一次,你就不得不去队尾排队来乘坐下一次。而工作队列就像乘坐完后,立即插队乘坐下一次。

一个Job还可能会导致更多的Job被加入同一个队列的末尾。所以,一个在理论上可能的情况是,Job“轮询”(一个Job持续不断地加入其他Job等)会无限地转下去,从而拖住程序不能移动到一下一个事件轮询tick。这与在你的代码中表达一个长时间运行或无限循环(比如while (true) ..)在概念上几乎是一样的。

Job的精神有点儿像setTimeout(..0)黑科技,但以一种定义明确得多的方式实现,而且保证顺序: 稍后,但尽快

让我们想象一个用于Job排程的API,并叫它schedule(..)。考虑如下代码:

console.log( "A" );

setTimeout( function(){
    console.log( "B" );
}, 0 );

// 理论上的 "Job API"
schedule( function(){
    console.log( "C" );

    schedule( function(){
        console.log( "D" );
    } );
} );

你肯能会期望它打印出A B C D,但是它将会打出A C D B,因为Job发生在当前的事件轮询tick的末尾,而定时器会在 下一个 事件轮询tick(如果可用的话!)触发排程。

在第三章中,我们会看到Promises的异步行为是基于Job的,所以搞明白它与事件轮询行为的联系是很重要的。

语句排序

我们在代码中表达语句的顺序没有必要与JS引擎执行它们的顺序相同。这可能看起来像是个奇怪的论断,所以我们简单地探索一下。

但在我们开始之前,我们应当对一些事情十分清楚:从程序的角度看,语言的规则/文法(参见本丛书的 类型与文法)为语句的顺序决定了一个非常可预知、可靠的行为。所以我们将要讨论的是在你的JS程序中 应当永远观察不到的东西

警告: 如果你曾经 观察到 过我们将要描述的编译器语句重排,那明显是违反了语言规范,而且无疑是那个JS引擎的Bug——它应当被报告并且修复!但是更常见的是你 怀疑 JS引擎里发生了什么疯狂的事,而事实上它只是你自己代码中的一个Bug(可能是一个“竞合状态”)——所以先检查那里,多检查几遍。在JS调试器使用断点并一行一行地步过你的代码,将是帮你在 你的代码 中找出这样的Bug的最强大的工具。

考虑下面的代码:

var a, b;

a = 10;
b = 30;

a = a + 1;
b = b + 1;

console.log( a + b ); // 42

这段代码没有任何异步表达(除了早先讨论的罕见的console异步I/O),所以最有可能的推测是它会一行一行地、从上到下地处理。

但是,JS引擎 有可能,在编译完这段代码后(是的,JS是被编译的——见本丛书的 作用域与闭包)发现有机会通过(安全地)重新安排这些语句的顺序来使你的代码运行得更快。实质上,只要你观察不到重排,一切都是合理的。

举个例子,引擎可能会发现如果实际上这样执行代码会更快:

var a, b;

a = 10;
a++;

b = 30;
b++;

console.log( a + b ); // 42

或者是这样:

var a, b;

a = 11;
b = 31;

console.log( a + b ); // 42

或者甚至是:

// 因为`a`和`b`都不再被使用,我们可以内联而且根本不需要它们!
console.log( 42 ); // 42

在所有这些情况下,JS引擎在它的编译期间进行着安全的优化,而最终的 可观察到 的结果将是相同的。

但也有一个场景,这些特殊的优化是不安全的,因而也是不被允许的(当然,不是说它一点儿都没优化):

var a, b;

a = 10;
b = 30;

// 我们需要`a`和`b`递增之前的状态!
console.log( a * b ); // 300

a = a + 1;
b = b + 1;

console.log( a + b ); // 42

编译器重排会造成可观测的副作用(因此绝不会被允许)的其他例子,包括任何带有副作用的函数调用(特别是getter函数),或者ES6的Proxy对象(参见本丛书的 ES6与未来)。

考虑如下代码:

function foo() {
    console.log( b );
    return 1;
}

var a, b, c;

// ES5.1 getter 字面语法
c = {
    get bar() {
        console.log( a );
        return 1;
    }
};

a = 10;
b = 30;

a += foo();             // 30
b += c.bar;             // 11

console.log( a + b );   // 42

如果不是为了这个代码段中的console.log(..)语句(只是作为这个例子中观察副作用的方便形式),JS引擎将会更加自由,如果它想(谁知道它想不想!?),它会重排这段代码:

// ...

a = 10 + foo();
b = 30 + c.bar;

// ...

多亏JS语义,我们不会观测到看起来很危险的编译器语句重排,但是理解源代码被编写的方式(从上到下)与它在编译后运行的方式之间的联系是多么微弱,依然是很重要的。

编译器语句重排几乎是并发与互动的微型比喻。作为一个一般概念,这样的意识可以帮你更好地理解异步JS代码流问题。

复习

一个JavaScript程序总是被打断为两个或更多的代码块儿,第一个代码块儿 现在 运行,下一个代码块儿 稍后 运行,来响应一个事件。虽然程序是一块儿一块儿地被执行的,但它们都共享相同的程序作用域和状态,所以对状态的每次修改都是在前一个状态之上的。

不论何时有事件要运行,事件轮询 将运行至队列为空。事件轮询的每次迭代称为一个“tick”。用户交互,IO,和定时器会将事件在事件队列中排队。

在任意给定的时刻,一次只有一个队列中的事件可以被处理。当事件执行时,他可以直接或间接地导致一个或更多的后续事件。

并发是当两个或多个事件链条随着事件相互穿插,因此从高层的角度来看,它们在 同时 运行(即便在给定的某一时刻只有一个事件在被处理)。

在这些并发“进程”之间进行某种形式的互动协调通常是有必要的,比如保证顺序或防止“竞合状态”。这些“进程”还可以 协作:通过将它们自己打断为小的代码块儿来允许其他“进程”穿插。