JavaScript 并发:避免顺序陷阱
介绍
范围和限制
空闲执行
Promise.all
Promise.allSettled
单线程语言的注意事项
Promises 和 Worker 线程
结论
介绍
异步函数和回调函数是 JavaScript “单线程”并发模型的核心。当我们谈论 JavaScript 中的异步操作时,我们经常会听到不起眼却传奇般的事件循环背后精妙的工程设计。这样的讨论总是会引出一句必不可少的箴言:“永远不要阻塞事件循环。”
确实,阻塞事件循环是一种“致命的罪过”。事实上,(任何程序的)事件循环就像人类的心跳一样。如果心脏持续以稳定的节奏跳动,程序就能顺利运行。然而,如果某些阻塞扰乱了自然的节奏,那么一切都会开始崩溃。
范围和限制
在本系列文章中,我们将探讨优化异步操作执行的各种方法,但不会探讨操作本身。之所以必须进行这种区分,是因为优化“操作本身”意味着讨论特定于实现的细节和逻辑,而这些超出了本文的讨论范围。
相反,我们将专注于如何合理安排此类操作。目标是尽可能地利用并发。在某些情况下,异步操作的顺序执行是可以的,甚至是必要的,但为了最大限度地利用异步 JavaScript,我们必须最大限度地减少程序的“空闲”时刻。
空闲执行
当没有任何事物阻塞事件循环,而 JavaScript 程序仍在继续等待待处理的异步操作时,该程序被视为处于“空闲”状态。换句话说,当除了等待之外没有其他事情可做时,就会出现“空闲程序” 。让我们考虑以下示例:
免责声明: 为了简洁起见并便于演示,本文将通篇使用“顶级”一词。然而,正如
await
Rich Harris(Svelte和Rollup的创建者)所言,这会成为在生产环境中可靠执行顶级 JavaScript 的“绊脚石”。因此,不建议await
像以下示例中那样随意使用“顶级” 。
// Assuming that this network request takes one second to respond...
await fetch('https://example.com');
// Anything after this point is code that cannot be
// executed until the network request resolves.
console.log('This will run one second later.'):
等待异步代码完成的问题在于“空闲时间”,在此期间可以安排许多其他异步操作。
或者,也可以在此期间安排大量同步计算(例如,通过工作线程和Web 工作线程),以便当网络请求最终完成时,一切都已准备就绪、设置好、计算好并缓存好。
当然,如果即将进行的计算依赖于网络请求的结果,那么等待是完全必要的。在异步操作需要顺序执行的情况下,仍然必须努力减少程序的“空闲时间”。为了说明这一点,让我们考虑一个涉及文件系统的示例:
import fetch from 'node-fetch';
import { promises as fs } from 'fs';
import { promisify } from 'util';
const sleep = promisify(setTimeout);
async function purelySequential() {
// Let us assume that this file contains a single line
// of text that happens to be some valid URL.
const url = await fs.readFile('file.txt');
const response = await fetch(url);
// Execute some **unrelated** asynchronous
// opeartion here...
await sleep(2500);
return result;
}
上面的函数从文件中读取数据,然后将检索到的文本用作网络请求的 URL 输入。请求解析完成后,它将执行另一个异步操作,该操作至少需要2.5
几秒钟才能完成。
如果一切顺利,该函数的最小2.5
总执行时间为秒。由于该函数的顺序执行特性,任何少于此时间的情况都是不可能的。它必须先等待文件读取完成,然后才能初始化网络请求。由于我们必须处理await
请求fetch
,因此函数的执行会暂停,直到请求Promise
完成。所有这些异步操作都必须先解析,然后我们才能调度不相关的异步操作。
我们可以通过在等待文件读取和网络请求完成的同时调度后者操作来优化此函数。但是,必须重申的是,这只能在假设后者操作不依赖于上述异步操作的输出的情况下才有效。
import fetch from 'node-fetch';
import { promises as fs } from 'fs';
import { promisify } from 'util';
const sleep = promisify(setTimeout);
async function optimizedVersion() {
// Schedule the unrelated operation here. The removal of the
// `await` keyword tells JavaScript that the rest of the code can
// be executed without having to _wait_ for `operation` to resolve.
const operation = sleep(2500);
// Now that `operation` has been scheduled, we can
// now initiate the file read and the network request.
const url = await fs.readFile('file.txt');
const result = await fetch(url);
// Once the network request resolves, we can now wait for
// the pending `operation` to resolve.
await operation;
return result;
}
假设文件系统和网络交互速度很快,优化后的函数现在最长执行时间只需2.5
几秒。这真是个好消息!通过巧妙地调度异步操作,我们优化了代码,使其能够并发运行。
为了真正说明这一点,下面的示例使用sleep
效用函数演示了所讨论的模式:
import { promisify } from 'util';
const sleep = promisify(setTimeout);
console.time('Sequential');
await sleep(1000);
await sleep(2000);
console.timeEnd('Sequential');
console.time('Optimized');
const operation = sleep(2000);
await sleep(1000);
await operation;
console.timeEnd('Optimized');
// Sequential: ~3.0 seconds ❌
// Optimized: ~2.0 seconds ✔
Promise.all
在多个异步操作无需顺序执行的情况下,我们可以充分利用 JavaScript 的并发模型Promise.all
。简单回顾一下,Promise.all
接受一个 Promise 数组,然后返回一个包装该数组的 Promise。一旦原始数组中的所有Promise都成功解析,Promise.all
就会返回一个包含结果的数组。
const promises = [
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3),
];
const results = Promise.all(promises);
// [ 1, 2, 3 ]
console.log(await results);
假设所有 Promise 都保证能够解决,那么调度一系列并发 Promise 就具有了独特的优势。让我们考虑以下示例:
/**
* This function runs three independent operations sequentially.
* Even if each operation is independent from each other, it makes
* the mistake of running one after the other as if they were
* dependent. In this case, the "idle time" is unnecessary and
* extremely wasteful.
*/
async function sequential() {
await sleep(2000);
await sleep(3000);
await sleep(4000);
}
/**
* This function runs all of the operations concurrently.
* `Promise.all` automatically schedules all of the
* promises in the given array. By the time they all
* resolve, `Promise.all` can safely return the array
* of resolved values (if applicable).
*/
async function concurrent() {
await Promise.all([
sleep(2000),
sleep(3000),
sleep(4000),
]);
}
// **TOTAL EXECUTION TIMES**
// Sequential: ~9.0 seconds ❌
// Concurrent: ~4.0 seconds ✔
Promise.allSettled
然而,有时我们无法保证 Promise 一定成功。更多的时候,我们需要处理错误。这时,newPromise.allSettled
就派上用场了。
顾名思义, 的Promise.allSettled
行为与 类似Promise.all
。两者的主要区别在于它们处理 Promise 拒绝的方式。对于Promise.all
,如果输入数组中的任何Promise 失败,它将立即终止进一步的执行并抛出被拒绝的 Promise ,无论其他 Promise 是否成功。
const results = Promise.all([
Promise.resolve(1),
Promise.reject(2),
Promise.resolve(3),
]);
// 2
console.error(await results);
这种方法的问题在于其“快速失败”特性。如果我们在出现错误的情况下仍然想要获取已解析 Promise 的值怎么办?这正是它的Promise.allSettled
亮点所在。与其使用“快速失败”机制,不如通过将已解析的 Promise 标记为或 来Promise.allSettled
将其与已拒绝的 Promise 隔离开来。这样,我们就可以处理已解析的值,同时仍然能够处理任何错误。'fulfilled'
'rejected'
const results = Promise.allSettled([
Promise.resolve(1),
Promise.resolve(2),
Promise.reject(3),
]);
// [
// { status: 'fulfilled', value: 1 },
// { status: 'fulfilled', value: 2 },
// { status: 'rejected', reason: 3 },
// ]
console.log(await results);
单线程语言的注意事项
在整篇文章中,我对“异步操作”这个术语非常谨慎。许多 JavaScript 开发者(包括我自己)第一次听说 ES6 Promise 的奇妙之处时,都误以为 JavaScript 突然就“多线程”了。
Promise 使我们能够并发运行多个异步操作,从而产生“并行执行”的假象。然而,“自由并行”与事实相去甚远。
I/O操作
在 JavaScript 中,区分输入输出 (I/O) 操作和CPU 密集型任务非常重要。I /O 操作(例如网络和文件系统交互)需要程序等待数据准备好才能使用。但是,这并不一定会“阻塞”程序的执行。在等待 I/O 操作完成时,程序仍然可以执行其他代码。程序也可以选择阻塞自身并轮询数据。
例如,一个程序可能会要求操作系统读取某个文件。操作系统命令硬盘“旋转一些磁盘”并“翻转一些位”,直到文件完全读取完毕。与此同时,程序继续执行并计算圆周率 (π) 的数字。一旦文件可用,程序就会使用其中的数据。
考虑到这个例子,这就是为什么我一直谨慎使用“调度”这个词。JavaScript 中的异步操作通常意味着 I/O 操作和超时。当我们fetch
请求资源时,我们会调度一个请求并等待数据可用。一旦请求被调度,我们就让操作系统“做它的事情”,以便程序中的其他代码可以同时执行,这就是 Node.js 的核心原则“非阻塞 I/O”。
CPU密集型任务
另一方面,CPU 密集型任务会因为计算成本高昂而阻塞程序的执行。这通常意味着冗长的搜索算法、排序算法、正则表达式求值、文本解析、压缩、加密以及各种数学计算。
在某些情况下,I/O 操作也会阻塞程序。然而,这通常是经过深思熟虑的设计选择。Node.js 通过*-Sync
函数为某些 I/O 操作提供了同步替代方案。尽管如此,这些同步活动仍然是必要的开销。
然而,问题就在这里:同步性是必要的。为了解决这个问题,计算机科学领域的伟大人物提出了“多线程系统”的概念,使代码可以并行运行。通过将计算工作转移到多个线程上,计算机在处理 CPU 密集型任务时变得更加高效。
尽管 JavaScript 拥有多线程的潜力,但它被明确设计为单线程,因为编写“安全”且“正确”的多线程代码极其困难。对于 Web 来说,为了安全性和可靠性,这是一个合理的权衡。
对 Promises 的误解
当 ES6 的 Promise 出现时,人们忍不住想把所有东西都“Promise 化”。Promise 给人一种 JavaScript 在某种程度上是“多线程”的错觉。JavaScript 运行时(例如 Node.js 和浏览器)确实是多线程的,但不幸的是,这并不意味着 JavaScript(这门语言)本身就超越了“单线程”。
实际上,Promise 仍然同步执行代码,尽管执行时间稍晚。与直觉和理想主义相反,将 CPU 密集型任务转移到 Promise 上并不会神奇地生成一个新线程。Promise 的目的不是为了促进并行,而是为了将代码的执行推迟到某些数据被解析或拒绝之后。
这里的关键词是“延迟”。通过延迟执行,任何计算量大的任务仍然不可避免地会阻塞程序的执行——前提是到那时数据已经准备好被使用。
// This promise will still block the event loop.
// It will **not** execute this in parallel.
new Promise(resolve => {
calculateDigitsOfPi();
mineForBitcoins();
renderSomeGraphcs();
doSomeMoreMath();
readFileSync('file.txt');
resolve();
});
Promises 和 Worker 线程
如前所述,Promise 的主要用途是将代码的执行推迟到请求的数据准备好被使用为止。Promise 意味着异步 I/O 操作的调度最终会被解决,但这并不意味着 CPU 密集型任务的并行性。
如果应用程序确实需要并行执行 CPU 密集型任务,那么最好的方法是在浏览器中使用Web Worker 。在 Node.js 中,工作线程是等效的 API。
如果需要多个异步 I/O 操作和超时的并发,承诺和事件是完成这项工作的最佳工具。
如果使用不当,Promise 中的 CPU 密集型任务会阻塞事件循环。相反,将多个 I/O 操作分散到多个后台工作线程中既多余又浪费。如果仅为了执行 I/O 操作而手动创建一个新线程,则会导致该线程在请求数据到达之前的大部分时间都处于空闲状态。
深入研究实现细节的技术部分,一个设计良好的 JavaScript 运行时已经处理并抽象了I/O 操作的多线程方面。这使得前面提到的对工作线程的滥用变得“多余”。
此外,在 Node.js 中,每个后台线程在工作池中只占用一个槽位。鉴于工作池中的线程数量有限,高效的线程管理对于 Node.js 的并发运行能力至关重要。否则,冗余地创建工作线程会严重影响有限的工作池资源。
因此,由于 I/O 操作待处理而导致工作线程处于空闲状态不仅浪费资源,而且毫无必要。更好的做法是让 JavaScript 运行时自行处理 I/O 操作。
结论
如果说本文有什么值得学习的地方,那就是I/O 操作和 CPU 密集型任务之间的区别。通过了解它们的用例,我们可以自信地找到最大化 JavaScript 并发性的正确工具。
I/O 操作本质上会延迟代码执行,直到某些数据准备就绪。因此,我们必须利用 Promise、事件和回调模式来调度请求。通过合理编排 I/O 操作,我们可以保持事件循环运行,同时仍然能够并发处理异步代码。
另一方面,CPU 密集型任务不可避免地会阻塞程序的执行。明智地将这些同步操作卸载到单独的后台工作线程是实现并行性的可靠方法。然而,仍然需要注意占用工作池中的资源所带来的开销和隐性成本。
一般来说,Promise 用于 I/O 操作,而工作线程用于 CPU 密集型任务。利用这些核心概念可以帮助我们避免顺序“阻塞”代码的陷阱。
文章来源:https://dev.to/somedood/javascript-concurrency-avoiding-the-sequential-trap-7f0