起因
开始阅读学习《Effective JavaScript》,以自身阅读和理解,着重记录内容精华部分以及对内容进行排版,便于日后自身回顾学习以及大家交流学习。
因内容居多,分为每个章节来进行编写文章,每章节的准条多少不一,故每篇学习笔记的文章以章节为准。
适合碎片化阅读,精简阅读的小友们。争取让小友们看完系列 === 看整本书的 85+%。
前言
内容总览
- 第一章让初学者快速熟悉 JavaScript,了解 JavaScript 中的原始类型、隐式强制转换、编码类型等几本概念;
- 第二章着重讲解了有关 JavaScript 的变量作用域的建议,不仅介绍了怎么做,还介绍了操作背后的原因,帮助读者加深理解;
- 第三章和第四章的主题涵盖函数、对象及原型三大方面,这可是 JavaScript 区别于其他语言的核心;
- 第五章阐述了数组和字典这两种容易混淆的常用类型及具体使用时的建议,避免陷入一些陷阱;
- 第六章讲述了库和 API 设计;
- 第七章讲述了并行编程,这是晋升为 JavaScript 专家的必经之路
第 7 章「并发」
JavaScript 被设计为一种嵌入式的脚本语言,意味着其不是以独立的应用程序运行,而是作为大型应用程序环境下的脚本运行。如一个 Web 浏览器可能具有许多窗体和标签运行多个 Web 应用程序,每个程序响应不同的输入和触发源 —— 用户通过键盘、鼠标、触摸板的动作,来自网络的数据到达。这些事件可能在 Web 应用程序的生命周期的任何时刻发生,甚至同时发生。
在 JavaScript 中,编写相应多个并发事件的程序的方法非常人性化且强大,其使用了一个简单的执行模型(有时称为事件队列或事件循环并发)和被称为异步的 API。多亏这一方法的有效性以及 JavaScript 独立于 Web 浏览器标准化的事实,使得 JavaScript 称为其他多种应用程序的编程语言,比如从桌面应用程序到服务器端框架的 Node.js。
因此,本章将讨论一些“约定成俗”的 JavaScript 特性,绝大数 JavaScript 的环境都使用相同的并发策略,未来标准的版本很可能会基于广泛实现的执行模型来标准化,因此使用事件和异步 API 是 JavaScript 编程的基础部分。
第 61 条:不要阻塞 I/O 事件队列
JavaScript 程序是构建在事件之上的,输入可能同时来自于各种各样的外部源,比如用户的交互操作、输入的网络数据或定时警报。
const text = downloadSync('xxx.txt');
console.log(text);
例如 downloadSync 这样的函数被称为同步函数(阻塞函数),程序会停止做任何工作而等待它的输入。在这个例子中,也就是等待从网络下载文件的结果。但由于在等待下载完成的期间,计算机可以做其他有用的工作,因此通常为程序员提供一种方法来创建多个线程,即并行执行子计算。它允许程序的一部分停下来等待(阻塞)一个低速的输入,而程序的另一部分可以继续进行独立的工作。
downloadAsync('xxx.txt', function(text) {
console.log(text);
});
在 JavaScript 中,大多数 I/O 操作都提供了异步的或非阻塞的 API。程序员提供一个回调函数,一旦输入完成就可以被系统调用,而不是将程序阻塞在等待结果的线程上。
现在,系统不仅会适时地介入其中,并且会在下载完成的瞬间调用回调函数,JavaScript 有时被称为提供一个运行到完成机制的担保。任何当前正在运行于共享上下文的用户代码,比如浏览器中的单个 Web 页面或者单个运行的 Web 服务器实例,只有在执行完成后才能调用下一个事件处理程序。事实上,系统维护了一个按事件发生顺序排列的内部事件队列,一次调用一个已注册的回调函数。

