Skip to content

💤

《你不知道的 JavaScript》

异步:现在与将来

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

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

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

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

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

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

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

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

块儿(chunks)中的程序

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

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

考虑这段代码:

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

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

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

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

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

javascript
// ajax(..)是某个包中任意的Ajax函数
ajax('http://some.url.1', function myCallbackFunction(data) {
  console.log(data) // yay, I got my data!
})

WARNING

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

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

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

javascript
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

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

现在:

javascript
function now() {
  return 21
}

function later() {
  // ..
}

var answer = now()

setTimeout(later, 1000)

将来:

javascript
answer = answer * 2
console.log('Meaning of life:', answer)

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

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

异步控制台

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

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

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

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

javascript
var a = {
  index: 1,
}

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

//在稍后
a.index++

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

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

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

NOTE

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

事件循环(event loop)

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

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

“被‘谁’要求”?这才是最重要的部分!

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

所有这些环境的一个共同的“线程”(一个“不那么微妙”的异步玩笑,顺便提一下),它们都有一种机制:可以随着时间的推移执行你的程序的多个代码块儿,每一时刻都会调用 JS 引擎,这称为“事件循环(event loop)”。

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

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

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

那么什么是事件循环

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

javascript
// `event loop`是一个像队列一样的数组(先进先出)
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(..)计时器可能不会完美的按照预计时间触发。你得到一个保证(粗略地说):你的回调不会在你指定的时间间隔之前被触发,但是可能会在这个时间间隔之后被触发,具体要看事件队列的状态。

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

NOTE

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

并行线程

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

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

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

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

例如:

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

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

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

考虑这段代码:

javascript
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)

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

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

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

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

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

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

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

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

markdown
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。那么这种顺序呢?

markdown
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

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

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

NOTE

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

运行至完成

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

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

javascript
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:

javascript
var a = 1
var b = 2

代码块儿 2:

javascript
a++
b = b * a
a = b + 3

代码块儿 3:

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

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

结果 1:

javascript
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:

javascript
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

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

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

NOTE

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

并发

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

NOTE

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

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

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

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

NOTE

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

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

“线程”1(onscroll事件):

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

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

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

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

markdown
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 5
response 6
response 7

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

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

事件循环队列:

markdown
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没有按照预想的顺序应答吗?

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

非互动

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

举个例子:

javascript
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,并发的“进程”将有必要间接的互动。当这样的互动将要发生时,你需要协调这些互动行为来防止前面讲述的“竞态条件”。

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

javascript
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。

NOTE

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

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

javascript
var res = []

function response(data) {
  if (data.url == 'http://some.url.1') {
    res[0] = data
  }
  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 元素拥有内容之前显示它,所以协调工作就必须保证正确顺序的互动。

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

javascript
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将都是可用的。

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

javascript
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())之前我们等待它们全部到达。

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

考虑这段有问题的代码:

javascript
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()(不太可能是期望的)。

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

javascript
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()中的第一个通过,而第二个(以及后续所有的)调用将会被忽略。第二名什么也得不到!

NOTE

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

协作

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

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

javascript
var ers = []

// `response(..)`从Ajax调用收到一个结果数组
function response(data) {
  // 连接到既存的`res`数组上

  res = res.contact(
    // 制造一个新的变形过的数组,所有的`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。如果只有几千或更少的结果记录,一般来说不是什么大事。但假如有一千万个记录,那么就可能会花一段时间运行(在强大的笔记本电脑上花几秒钟,在移动设备上花的时间长得多,等等)。

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

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

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

javascript
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)(黑科技)来异步排程,基本上它的意思是“将这个函数贴在事件循环队列的末尾”。

NOTE

从技术上讲,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(..)。考虑如下代码:

javascript
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(如果可用的话!)触发排程。

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

语句排序

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

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

WARNING

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

考虑下面的代码:

javascript
var a, b

a = 10
b = 30

a = a + 1
b = b + 1

console.log(a + b) // 42

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

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

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

javascript
var a, b
a = 10
a++

b = 30
b++

console.log(a + b) // 42

或者是这样:

javascript
var a, b

a = 11
b = 31

console.log(a + b) // 42

或者甚至是:

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

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

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

javascript
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 与未来)。

考虑如下代码:

javascript
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 (clg 的结果,非执行结果)
b += c.bar // 11

console.log(a + b) // 42

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

javascript
// ...

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

// ..

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

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

复习

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

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

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

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

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


  1. 原子性 ↩︎