- 快召唤伙伴们来围观吧
- 微博 QQ QQ空间 贴吧
- 文档嵌入链接
- 复制
- 微信扫一扫分享
- 已成功复制到剪贴板
你不懂JS:异步与性能
展开查看详情
1 . 目 录 致谢 阅前必读 序 第一章: 异步:现在与稍后 块儿(Chunks)中的程序 事件轮询(Event Loop) 并行线程 并发 Jobs 语句排序 复习 第二章: 回调 延续 顺序的大脑 信任问题 尝试拯救回调 复习 第三章: Promise 什么是 Promise? Thenable 鸭子类型(Duck Typing) Promise的信任 链式流程 错误处理 Promise 模式 Promise API概览 Promise 的限制 复习 第四章: Generator 打破运行至完成 生成值 异步地迭代 Generator Generators + Promises Generator 委托 本文档使用 书栈(BookStack.CN) 构建 - 1 -
2 . Generator 并发 Thunks 前ES6时代的 Generator 复习 第五章: 程序性能 Web Workers SIMD asm.js 复习 第六章: 基准分析与调优 基准分析(Benchmarking) 上下文为王 jsPerf.com 编写好的测试 微观性能 尾部调用优化 (TCO) 复习 附录A:库:asynquence 附录B:高级异步模式 附录C:鸣谢 本文档使用 书栈(BookStack.CN) 构建 - 2 -
3 .致谢 致谢 当前文档 《你不懂JS: 异步与性能(You Dont Know JS)》 由 进击的皇虫 使用 书栈 (BookStack.CN) 进行构建,生成于 2018-02-09。 书栈(BookStack.CN) 仅提供文档编写、整理、归类等功能,以及对文档内容的生成和导出工 具。 文档内容由网友们编写和整理,书栈(BookStack.CN) 难以确认文档内容知识点是否错漏。如 果您在阅读文档获取知识的时候,发现文档内容有不恰当的地方,请向我们反馈,让我们共同携手, 将知识准确、高效且有效地传递给每一个人。 同时,如果您在日常生活、工作和学习中遇到有价值有营养的知识文档,欢迎分享到 书栈 (BookStack.CN) ,为知识的传承献上您的一份力量! 如果当前文档生成时间太久,请到 书栈(BookStack.CN) 获取最新的文档,以跟上知识更新换 代的步伐。 文档地址:http://www.bookstack.cn/books/You-Dont-Know-JS-async- performance 书栈官网:http://www.bookstack.cn 书栈开源:https://github.com/TruthHun 分享,让知识传承更久远! 感谢知识的创造者,感谢知识的分享者,也感谢每一位阅读到此处的 读者,因为我们都将成为知识的传承者。 本文档使用 书栈(BookStack.CN) 构建 - 3 -
4 .阅前必读 阅前必读 你不懂JS: 异步与性能 你不懂JS: 异步与性能 从O’Reilly购买数字/印刷版 序(Jake Archibald) 第一章: 异步:现在与稍后 第二章: 回调 第三章: Promise 第四章: Generator 第五章: 程序性能 第六章: 基准分析与调优 附录A:库:asynquence 附录B:高级异步模式 附录C:鸣谢 本文档使用 书栈(BookStack.CN) 构建 - 4 -
5 .阅前必读 本文档使用 书栈(BookStack.CN) 构建 - 5 -
6 .序 序 序 序 多年以前,我的雇主十分信任我来让我进行面试。如果我们要找某些拥有JavaScript技能的人,我 的问卷的第一行是…实际上这不是真的,我首先会问问应聘者是否需要上个卫生间或者喝些饮料,因为 平静是很重要的,但是一旦我确信可以和应聘者进行流畅的交流,我就要开始考察这位应聘者是否懂 得JavaScript,还是只懂得jQuery。 并不是jQuery有什么错。它使你不必真的懂得JavaScript就可以做很多事,这是一个特性而不是一 个bug。但是如果这份工作需要关于JavaScript性能和可维护性上的高级技能,你就需要一些懂得 jQuery这样的库是如何组装在一起的人。你需要能够像他们一样操控JavaScript的核心。 如果我想对某人的核心JavaScript技能取得一些了解,我最感兴趣就是他们如何使用闭包(你已经 读过这个系列的那本书了,对吧?),以及如何最大限度地利用异步性,而这就是这本书带给我们 的。 对于初学者,你将被带领着学习回调,它是异步编程的面包和黄油。当然,面包和黄油并不能做一顿 特别令人满意的午餐,但是下一课满是非常美味的promise! 如果你不懂得promise,现在是学习的时候了。现在在JavaScript和DOM中,Promise是提供异步 返回值的官方方法。所有未来的异步DOM API都将使用它们,而且有许多已经这样做了,所以做好准 备!在本次写作时,Promise已经在大多数主流浏览器中获得了支持,IE也很快会支持。一旦你完成 了这一课,我希望你离开教室去学习下一刻,Generator。 Generator不声不响地溜进了Chrome和Firefox的稳定版本,因为,老实说,它们的复杂程度要比 有趣程度大多了。或者说,直到我看到它们与promise组合起来之前我都是这么认为的。在此,它们 成为了增强可读性和可维护性的重要工具。 至于甜点,好吧,我不会把惊喜放坏了,准备好凝视JavaScript的未来吧!许多特性在并发性和异 步性上给了你越来越多的控制权。 好吧,我不会继续挡着你享受这本书了,让好戏开始吧!如果你已经在读这篇序之前度过了这本书的 一些部分,给你10点异步加分!你值得拥有! Jake Archibald jakearchibald.com, @jaffathecake Google Chrome 技术推广部 本文档使用 书栈(BookStack.CN) 构建 - 6 -
7 .序 本文档使用 书栈(BookStack.CN) 构建 - 7 -
8 .第一章: 异步:现在与稍后 第一章: 异步:现在与稍后 第一章: 异步: 现在与稍后 链接 第一章: 异步: 现在与稍后 在像JavaScript这样的语言中最重要但经常被误解的编程技术之一,就是如何表达和操作跨越一段 时间的程序行为。 这不仅仅是关于从 for 循环开始到 for 循环结束之间发生的事情,当然它确实要花 一些时间(几 微秒到几毫秒)才能完成。它是关于你的程序 现在 运行的部分,和你的程序 稍后 运行的另一部分 之间发生的事情——现在 和 稍后 之间有一个间隙,在这个间隙中你的程序没有活跃地执行。 几乎所有被编写过的(特别是用JS)大型程序都不得不用这样或那样的方法来管理这个间隙,不管是 等待用户输入,从数据库或文件系统请求数据,通过网络发送数据并等待应答,还是在规定的时间间 隔重复某些任务(比如动画)。在所有这些各种方法中,你的程序都不得不跨越时间间隙管理状态。 就像在伦敦众所周知的一句话(地铁门与月台间的缝隙):“小心间隙。” 实际上,你程序中 现在 与 稍后 的部分之间的关系,就是异步编程的核心。 可以确定的是,异步编程在JS的最开始就出现了。但是大多数开发者从没认真地考虑过它到底是如 何,为什么出现在他们的程序中的,也没有探索过 其他 处理异步的方式。足够好 的方法总是老实 巴交的回调函数。今天还有许多人坚持认为回调就绰绰有余了。 但是JS在使用范围和复杂性上不停地生长,作为运行在浏览器,服务器和每种可能的设备上的头等编 程语言,为了适应它不断扩大的要求,我们在管理异步上感受到的痛苦日趋严重,人们迫切地需要一 种更强大更合理的处理方法。 虽然眼前这一切看起来很抽象,但我保证,随着我们通读这本书你会更完整且坚实地解决它。在接下 来的几章中我们将会探索各种异步JavaScript编程的新兴技术。 但在接触它们之前,我们将不得不更深刻地理解异步是什么,以及它在JS中如何运行。 链接 块儿(Chunks)中的程序 事件轮询(Event Loop) 并行线程 并发 Jobs 语句排序 本文档使用 书栈(BookStack.CN) 构建 - 8 -
9 .第一章: 异步:现在与稍后 复习 本文档使用 书栈(BookStack.CN) 构建 - 9 -
10 .块儿(Chunks)中的程序 块儿(Chunks)中的程序 块儿(Chunks)中的程序 异步控制台 块儿(Chunks)中的程序 你可能将你的JS程序写在一个 .js 文件中,但几乎可以确定你的程序是由几个代码块儿构成的,仅 有其中的一个将会在 现在 执行,而其他的将会在 稍后 执行。最常见的 代码块儿 单位 是 function 。 大多数刚接触JS的开发者都可能会有的问题是,稍后 并不严格且立即地在 现在 之后发生。换句话 说,根据定义,现在 不能完成的任务将会异步地完成,而且我们因此不会有你可能在直觉上期望或想 要的阻塞行为。 考虑这段代码: 1. // ajax(..)是某个包中任意的Ajax函数 2. var data = ajax( "http://some.url.1" ); 3. 4. console.log( data ); 5. // 噢!`data`一般不会有Ajax的结果 你可能意识到Ajax请求不会同步地完成,这意味着 ajax(..) 函数还没有任何返回的值可以赋值给变 量 data 。如果 ajax(..) 在应答返回之前 能够 阻塞,那么 data = .. 赋值将会正常工作。 但那不是我们使用Ajax的方式。我们 现在 制造一个异步的Ajax请求,直到 稍后 我们才会得到结 果。 从 现在 “等到” 稍后 最简单的(但绝对不是唯一的,或最好的)方法,通常称为回调函数: 1. // ajax(..) 是某个包中任意的Ajax函数 2. ajax( "http://some.url.1", function myCallbackFunction(data){ 3. 4. console.log( data ); // Yay, 我得到了一些`data`! 5. 6. } ); 警告: 你可能听说过发起同步的Ajax请求是可能的。虽然在技术上是这样的,但你永远,永远不应 该在任何情况下这样做,因为它将锁定浏览器的UI(按钮,菜单,滚动条,等等)而且阻止用户与任 何东西互动。这是一个非常差劲的主意,你应当永远回避它。 本文档使用 书栈(BookStack.CN) 构建 - 10 -
11 .块儿(Chunks)中的程序 在你提出抗议之前,不,你渴望避免混乱的回调不是使用阻塞的,同步的Ajax的正当理由。 举个例子,考虑下面的代码: 1. function now() { 2. return 21; 3. } 4. 5. function later() { 6. answer = answer * 2; 7. console.log( "Meaning of life:", answer ); 8. } 9. 10. var answer = now(); 11. 12. setTimeout( later, 1000 ); // Meaning of life: 42 这个程序中有两个代码块儿:现在 将会运行的东西,和 稍后 将会运行的东西。这两个代码块分别 是什么应当十分明显,但还是让我们以最明确的方式指出来: 现在: 1. function now() { 2. return 21; 3. } 4. 5. function later() { .. } 6. 7. var answer = now(); 8. 9. setTimeout( later, 1000 ); 稍后: 1. answer = answer * 2; 2. console.log( "Meaning of life:", answer ); 你的程序一执行,现在 代码块儿就会立即运行。但 setTimeout(..) 还设置了一个 稍后 会发生的事 件(一个超时事件),所以 later() 函数的内容将会在一段时间后(从现在开始1000毫秒)被执 行。 每当你将一部分代码包进 function 并且规定它应当为了响应某些事件而执行(定时器,鼠标点击, Ajax应答等等),你就创建了一个 稍后 代码块儿,也因此在你的程序中引入了异步。 本文档使用 书栈(BookStack.CN) 构建 - 11 -
12 .块儿(Chunks)中的程序 异步控制台 关于 console.* 方法如何工作,没有相应的语言规范或一组需求——它们不是JavaScript官方的一 部分,而是由 宿主环境 添加到JS上的(见本丛书的 类型与文法)。 所以,不同的浏览器和JS环境各自为战,这有时会导致令人困惑的行为。 特别地,有些浏览器和某些条件下, console.log(..) 实际上不会立即输出它得到的东西。这个现象 的主要原因可能是因为I/O处理很慢,而且是许多程序的阻塞部分(不仅是JS)。所以,对一个浏览 器来说,可能的性能更好的处理方式是(从网页/UI的角度看),在后台异步地处理 console I/O, 而你也许根本不知道它发生了。 虽然不是很常见,但是一种可能被观察到(不是从代码本身,而是从外部)的场景是: 1. var a = { 2. index: 1 3. }; 4. 5. // 稍后 6. console.log( a ); // ?? 7. 8. // 再稍后 9. a.index++; 我们一般希望看到的是,就在 console.log(..) 语句被执行的那一刻,对象 a 被取得一个快照,打 印出如 { index: 1 } 的内容,如此在下一个语句 a.index++ 执行时,它修改不同于 a 的输出,或 者严格的在 a 的输出之后的某些东西。 大多数时候,上面的代码将会在你的开发者工具控制台中产生一个你期望的对象表现形式。但是同样 的代码也可能运行在这样的情况下:浏览器告诉后台它需要推迟控制台I/O,这时,在对象在控制台 中被表示的那个时间点, a.index++ 已经执行了,所以它将显示 { index: 2 } 。 到底在什么条件下 console I/O将被推迟是不确定的,甚至它能不能被观察到都是不确定的。只能当 你在调试过程中遇到问题时——对象在 console.log(..) 语句之后被修改,但你却意外地看到了修改后 的内容——意识到I/O的这种可能的异步性。 注意: 如果你遇到了这种罕见的情况,最好的选择是使用JS调试器的断点,而不是依赖 console 的 输出。第二好的选择是通过将目标对象序列化为一个 string 强制取得一个它的快照,比如 用 JSON.stringify(..) 。 本文档使用 书栈(BookStack.CN) 构建 - 12 -
13 .事件轮询(Event Loop) 事件轮询(Event Loop) 事件轮询(Event Loop) 事件轮询(Event Loop) 让我们来做一个(也许是令人震惊的)声明:尽管明确地允许异步JS代码(就像我们刚看到的超 时),但是实际上,直到最近(ES6)为止,JavaScript本身从来没有任何内建的异步概念。 什么!? 这听起来简直是疯了,对吧?事实上,它是真的。JS引擎本身除了在某个在被要求的时刻 执行你程序的一个单独的代码块外,没有做过任何其他的事情。 “被’谁’要求”?这才是重要的部分! JS引擎没有运行在隔离的区域。它运行在一个 宿主环境 中,对大多数开发者来说这个宿主环境就是 浏览器。在过去的几年中(但不特指这几年),JS超越了浏览器的界限进入到了其他环境中,比如服 务器,通过Node.js这样的东西。其实,今天JavaScript已经被嵌入到所有种类的设备中,从机器 人到电灯泡儿。 所有这些环境的一个共通的“线程”(一个“不那么微妙”的异步玩笑,不管怎样)是,他们都有一种机 制:在每次调用JS引擎时,可以 随着时间的推移 执行你的程序的多个代码块儿,这称为“事件轮询 (Event Loop)”。 换句话说,JS引擎对 时间 没有天生的感觉,反而是一个任意JS代码段的按需执行环境。是它周围的 环境在不停地安排“事件”(JS代码的执行)。 那么,举例来说,当你的JS程序发起一个从服务器取得数据的Ajax请求时,你在一个函数(通常称为 回调)中建立好“应答”代码,然后JS引擎就会告诉宿主环境,“嘿,我就要暂时停止执行了,但不管 你什么时候完成了这个网络请求,而且你还得到一些数据的话,请 回来调 这个函数。” 然后浏览器就会为网络的应答设置一个监听器,当它有东西要交给你的时候,它会通过将回调函数插 入 事件轮询 来安排它的执行。 那么什么是 事件轮询? 让我们先通过一些假想代码来对它形成一个概念: 1. // `eventLoop`是一个像队列一样的数组(先进先出) 2. var eventLoop = [ ]; 3. var event; 4. 5. // “永远”执行 6. while (true) { 7. // 执行一个"tick" 本文档使用 书栈(BookStack.CN) 构建 - 13 -
14 .事件轮询(Event Loop) 8. if (eventLoop.length > 0) { 9. // 在队列中取得下一个事件 10. event = eventLoop.shift(); 11. 12. // 现在执行下一个事件 13. try { 14. event(); 15. } 16. catch (err) { 17. reportError(err); 18. } 19. } 20. } 当然,这只是一个用来展示概念的大幅简化的假想代码。但是对于帮助我们建立更好的理解来说应该 够了。 如你所见,有一个通过 while 循环来表现的持续不断的循环,这个循环的每一次迭代称为一 个“tick”。在每一个“tick”中,如果队列中有一个事件在等待,它就会被取出执行。这些事件就是 你的函数回调。 很重要并需要注意的是, setTimeout(..) 不会将你的回调放在事件轮询队列上。它设置一个定时器; 当这个定时器超时的时候,环境才会把你的回调放进事件轮询,这样在某个未来的tick中它将会被取 出执行。 如果在那时事件轮询队列中已经有了20个事件会怎么样?你的回调要等待。它会排到队列最后——没有 一般的方法可以插队和跳到队列的最前方。这就解释了为什么 setTimeout(..) 计时器可能不会完美地 按照预计时间触发。你得到一个保证(粗略地说):你的回调不会再你指定的时间间隔之前被触发, 但是可能会在这个时间间隔之后被触发,具体要看事件队列的状态。 换句话说,你的程序通常被打断成许多小的代码块儿,它们一个接一个地在事件轮询队列中执行。而 且从技术上说,其他与你的程序没有直接关系的事件也可以穿插在队列中。 注意: 我们提到了“直到最近”,暗示着ES6改变了事件轮询队列在何处被管理的性质。这主要是一个 正式的技术规范,ES6现在明确地指出了事件轮询应当如何工作,这意味着它技术上属于JS引擎应当 关心的范畴内,而不仅仅是 宿主环境。这么做的一个主要原因是为了引入ES6的Promises(我们将 在第三章讨论),因为人们需要有能力对事件轮询队列的排队操作进行直接,细粒度的控制(参见“协 作”一节中关于 setTimeout(..0) 的讨论)。 本文档使用 书栈(BookStack.CN) 构建 - 14 -
15 .并行线程 并行线程 并行线程 运行至完成 并行线程 “异步”与“并行”两个词经常被混为一谈,但它们实际上是十分不同的。记住,异步是关于 现在 与 稍后 之间的间隙。但并行是关于可以同时发生的事情。 关于并行计算最常见的工具就是进程与线程。进程和线程独立地,可能同时地执行:在不同的处理器 上,甚至在不同的计算机上,而多个线程可以共享一个进程的内存资源。 相比之下,一个事件轮询将它的工作打碎成一系列任务并串行地执行它们,不允许并行访问和更改共 享的内存。并行与“串行”可能以在不同线程上的事件轮询协作的形式共存。 并行线程执行的穿插,与异步事件的穿插发生在完全不同的粒度等级上: 比如: 1. function later() { 2. answer = answer * 2; 3. console.log( "Meaning of life:", answer ); 4. } 虽然 later() 的整个内容将被当做一个事件轮询队列的实体,但当考虑到将要执行这段代码的线程 时,实际上也许会有许多不同的底层操作。比如, answer = answer * 2 首先需要读取当 前 answer 的值,再把 2 放在某个地方,然后进行乘法计算,最后把结果存回到 answer 。 在一个单线程环境中,线程队列中的内容都是底层操作真的无关紧要,因为没有什么可以打断线程。 但如果你有一个并行系统,在同一个程序中有两个不同的线程,你很可能会得到无法预测的行为: 考虑这段代码: 1. var a = 20; 2. 3. function foo() { 4. a = a + 1; 5. } 6. 7. function bar() { 8. a = a * 2; 9. } 本文档使用 书栈(BookStack.CN) 构建 - 15 -
16 .并行线程 10. 11. // ajax(..) 是一个给定的库中的随意Ajax函数 12. ajax( "http://some.url.1", foo ); 13. ajax( "http://some.url.2", bar ); 在JavaScript的单线程行为下,如果 foo() 在 bar() 之前执行,结果 a 是 42 ,但如 果 bar() 在 foo() 之前执行,结果 a 将是 41 。 如果JS事件共享相同的并列执行数据,问题将会变得微妙得多。考虑这两个假想代码段,它们分别描 述了运行 foo() 和 bar() 中代码的线程将要执行的任务,并考虑如果它们在完全相同的时刻运行 会发生什么: 线程1( X 和 Y 是临时的内存位置): 1. foo(): 2. a. 将`a`的值读取到`X` 3. b. 将`1`存入`Y` 4. c. 把`X`和`Y`相加,将结果存入`X` 5. d. 将`X`的值存入`a` 线程2( X 和 Y 是临时的内存位置): 1. bar(): 2. a. 将`a`的值读取到`X` 3. b. 将`2`存入`Y` 4. c. 把`X`和`Y`相乘,将结果存入`X` 5. d. 将`X`的值存入`a` 现在,让我们假定这两个线程在并行执行。你可能发现了问题,对吧?它们在临时的步骤中使用共享 的内存位置 X 和 Y 。 如果步骤像这样发生, a 的最终结果什么? 1. 1a (将`a`的值读取到`X` ==> `20`) 2. 2a (将`a`的值读取到`X` ==> `20`) 3. 1b (将`1`存入`Y` ==> `1`) 4. 2b (将`2`存入`Y` ==> `2`) 5. 1c (把`X`和`Y`相加,将结果存入`X` ==> `22`) 6. 1d (将`X`的值存入`a` ==> `22`) 7. 2c (把`X`和`Y`相乘,将结果存入`X` ==> `44`) 8. 2d (将`X`的值存入`a` ==> `44`) a 中的结果将是 44 。那么这种顺序呢? 本文档使用 书栈(BookStack.CN) 构建 - 16 -
17 .并行线程 1. 1a (将`a`的值读取到`X` ==> `20`) 2. 2a (将`a`的值读取到`X` ==> `20`) 3. 2b (将`2`存入`Y` ==> `2`) 4. 1b (将`1`存入`Y` ==> `1`) 5. 2c (把`X`和`Y`相乘,将结果存入`X` ==> `20`) 6. 1c (把`X`和`Y`相加,将结果存入`X` ==> `21`) 7. 1d (将`X`的值存入`a` ==> `21`) 8. 2d (将`X`的值存入`a` ==> `21`) a 中的结果将是 21 。 所以,关于线程的编程十分刁钻,因为如果你不采取特殊的步骤来防止这样的干扰/穿插,你会得到令 人非常诧异的,不确定的行为。这通常让人头疼。 JavaScript从不跨线程共享数据,这意味着不必关心这一层的不确定性。但这并不意味着JS总是确 定性的。记得前面 foo() 和 bar() 的相对顺序产生两个不同的结果吗( 41 或 42 )? 注意: 可能还不明显,但不是所有的不确定性都是坏的。有时候它无关紧要,有时候它是故意的。我 们会在本章和后续几章中看到更多的例子。 运行至完成 因为JavaScript是单线程的, foo() (和 bar() )中的代码是原子性的,这意味着一 旦 foo() 开始运行,它的全部代码都会在 bar() 中的任何代码可以运行之前执行完成,反之亦 然。这称为“运行至完成”行为。 事实上,运行至完成的语义会在 foo() 与 bar() 中有更多的代码时更明显,比如: 1. var a = 1; 2. var b = 2; 3. 4. function foo() { 5. a++; 6. b = b * a; 7. a = b + 3; 8. } 9. 10. function bar() { 11. b--; 12. a = 8 + b; 13. b = a * 2; 14. } 15. 16. // ajax(..) 是某个包中任意的Ajax函数 17. ajax( "http://some.url.1", foo ); 18. ajax( "http://some.url.2", bar ); 本文档使用 书栈(BookStack.CN) 构建 - 17 -
18 .并行线程 因为 foo() 不能被 bar() 打断,而且 bar() 不能被 foo() 打断,所以这个程序根据哪一个先执 行只有两种可能的结果——如果线程存在, foo() 和 bar() 中的每一个语句都可能被穿插,可能的 结果数量将会极大地增长! 代码块儿1是同步的(现在 发生),但代码块儿2和3是异步的(稍后 发生),这意味着它们的执行 将会被时间的间隙分开。 代码块儿1: 1. var a = 1; 2. var b = 2; 代码块儿2 ( foo() ): 1. a++; 2. b = b * a; 3. a = b + 3; 代码块儿3 ( bar() ): 1. b--; 2. a = 8 + b; 3. b = a * 2; 代码块儿2和3哪一个都有可能先执行,所以这个程序有两个可能的结果,正如这里展示的: 结果1: 1. var a = 1; 2. var b = 2; 3. 4. // foo() 5. a++; 6. b = b * a; 7. a = b + 3; 8. 9. // bar() 10. b--; 11. a = 8 + b; 12. b = a * 2; 13. 14. a; // 11 15. b; // 22 本文档使用 书栈(BookStack.CN) 构建 - 18 -
19 .并行线程 结果2: 1. var a = 1; 2. var b = 2; 3. 4. // bar() 5. b--; 6. a = 8 + b; 7. b = a * 2; 8. 9. // foo() 10. a++; 11. b = b * a; 12. a = b + 3; 13. 14. a; // 183 15. b; // 180 同一段代码有两种结果仍然意味着不确定性!但是这是在函数(事件)顺序的水平上,而不是在使用 线程时语句顺序的水平上(或者说,实际上是表达式操作的顺序上)。换句话说,他比线程更具有 确 定性。 当套用到JavaScript行为时,这种函数顺序的不确定性通常称为“竞合状态”,因 为 foo() 和 bar() 在互相竞争看谁会先运行。明确地说,它是一个“竞合状态”因为你不能可靠地 预测 a 与 b 将如何产生。 注意: 如果在JS中不知怎的有一个函数没有运行至完成的行为,我们会有更多可能的结果,对吧? ES6中引入一个这样的东西(见第四章“生成器”),但现在不要担心,我们会回头讨论它。 本文档使用 书栈(BookStack.CN) 构建 - 19 -
20 .并发 并发 并发 非互动 互动 协作 并发 让我们想象一个网站,它显示一个随着用户向下滚动而逐步加载的状态更新列表(就像社交网络的新 消息)。要使这样的特性正确工作,(至少)需要两个分离的“进程” 同时 执行(在同一个时间跨度 内,但没必要是同一个时间点)。 注意: 我们在这里使用带引号的“进程”,因为它们不是计算机科学意义上的真正的操作系统级别的 进程。它们是虚拟进程,或者说任务,表示一组逻辑上关联,串行顺序的操作。我们将简单地使用“进 程”而非“任务”,因为在术语层面它与我们讨论的概念的定义相匹配。 第一个“进程”将响应当用户向下滚动页面时触发的 onscroll 事件(发起取得新内容的Ajax请 求)。第二个“进程”将接收返回的Ajax应答(将内容绘制在页面上)。 显然,如果用户向下滚动的足够快,你也许会看到在第一个应答返回并处理期间,有两个或更多 的 onscroll 事件被触发,因此你将使 onscroll 事件和Ajax应答事件迅速触发,互相穿插在一 起。 并发是当两个或多个“进程”在同一时间段内同时执行,无论构成它们的各个操作是否 并行地(在同 一时刻不同的处理器或内核)发生。你可以认为并发是“进程”级别的(或任务级别)的并行机制,而 不是操作级别的并行机制(分割进程的线程)。 注意: 并发还引入了这些“进程”间彼此互动的概念。我们稍后会讨论它。 在一个给定的时间跨度内(用户可以滚动的那几秒),让我们将每个独立的“进程”作为一系列事件/ 操作描绘出来: “线程”1 ( onscroll 事件): 1. onscroll, request 1 2. onscroll, request 2 3. onscroll, request 3 4. onscroll, request 4 5. onscroll, request 5 6. onscroll, request 6 7. onscroll, request 7 本文档使用 书栈(BookStack.CN) 构建 - 20 -
21 .并发 “线程”2 (Ajax应答事件): 1. response 1 2. response 2 3. response 3 4. response 4 5. response 5 6. response 6 7. response 7 一个 onscroll 事件与一个Ajax应答事件很有可能在同一个 时刻 都准备好被处理了。比如我们在 一个时间线上描绘一下这些事件的话: 1. onscroll, request 1 2. onscroll, request 2 response 1 3. onscroll, request 3 response 2 4. response 3 5. onscroll, request 4 6. onscroll, request 5 7. onscroll, request 6 response 4 8. onscroll, request 7 9. response 6 10. response 5 11. response 7 但是,回到本章前面的事件轮询概念,JS一次只能处理一个事件,所以不是 onscroll, request 2 首 先发生就是 response 1 首先发生,但是他们不可能完全在同一时刻发生。就像学校食堂的孩子们一 样,不管他们在门口挤成什么样,他们最后都不得不排成一个队来打饭! 让我们来描绘一下所有这些事件在事件轮询队列上穿插的情况: 事件轮询队列: 1. onscroll, request 1 <--- 进程1开始 2. onscroll, request 2 3. response 1 <--- 进程2开始 4. onscroll, request 3 5. response 2 6. response 3 7. onscroll, request 4 8. onscroll, request 5 9. onscroll, request 6 10. response 4 11. onscroll, request 7 <--- 进程1结束 12. response 6 13. response 5 本文档使用 书栈(BookStack.CN) 构建 - 21 -
22 .并发 14. response 7 <--- 进程2结束 “进程1”和“进程2”并发地运行(任务级别的并行),但是它们的个别事件在事件轮询队列上顺序地运 行。 顺便说一句,注意到 response 6 和 response 5 没有按照预想的顺序应答吗? 单线程事件轮询是并发的一种表达(当然还有其他的表达,我们稍后讨论)。 非互动 在同一个程序中两个或更多的“进程”在穿插它们的步骤/事件时,如果它们的任务之间没有联系,那 么他们就没必要互动。如果它们不互动,不确定性就是完全可以接受的。 举个例子: 1. var res = {}; 2. 3. function foo(results) { 4. res.foo = results; 5. } 6. 7. function bar(results) { 8. res.bar = results; 9. } 10. 11. // ajax(..) 是某个包中任意的Ajax函数 12. ajax( "http://some.url.1", foo ); 13. ajax( "http://some.url.2", bar ); foo() 和 bar() 是两个并发的“进程”,而且它们被触发的顺序是不确定的。但对我们的程序的结 构来讲它们的触发顺序无关紧要,因为它们的行为相互独立所以不需要互动。 这不是一个“竞合状态”Bug,因为这段代码总能够正确工作,与顺序无关。 互动 更常见的是,通过作用域和/或DOM,并发的“进程”将有必要间接地互动。当这样的互动将要发生时, 你需要协调这些互动行为来防止前面讲述的“竞合状态”。 这里是两个由于隐含的顺序而互动的并发“进程”的例子,它 有时会出错: 1. var res = []; 2. 3. function response(data) { 本文档使用 书栈(BookStack.CN) 构建 - 22 -
23 .并发 4. res.push( data ); 5. } 6. 7. // ajax(..) 是某个包中任意的Ajax函数 8. ajax( "http://some.url.1", response ); 9. 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" 要慢得多,也许有赖于它们所 做的任务(比如,一个执行数据库任务而另一个只是取得静态文件),所以观察到的顺序看起来总是 所期望的。就算两个请求都发到同一个服务器,而且它故意以确定的顺序应答,也不能 真正 保证应 答回到浏览器的顺序。 所以,为了解决这样的竞合状态,你可以协调互动的顺序: 1. var res = []; 2. 3. function response(data) { 4. if (data.url == "http://some.url.1") { 5. res[0] = data; 6. } 7. else if (data.url == "http://some.url.2") { 8. res[1] = data; 9. } 10. } 11. 12. // ajax(..) 是某个包中任意的Ajax函数 13. ajax( "http://some.url.1", response ); 14. 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元素拥有内容之前显示它,所以协调工作就必须保证正确顺序的互动。 本文档使用 书栈(BookStack.CN) 构建 - 23 -
24 .并发 没有协调的互动,有些并发的场景 总是出错(不仅仅是 有时)。考虑下面的代码: 1. var a, b; 2. 3. function foo(x) { 4. a = x * 2; 5. baz(); 6. } 7. 8. function bar(y) { 9. b = y * 2; 10. baz(); 11. } 12. 13. function baz() { 14. console.log(a + b); 15. } 16. 17. // ajax(..) 是某个包中任意的Ajax函数 18. ajax( "http://some.url.1", foo ); 19. ajax( "http://some.url.2", bar ); 在这个例子中,不管 foo() 和 bar() 谁先触发,总是会使 baz() 运行的太早了( a 和 b 之 一还是空的时候),但是第二个 baz() 调用将可以工作,因为 a 和 b 将都是可用的。 有许多不同的方法可以解决这个状态。这是简单的一种: 1. var a, b; 2. 3. function foo(x) { 4. a = x * 2; 5. if (a && b) { 6. baz(); 7. } 8. } 9. 10. function bar(y) { 11. b = y * 2; 12. if (a && b) { 13. baz(); 14. } 15. } 16. 17. function baz() { 18. console.log( a + b ); 19. } 20. 本文档使用 书栈(BookStack.CN) 构建 - 24 -
25 .并发 21. // ajax(..) 是某个包中任意的Ajax函数 22. ajax( "http://some.url.1", foo ); 23. ajax( "http://some.url.2", bar ); baz() 调用周围的 if (a && b) 条件通常称为“大门”,因为我们不能确定 a 和 b 到来的顺 序,但在打开大门(调用 baz() )之前我们等待它们全部到达。 另一种你可能会遇到的并发互动状态有时称为“竞争”,但更准确地说应该叫“门闩”。它的行为特点 是“先到者胜”。在这里不确定性是可以接受的,因为你明确指出“竞争”的终点线上只有一个胜利者。 考虑这段有问题的代码: 1. var a; 2. 3. function foo(x) { 4. a = x * 2; 5. baz(); 6. } 7. 8. function bar(x) { 9. a = x / 2; 10. baz(); 11. } 12. 13. function baz() { 14. console.log( a ); 15. } 16. 17. // ajax(..) 是某个包中任意的Ajax函数 18. ajax( "http://some.url.1", foo ); 19. ajax( "http://some.url.2", bar ); 不管哪一个函数最后触发( foo() 或 bar() ),它不仅会覆盖前一个函数对 a 的赋值,还会重 复调用 baz() (不太可能是期望的)。 所以,我们可以用一个简单的门闩来协调互动,仅让第一个过去: 1. var a; 2. 3. function foo(x) { 4. if (a == undefined) { 5. a = x * 2; 6. baz(); 7. } 8. } 9. 本文档使用 书栈(BookStack.CN) 构建 - 25 -
26 .并发 10. function bar(x) { 11. if (a == undefined) { 12. a = x / 2; 13. baz(); 14. } 15. } 16. 17. function baz() { 18. console.log( a ); 19. } 20. 21. // ajax(..) 是某个包中任意的Ajax函数 22. ajax( "http://some.url.1", foo ); 23. ajax( "http://some.url.2", bar ); if (a == undefined) 条件仅会让 foo() 或 bar() 中的第一个通过,而第二个(以及后续所有的) 调用将会被忽略。第二名什么也得不到! 注意: 在所有这些场景中,为了简化说明的目的我们都用了全局变量,这里我们没有任何理由需要这 么做。只要我们讨论中的函数可以访问变量(通过作用域),它们就可以正常工作。依赖于词法作用 域变量(参见本丛书的 作用域与闭包 ),和这些例子中实质上的全局变量,是这种并发协调形式的 一个明显的缺点。在以后的几章中,我们会看到其他的在这方面干净得多的协调方法。 协作 另一种并发协调的表达称为“协作并发”,它并不那么看重在作用域中通过共享值互动(虽然这依然是 允许的!)。它的目标是将一个长时间运行的“进程”打断为许多步骤或批处理,以至于其他的并 发“进程”有机会将它们的操作穿插进事件轮询队列。 举个例子,考虑一个Ajax应答处理器,它需要遍历一个很长的结果列表来将值变形。我们将使 用 Array#map(..) 来让代码短一些: 1. var res = []; 2. 3. // `response(..)`从Ajax调用收到一个结果数组 4. function response(data) { 5. // 连接到既存的`res`数组上 6. res = res.concat( 7. // 制造一个新的变形过的数组,所有的`data`值都翻倍 8. data.map( function(val){ 9. return val * 2; 10. } ) 11. ); 12. } 13. 14. // ajax(..) 是某个包中任意的Ajax函数 本文档使用 书栈(BookStack.CN) 构建 - 26 -
27 .并发 15. ajax( "http://some.url.1", response ); 16. ajax( "http://some.url.2", response ); 如果 "http://some.url.1" 首先返回它的结果,整个结果列表将会一次性映射进 res 。如果只有几 千或更少的结果记录,一般来说不是什么大事。但假如有1千万个记录,那么就可能会花一段时间运行 (在强大的笔记本电脑上花几秒钟,在移动设备上花的时间长得多,等等)。 当这样的“处理”运行时,页面上没有任何事情可以发生,包括不能有另一个 response(..) 调用,不 能有UI更新,甚至不能有用户事件比如滚动,打字,按钮点击等。非常痛苦。 所以,为了制造协作性更强、更友好而且不独占事件轮询队列的并发系统,你可以在一个异步批处理 中处理这些结果,在批处理的每一步都“让出”事件轮询来让其他等待的事件发生。 这是一个非常简单的方法: 1. var res = []; 2. 3. // `response(..)`从Ajax调用收到一个结果数组 4. function response(data) { 5. // 我们一次只处理1000件 6. var chunk = data.splice( 0, 1000 ); 7. 8. // 连接到既存的`res`数组上 9. res = res.concat( 10. // 制造一个新的变形过的数组,所有的`data`值都翻倍 11. chunk.map( function(val){ 12. return val * 2; 13. } ) 14. ); 15. 16. // 还有东西要处理吗? 17. if (data.length > 0) { 18. // 异步规划下一个批处理 19. setTimeout( function(){ 20. response( data ); 21. }, 0 ); 22. } 23. } 24. 25. // ajax(..) 是某个包中任意的Ajax函数 26. ajax( "http://some.url.1", response ); 27. ajax( "http://some.url.2", response ); 我们以每次最大1000件作为一个块儿处理数据。这样,我们保证每个“进程”都是短时间运行的,即便 这意味着会有许多后续的“进程”,在事件轮询队列上的穿插将会给我们一个响应性(性能)强得多的 网站/应用程序。 本文档使用 书栈(BookStack.CN) 构建 - 27 -
28 .并发 当然,我们没有对任何这些“进程”的顺序进行互动协调,所以在 res 中的结果的顺序是不可预知 的。如果要求顺序,你需要使用我们之前讨论的互动技术,或者在本书后续章节中介绍的其他技术。 我们使用 setTimeout(..0) (黑科技)来异步排程,基本上它的意思是“将这个函数贴在事件轮询队 列的末尾”。 注意: 从技术上讲, setTimeout(..0) 没有直接将一条记录插入事件轮询队列。计时器将会在下一 个运行机会将事件插入。比如,两个连续的 setTimeout(..0) 调用不会严格保证以调用的顺序被处 理,所以我们可能看到各种时间偏移的情况,使这样的事件的顺序是不可预知的。在Node.js中,一 个相似的方式是 process.nextTick(..) 。不管那将会有多方便(而且通常性能更好),(还)没有一 个直接的方法可以横跨所有环境来保证异步事件顺序。我们会在下一节详细讨论这个话题。 本文档使用 书栈(BookStack.CN) 构建 - 28 -
29 .Jobs Jobs Jobs 在ES6中,在事件轮询队列之上引入了一层新概念,称为“工作队列(Job queue)”。你最有可能接 触它的地方是在Promises(见第三章)的异步行为中。 不幸的是,它目前是一个没有公开API的机制,因此要演示它有些兜圈子。我们不得不仅仅在概念上 描述它,这样当我们在第三章中讨论异步行为时,你将会理解那些动作行为是如何排程与处理的。 那么,我能找到的考虑它的最佳方式是:“工作队列”是一个挂靠在事件轮询队列的每个tick末尾的队 列。在事件轮询的一个tick期间内,某些可能发生的隐含异步动作的行为将不会导致一个全新的事件 加入事件轮询队列,而是在当前tick的工作队列的末尾加入一个新的记录(也就是一个Job)。 它好像是在说,“哦,另一件需要我 稍后 去做的事儿,但是保证它在其他任何事情发生之间发生。” 或者,用一个比喻:事件轮询队列就像一个游乐园项目,一旦你乘坐完一次,你就不得不去队尾排队 来乘坐下一次。而工作队列就像乘坐完后,立即插队乘坐下一次。 一个Job还可能会导致更多的Job被加入同一个队列的末尾。所以,一个在理论上可能的情况是, Job“轮询”(一个Job持续不断地加入其他Job等)会无限地转下去,从而拖住程序不能移动到一下一 个事件轮询tick。这与在你的代码中表达一个长时间运行或无限循环(比如 while (true) .. )在概 念上几乎是一样的。 Job的精神有点儿像 setTimeout(..0) 黑科技,但以一种定义明确得多的方式实现,而且保证顺序: 稍后,但尽快。 让我们想象一个用于Job排程的API,并叫它 schedule(..) 。考虑如下代码: 1. console.log( "A" ); 2. 3. setTimeout( function(){ 4. console.log( "B" ); 5. }, 0 ); 6. 7. // 理论上的 "Job API" 8. schedule( function(){ 9. console.log( "C" ); 10. 11. schedule( function(){ 12. console.log( "D" ); 13. } ); 14. } ); 本文档使用 书栈(BookStack.CN) 构建 - 29 -