然而运行到完成机制的不足是,实际上所有你编写的代码支撑着余下应用程序的继续执行。像浏览器这样的交互式应用程序中,一个阻塞的事件处理程序会阻塞任何将被处理的其他用户输入,甚至可能阻塞一个页面的渲染,从而导致页面失去响应的用户体验。在服务端环境,一个阻塞的事件处理程序可能会阻塞将被处理的其他网络请求,从而导致服务器失去响应。
因此,并发的一个最重要规则是绝不要在应用程序事件队列中使用阻塞 I/O 的 API。在浏览器中,甚至几乎没有任何阻塞 API 是可用的,对于 Web 应用程序的交互性,同步的 I/O 会导致灾难性的后果,它在 I/O 操作完成之前一直会阻塞用户与页面的交互。
相比之下,异步的 API 用在基于事件的环境中时安全的,因为它们迫使应用程序逻辑在一个独立的事件循环“轮询”中继续处理。这将允许 JavaScript 代码在上述代码下载完成之前,允许其他事件处理程序处理这期间的事件。
在主应用程序事件队列不受影响的环境中,阻塞操作很少出问题。例如,Web 平台提供了 Worker 的 API,该 API 使得产生大量的并行计算称为可能。不同于传统的线程执行,Workers 在一个完全隔离的状态下执行,没有获取全局作用域或应用程序主线程 Web 页面内容的能力。因此,它们不会妨碍主事件队列中运行代码的执行。
在一个 Worker 中,使用 XMLHttpRequest 同步的变种很少出问题。下载操作虽会阻塞 Worker 继续执行,但这并不会阻止页面的渲染或事件队列中的事件响应。在服务端环境中,阻塞的 API 在启动一开始时没有问题的,也就是在服务器开始响应输入的请求之前。然而在处理请求期间,浏览器事件队列中存在阻塞的 API 就是彻头彻尾的灾难。
总结
- 异步 API 使用毁掉函数来延缓处理代价高昂的操作以避免阻塞主应用程序。
- JavaScript 并发地接收事件,但会使用一个事件队列按序地处理事件处理程序。
- 在应用程序事件队列中绝不要使用阻塞的 I/O。
第 62 条:在异步序列中使用嵌套或命名的回调函数
异步嵌套
理解异步程序的操作顺序刚开始有点混乱。例如,下面的程序在打印“finished” 之前打印 "starting"。
downloadAsync('file.txt', function(text) {
console.log('finished');
});
console.log('starting')
理解操作序列的最简单的方式是异步 API 是发起操作而不是执行操作。当下载完成后,在事件循环的某个单独的轮次中,被注册的事件处理程序才会打印出“finish”。
如果需要在发起一个操作后做一些事情,可以放置声明串联已完成的异步操作吗?
lookupAsync('url', function(url) {
// ?
});
downloadAsync('url', function(text) { // error: url is not bound
console.log('text');
});
这不能工作,因为从数据库查询到的 URL 结果需要作为 downloadAsync 方法的参数,但它并不在作用域内。最简单的方式是利用嵌套,利用闭包的魔力,暂存下内存变量。
lookupAsync('url', function(url) {
downloadAsync('url', function(text) {
console.log('text');
});
});
命名回调
当多个嵌套的异步操作,扩展到更长的序列时会变得很笨拙。
lookupAsync('url', function(url) {
downloadAsync('url', function(text) {
downloadAsync('a.txt', function(text) {
downloadAsync('b.txt', function(text) {
...
});
});
});
});
减少过多嵌套的方法之一是将嵌套的回调函数作为命名的函数,并将它们需要的附加数据作为额外的参数传递。如下:
db.lookupAsync('url', downloadURL);
function downloadURL(url) {
downloadAsync(url, function(text) { // still nested
showContents(url, text);
});
}
function showContents(url, text) {
console.log("contents of" + url + ":" + text);
}
在 downloadURL 方法中,为了合并外部的 url 变量和内部的 text 变量作为参数,仍然使用了嵌套的回调函数,我们可以使用 bind 方法来显示改变作用域。
function downloadURL(url) {
downloadAsync(url, showContents.bind(null, url));
}
这种做法导致了代码看起来更具顺序性,但需要为操作序列的每个中间步骤命名,并且一步步绑定:
// awkward name
function downloadABC(url, file) {
downloadAsync('a.txt', downloadFiles23.bind(null, url, file));
}
// awkward name
function downloadBC(url, file) {
downloadAsync('b.txt', downloadFiles3.bind(null, url, file, a));
}
// awkward name
function downloadC(url, file) {
downloadAsync('c.txt', finish.bind(null, url, file, a b));
}
function finish(url, file, a, b, c) {
// ...
}
更好的方法是最后一步可以使用额外的抽象来简化,可以下载多个文件并将它们存储在数组中。
function downloadFiles(url, file) {
downloadAllAsync(['a.txt', 'b.txt', 'c.txt'], function (all) {
const a = all[0], b = all[1], c = all[2];
// ...
});
}
一些操作本质上是连续的,比如下载我们从数据库查询到的 URL。但如果我们有一个文件列表要下载,没理由等每个文件完成下载后才请求接下来的一个。
除了嵌套和命名回调,还可能建立更高层的抽象使异步控制流更简单、更简洁。第 68 条描述了 Promise的做法。除此之外,也值得探索一些异步的程序库或尝试使用自己的抽象。
总结
- 使用嵌套或命名的回调函数按顺序地执行多个异步操作。
- 尝试在过多的嵌套的回调函数和尴尬的命名的非嵌套回调函数之间取得平衡。
- 避免将可被并行执行的操作顺序化。
第 63 条:当心丢弃错误
管理异步编程的一个比较难的方面是对错误的处理。对于同步的代码,通过使用 try...catch 语句块包装一段代码很容易处理错误。
try {
f();
g();
h()
} catch (e) {
throw(e);
}
而面对异步的代码,多步的处理通常被分割到事件队列的单独轮次中,因此不可能将它们全部包装在一个 try 语句块中。事实上,异步的 API 甚至根本不可能抛出异常,当一个异步错误发生时,没有一个明显的执行上下文来抛出异常。相反,异步的 API 倾向于将错误表示为回调函数的特定参数,或使用一个附加的错误处理回调函数(errbacks)。
downloadAsync('file.txt', function(text) {
console.log('finished');
}, function(error) {
console.log('Error: ' + error);
});
如果下载多个文件,可以参照 62 条嵌套或命名函数。
downloadAllAsync(['a.txt', 'b.txt', 'c.txt'], function (all) {
const a = all[0], b = all[1], c = all[2];
// ...
}, function(error) {
console.log('Error: ' + error);
});
另一种错误处理 API 的风格受到 Node.js 平台的推广,只需要一个回调函数,该回到函数的第一个参数如果有错误发生那就表示为一个错误;否则就为一个假值,比如 null。
function onError(error) {
console.log('Error: ' + error);
}
downloadAsync('a.txt', function(error, a) {
if (error) return onError(error);
...
})
try...catch 语句在和异步 API 中典型的错误处理逻辑的一个实际差异是:try 语句使得定义一个“捕获所有”的逻辑很容易导致程序员忘记整个代码区的错误处理。而像上面给出的异步 API,我们非常容易忘记在进程的任意一步提供错误处理。通常,这将导致错误被默默丢弃。类似的,默认的错误也是调试的噩梦,因为其没有提供问题的线索。最好使用异步 API 时,保持警惕,确保明确地处理所有的错误状态条件。
总结
- 通过编写共享的错误处理函数来避免复制和粘贴错误处理代码。
- 确保明确地处理所有的错误条件以避免丢弃错误。
第 64 条:对异步循环使用递归
设想有一个函数接收一个 URL 的数组并尝试依次下载每个文件,直到有一个文件被成功下载。如果 API 是同步的,很容易使用一个循环来实现。
function downloadOneSync(urls) {
for (let i = 0, n = urls.length; i < n; i++) {
try {
return downloadSync(urls[i]);
} catch (e) {}
}
throw new Error('all downloads failed');
}
如果我们尝试使用循环,它将启动所有的下载,而不是等待一个完成再试下一个。因此我们要实现一个类似循环的东西,只有显式地说继续执行,它才会继续执行。解决方案是将循环实现为一个函数,我们可以决定何时开始每次迭代。
function downloadOneSync(urls, onsuccess, onfailure) {
const n = urls.length;
function tryNextURL(i) {
if (i >= n) {
onfailure('all downloads failed');
return;
}
downloadAsync(urls[i], onsuccess, function() {
tryNextURL(i + 1);
});
}
tryNextURL(0);
}
局部函数 tryNextURL 是一个递归函数,它实现调用了其自身,**而目前典型的 JavaScript 环境中一个递归函数同步调用自身过多次会导致失败。**因此当 n 太大时,递归的函数可能会执行失败,在此我们可以看下 JavaScript 执行简单的原理。
JavaScript 环境通常在内存中保存一块固定的区域,称为调用栈,用于记录函数调用返回前下一步该做什么,如下例子:
function negative(x) {
return abs(x) * -1;
}
function abs(x) {
return Math.abs(x);
}
console.log(negative(42));
这个调用信息遵循“先进后出”协议,最新的函数调用将信息推入栈(被表示为栈的最底层的帧),该信息也将首先从栈中弹出。当 Math.abs 执行完毕,将会返回给 abs 函数,其将返回给 negative 函数,然后将返回到最外面的脚本。
因此当一个程序执行中有太多的函数调用,它会耗尽栈空间,最终抛出异常,这种情况被称为栈溢出。
而实现的异步递归 downloadOneAsync 函数,不像同步函数递归调用返回后才会返回。异步递归函数只有异步回调函数中调用自身,记住异步 API 在其回调函数被调用前会立即返回。所有 downloadOneAsync 返回,导致其栈帧在任何递归调用将新的栈帧推入栈前,会从调用栈中弹出。所有无论其需要多少次迭代,都不会耗尽栈空间。
总结
- 循环不能是异步的。
- 使用递归函数在事件循环的单独轮次中执行迭代,避免栈溢出的风险。
- 在事件循环的单独轮次中执行递归,并不会导致调用栈溢出。
第 65 条:不要在计算时阻塞事件队列
即便没有一个函数调用也容易使一个应用程序陷入泥潭,代码需要时间来运行,而低效的算法或数据结构可能导致运行长时间的计算。
while (true) {}
为了保持客户端应用程序的高度交互性和确保所有传入的请求在服务器应用程序中得到充分服务,因此保持事件循环的每个轮次尽可能短是至关重要的。否则,事件队列会滞销,其增长速度会超过分发处理事件处理程序的速度。在浏览器环境中,一个页面的用户界面无响应多数是由于在运行 JavaScript 代码。
如果应用程序需要执行代码高昂的计算呢?最简单的方法是使用像 Web 客户端平台的 Worker API 这样的并发机制。这对于需要搜索大量可移动距离的人工智能游戏是一个很好的方法。
const ai = new Worker('ai.js');
这将使用 ai.js 源文件作为 worker 的脚本,产生一个新的线程独立的事件队列的并发执行线程。该 worker 运行在一个完全隔离的状态——没有任何应用程序对象的直接访问。而该 worker 和应用程序之间可以通过发送形式为字符串的 messages 来交互。
const userMove = /* ... */;
ai.postMessage(JSON.stringify({
userMove: userMove
}));
postMessage 的参数被作为一个消息增加到 worker 的事件队列中。应用程序为处理 worker 的响应会注册一个事件处理程序。
ai.onmessage = function(event) {
executeMove(JSON.parse(event.data).computerMove);
};
同时源文件 ai.js 指示 worker 监听消息并执行计算下一步移动所需的工作。
self.onmessage = function(event) {
// parse the user move
const userMove = JSON.parse(event.data).userMove;
// generate the next computer move
const computerMove = computeNextMove(userMove);
// format the computer move
const message = JSON. stringify({
computerMove: computerMove
});
self.postMessage(message);
};
不是所有 JavaScipt 平台都提供 Worker 独立线程的 API,并且传递消息的开销昂贵。另一种方法是将算法分解为多个步骤,每个步骤组成一个可管理的工作块。
Member.prototype.inNetwork = function(other) {
const vistied = {};
const worklist = {this};
while (worklist.length > 0) {
const member = worklist.pop();
// ... 执行所需操作
if (member === other) { // 找到成员
return true;
}
// ...
}
return false;
}
但这段代码核心的 while 循环代价太过高昂,搜索工作很可能会以不可接受的时间运行且阻塞应用程序事件队列。因此我们可以定义一个步骤集的算法——while 循环的迭代,可以通过增加一个回调参数将 inNetwork 转换为一个匿名函数。
Member.prototype.inNetwork = function(other, callback) {
const vistied = {};
const worklist = {this};
function next() {
if (worklist.length === 0) {
callback(false);
return;
}
const member = worklist.pop();
// ...
if (member === other) {
callback(true);
}
// ...
setTimeout(next, 0); // 定时下一次迭代
}
setTimeout(next, 0); // 定时初始化迭代
}
这样一来,每次迭代都是上一次完成的回调,不会阻塞事件队列,且有效地完成循环。
总结
- 避免在主事件队列中执行代码高昂的算法。
- 在支持 Worker API 的平台,该 API 可以用来在一个独立的事件队列中运行长计算程序。
- 在 Worker API 不可用或代码昂贵的环境中,考虑将计算程序分解到事件循环的多个轮次中。
第 66 条:使用计数器来执行并行操作
第 63 条建议使用工具函数 downloadAllAsync 接收一个 URL 数组并下载所有文件,结果返回一个存储了文件内容的数组,每个 URL 对应一个字符串。其采用了并行下载文件的方式,我们可以在同一个事件循环中一次启动所有文件的下载。
function downloadOneSync(urls, onsuccess, onfailure) {
const result = [], length = urls.length;
if (length === 0) {
setTimeout(onsuccess.bind(null, result), 0);
return;
}
urls.forEach(function(url) {
downloadAsync(url, function(text) {
if (result) {
// race condition
result.push(text);
if (result.length === urls.length) {
onsuccess(result);
}
}
}, function (error) {
if (result) {
result = null;
onerror(error);
}
})
})
}
我们观察其如何工作,先确保如果数组是空的,则会使用空结果数组调用回调函数。如果不这样做,这两个回调函数将不会被调用,因为 forEach 循环是空的。(第 67 条解释了为什么我们使用 setTimeout 函数来调用 onsuccess 回调函数,而不是直接调用 onsuccess)接下来遍历整个 URL 数组,为每个 URL 请求一个异步下载,成功则将内容加入数组中。如果所有 URL 都成功则用 result 数组调用 onsuccess 回调函数,若有失败则用错误值调用 onerror 回调。
其并行下载的问题在于,事件可以以任何的顺序发生。若我们希望以一种特定的顺序进行下载,但并行下载中注册到 downloadAllAsync 的回调函数并不会按照它们被创建的顺序进行调用。
第 48 条介绍了不确定性的概念。如果行为不可预知,则不能信赖程序中不确定的行为。因此,并行事件是 JavaScript 中不确定性的重要来源。具体来说,程序的执行顺序不能保证与事件发生的顺序一致。
例如 downloadAllAsync 使用者可能会对文件重新排序,基于的顺序是哪个文件可能会最先完成下载。
downloadAllAsync(filenames, function(files) {
console.log('Huge file:' + files[2].length);
console.log('Tiny file:' + files[0].length);
console.log('Medium file:' + files[1].length);
}, function (error) {
console.log('Error: ' + error);
});
在这种情况下大多时候结果是相同的顺序,但偶尔由于改变了服务器负载均衡或网络缓存,文件可能不是期望的顺序。这往往是诊断 Bug 的最大挑战,因为它们很难重现。当然,我们可以顺序下载文件,但却失去了并发的性能优势。
下面为我们实现 downloadAllAsync 不依赖不可预期的事件执行顺序而总能提供预期结果,存储在其原始的索引位置中。同时若是索引为 2 的属性内容,这将导致 result.length 被更新为 3,用户的 success 回调函数将被过早地调用,其结果数组可能不完整。
因此我们利用 pending 来实现一个计数器来追踪正在进行的操作数量。
function downloadOneSync(urls, onsuccess, onfailure) {
const result = [], pending = urls.length;
if (pending === 0) {
setTimeout(onsuccess.bind(null, result), 0);
return;
}
urls.forEach(function(url, i) {
downloadAsync(url, function(text) {
if (result) {
result[i] = (text); // 存储固定索引
if (--pending === 0) {
onsuccess(result);
}
}
}, function (error) {
if (result) {
result = null;
onerror(error);
}
})
})
}
现在不论事件以什么样的顺序发生,pending 计数器都能准确地指出何时所有的事件会被完成,并以适当的顺序返回完整的结果。
总结
- JavaScript 应用程序中的事件发生时不确定的,即顺序是不可预测的。
- 使用计数器避免并行操作中的数据竞争。
第 67 条:绝不要同步地调用异步的回调函数
设想 downloadAsync 函数它持有一个缓存来避免多次下载同一个文件。
const cache = new Dict();
function downloadCachingAsync(url, onsuccess, onerror) {
if (cache.has(url)) {
onsuccess(cache.get(url));
return;
}
return downloadAsync(url, function(file) {
cache.set(url, file);
onsuccess(file);
}, onerror);
}
这种方式似乎利用缓存在立即提供数据,但却以微妙的方式违反了异步 API 客户端的期待。首先,它改变了操作的预期顺序。
downloadAsync('file.txt', function(file) {
console.log('finished');
})
console.log('starting');
但若是文件已被缓存起来:
downloadCachingAsync('file.txt', function(file) {
console.log('finished'); // 可能先执行
})
console.log('starting');
异步 API 的目的是维持事件循环中每轮的严格分离,如 61 条中简化了并发,通过减轻每轮事件循环的代码量而不必担心其他代码并发地修改共享的数据结构。同步地调用异步的回调函数违反了这一分离,导致在当前轮完成之前,代码用于执行一轮隔离的事件循环。
同步的调用异步的回调函数甚至可能会导致一些微妙的问题。第 64 条解释了异步的回调函数本质上是以空的调用栈来调用,因此将异步的循环实现为递归函数是安全的。同步的调用不能保障这一点,因而使得一个表面上的异步循环很可能会耗尽调用栈空间。
另一个问题是异常,对于上面 downloadCachingAsync 实现,如果回调函数抛出一个异常,它将会在每轮的事件循环中,也就是开始下载时而不是期望的一个分离的回合中抛出该异常。
因此为了确保总是异步地调用回调函数,我们可以使用已存在的异步 API setTimeout 在每个一个最小的超时事件后给事件队列增加一个回调函数。当前可能有比 setTimeout 函数更完美的替代方案来调度即时事件,这取决于特定平台。
const cache = new Dict();
function downloadCachingAsync(url, onsuccess, onerror) {
if (cache.has(url)) {
const cached = cache.get(url);
setTimeout(onsuccess.bind(null, cached);)
return;
}
return downloadAsync(url, function(file) {
cache.set(url, file);
onsuccess(file);
}, onerror);
}
注意使用 bind 函数(25条)改变 this 的指向。
总结
- 即使可以立即得到数据,也绝不要同步地调用异步回调函数。
- 同步地调用异步的回调函数扰乱了预期的操作序列,并可能导致意想不到的交错代码。
- 同步地调用异步的回调函数可能导致栈溢出或错误地处理异常。
- 使用异步的 API,比如 setTimeout 函数来调度异步回调函数,使其运行于另一个回合,作用分离。
第 68 条:使用 Promise 模式清洁异步逻辑
构建异步 API 的一种流行的替代方式是使用 promise 模式,基于本章讨论的异步 API,相比之下基于 promise 的 API 不接收回调函数作为参数。相反,它返回一个 promise对象,该对象通过其自身的 then 方法接收回调函数。
const p = downloadP('file.txt');
p.then((file) => console.log('file:' + file));
并且 promise 模式还具有方法链的能力,其返回了一个新的 promise。
const fileP = downloadP('file.txt');
const lengthP = fileP.then((file) => file.length);
lengthP.then((file) => console.log('length: ' + length));
理解 promise 的一种方法是将它理解为表示最终值的对象,它封装了一个还未完成的并发操作,但最终会产生一个结果值。
使 promise 称为卓越的抽象层级的部分原因是通过 then 方法的返回值来联系结果,而不是在并行的回调函数间共享数据结构。这本质上是安全的,因为它避免了第 66 条中讨论过的数据竞争,例如在保存异步操作的结果到共享的变量或数据结构时犯下简单的错误。
const file1, file2;
downloadAsync('file1.txt', function(file) {
file1 = file;
});
downloadAsync('file2.txt', function(file) {
file1 = file; // 数据竞争
});
并且异步逻辑的有序链也可以使用有序的 promise,而不是在第 62 条中展现的笨重的嵌套模式。其错误处理也会自动地通过 promise 传播。
const p = downloadP('file.txt');
p.then((file) => console.log('file:' + file), function(error) {
console.log('error: ' + error);
});
但有时故意创建某些种类的数据竞争是有用的,如一个应用程序可能需要尝试从个多个不同的服务器上同时下载同一份文件,而选择最先完成的那个文件,也就是几个 promise 彼此竞争。
const fileP = select(downloadP('file1.txt'),
downloadP('file2.txt'),
downloadP('file3.txt'));
fileP.then((file) => console.log('file: ' + file));
总结
- promise 代表最终值,即并行操作完成时最终产生的结果。
- 使用 promise 组合不同的并行操作。
- 使用 promise 模式的 API 避免数据竞争。