深入探讨Node.js中的队列
文章译自: A deep dive into queues in Node.js[1],原作者 Dillion Megida 。
队列(queueing)是Node.js中用于有效处理异步操作的重要技术。
我们将深入了解Node.js中的队列:它们是什么,它们是如何工作的(与Event Loop一起),以及它们的各种类型。
What are queues in Node.js? (Node.js中的队列是什么?)
队列是Node.js中用来适当组织异步操作的数据结构。这些操作以不同的形式存在,包括HTTP请求、文件的读或写操作、流(stream)等等。
在Node.js中处理异步操作有时会是一个挑战。
在HTTP请求过程中,根据网络强度的不同,可能会出现不可预知的延迟(或者更糟的是,没有结果)。在尝试用Node.js读取或写入文件时,也可能会出现延迟,这取决于文件的大小。
与定时器(timers)和许多其他操作类似,异步操作完成所需要的时间也是不确定的。
对于这些耗时不同的操作,Node.js需要一种方式来有效地处理它们。
Node.js不能简单地按照 先开始-先处理 或 先完成-先处理 来搞。
这可能不是一个好的选择的原因之一是,一个异步操作可能包含另一个异步操作。
为第一个异步操作留有余地,意味着在考虑队列中的其他异步操作之前,必须先完成其内部的异步操作。(即影响了并行执行异步操作的效率)
有很多情况需要考虑,所以最好的选择是确立一个规则。这个规则会影响Node.js中Event Loop和队列的工作方式。
我们简单地看看Node.js如何处理异步操作。
The call stack, event loop, and callback queues (调用栈、事件循环和回调队列)
调用栈跟踪当前正在执行的函数,以及它从哪里运行。当一个函数即将被执行时,它就会被添加到调用栈中。
这有助于JavaScript在执行函数后回溯其步骤。
回调队列是指当异步操作在后台完成后,用来保存回调函数的队列。
它们以 先入先出(FIFO) 的方式工作。我们将在后面的内容中查看不同类型的回调队列。
请注意,Node.js负责每一个异步活动,因为JavaScript因其单线程的特性会阻塞线程。
它还负责在完成后台(异步)操作后向回调队列添加回调函数。JavaScript与回调队列无关。
同时,事件循环不断检查调用栈是否为空,以便从回调队列中拾取一个函数并添加到调用栈中。事件循环只有在所有同步操作执行完毕后才会检查队列。
那么,事件循环按照什么顺序从队列中选择回调函数呢?
首先,我们来看一下回调队列的五大类型。
Types of callback queues (回调队列的类型)
IO queue (IO队列)
IO操作是指涉及外部设备的操作。常见的包括读写文件操作、网络操作等。
这些操作应该是异步的,因为它们是留给Node.js处理的。JavaScript无法访问计算机的内部结构(computer’s internals)。
当要进行这样的操作时,JavaScript会将它们转交给Node.js,后者在后台进行处理。
后台任务结束后,它们(IO操作传入的回调函数)被添加到IO回调队列中,以便让事件循环将其转移到调用栈中执行。
Timer queue (定时器队列)
每一个涉及Node.js的定时器功能[2]的操作(比如setTimeout()
和setInterval()
)都会被添加到定时器队列中。
请注意,JavaScript本身没有定时器功能[3]。
它使用Node.js提供的定时器API(包括setTimeout
)来执行与时间相关的操作。为此,定时器操作是异步的。
无论是2秒还是0秒的计时时间,JavaScript都会将与时间相关的操作交给Node.js,然后完成这些操作并添加回调到定时器队列中。
例如:
setTimeout(function () {
console.log("setTimeout");
}, 0);
console.log("yeah");
运行结果:
yeah
setTimeout
当异步操作被处理时,JavaScript会继续进行其他操作。只有当所有同步操作处理完毕后,事件循环才会进入回调队列。
Microtask queue (微任务队列)
这个队列分成两个队列:
-
第一个队列存放的是被
process.nextTick
函数延迟的函数。事件循环执行的每一次迭代都被称为tick。
process.nextTick
是一个在下一个 tick(也就是事件循环的下一次迭代)执行函数的函数。微任务队列存储了这样的函数,以便在下一个tick上执行。这意味着事件循环在进入其他队列之前,必须不断检查微任务队列中是否有此类函数。
-
第二个队列存放被
Promise
所延迟的函数。在IO和Timer队列中,如我们所看到的,所有关于异步操作的事情都交给了异步函数。
Promises则不同。在promises中,一个初始变量被存储在了JavaScript内存中(你可能已经看到,
<Pending>
)。当异步操作完成后,Node.js会将该函数(附加在Promise上的)放入微任务队列中。同时,它用得到的结果更新JavaScript内存中的变量,这样该函数在调用时得到的就不是
<Pending>
的值了。以下代码解释了Promise的工作原理:
let prom = new Promise(function (resolve, reject) {
// delay execution
setTimeout(function () {
return resolve("hello");
}, 2000);
});
console.log(prom);
// Promise { <pending> }
prom.then(function (response) {
console.log(response);
});
// after 2000ms,
// hello
关于微任务队列,需要注意的一个重要特征是,事件循环在转向其他队列之前,会反复检查和执行微任务队列中的函数。
例如,当微任务队列完成后,比如说,一个定时器操作执行了一个Promise操作,事件循环会先运行这个Promise操作,然后再转到定时器队列中的其他函数。
因此,微任务队列比其他队列具有更高优先级。
Check queue (also known as immediate queue,检查队列)
该队列中的回调函数会在IO队列中的所有回调函数执行完毕后立即执行。
setImmediate
是用来给这个队列添加函数的函数。
例如:
const fs = require("fs");
setImmediate(function () {
console.log("setImmediate");
});
// assume this operation takes 1ms
fs.readFile("path-to-file", function () {
console.log("readFile");
});
// assume this operation takes 3ms
do...while...
当这个程序执行时,Node.js会将setImmediate
的回调函数添加到检查队列中。由于整个程序还没有完成,事件循环不会检查任何一个队列。
readFile
操作是异步的,所以交给Node.js,程序继续执行。
do while
操作持续3ms。在这段时间内,readFile
操作完成并被推送到IO队列中。完成该操作后,事件循环开始检查队列。
虽然检查队列先被填充,但只有在IO队列空了之后才会考虑。因此,readFile
会在setImmediate
之前被打印到控制台。
Close queue (关闭队列/结束队列)
该队列存储与关闭事件(close event)操作相关的函数。
例子有:
-
流关闭事件(stream close event),当流被关闭时发出。它表示不会再有事件发生。 -
http关闭事件(http close event),当服务器关闭时发出。
这些被认为是优先级最低的队列,因为这里的操作发生在较晚的时间。
你通常不会想在处理一个Promise函数之前,在关闭事件中执行一个回调函数。既然服务器已经关闭了,那个Promise还能做什么呢?
Order of the queues (各队列的优先级)
微任务队列的优先级最高,其次是定时器队列、I/O队列、检查队列,最后是关闭队列。
An example of callback queues (回调队列的一个例子)
我们看一个更大的例子来说明队列的类型和顺序(原文中毫秒和秒用混了,这里统一为毫秒):
const fs = require("fs");
// assume this operation takes a 2ms
fs.writeFile("./new-file.json", "...", function () {
console.log("writeFile");
});
// assume this takes 10ms to complete
fs.readFile("./file.json", function (err, data) {
console.log("readFile");
});
// don't assume, this actually takes 1ms
setTimeout(function () {
console.log("setTimeout");
}, 1);
// assume this operation takes 3ms
while(...) {
...
}
setImmediate(function () {
console.log("setImmediate");
});
// promise that takes 4ms to resolve
let promise = new Promise(function (resolve, reject) {
setTimeout(function () {
return resolve("promise");
}, 4);
});
promise.then(function (response) {
console.log(response);
});
console.log("last line");
你能预估出上述代码的打印顺序吗?
答案:
"last line"
"setTimeout"
"writeFile"
"setImmediate"
"promise"
"readFile"
如果需要解释,可在留言中说明或点击 阅读原文。
总结
JavaScript是单线程的。每一个异步函数都是由Node.js与计算机的内部功能协作处理的。
Node.js负责在回调队列中添加回调函数(由JavaScript追加到异步操作上的函数)。事件循环决定了每次迭代时下一步要执行的回调函数。
了解Node.js中的队列是如何工作的,可以让你更好地理解它,因为队列是该运行时的核心功能之一。对Node.js最流行的定义是非阻塞(non-blocking),这意味着异步操作会得到妥善处理。
而这正是通过事件循环和回调队列来实现的。
参考资料
A deep dive into queues in Node.js: https://blog.logrocket.com/a-deep-dive-into-queues-in-node-js/
[2]Node.js中定时器: https://nodejs.org/en/docs/guides/timers-in-node/
[3]JavaScript does not have a timer feature by itself: https://dillionmegida.com/p/browser-apis-and-javascript/#javascript-on-nodejs
原文始发于微信公众号(背井):【译】深入探讨Node.js中的队列
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/246873.html