深入解析 Node.js 的回调队列
队列是 Node.js 中用于有效处置异步操纵的一项重要技术。【视频教程引荐:node js教程 】
在本文中,我们将深入研讨 Node.js 中的队列:它们是啥,它们怎样工作(通过事件轮回)乃至它们的类型。
Node.js 中的队列是啥?
队列是 Node.js 中用于组织异步操纵的数据构造。这些操纵以不一样的情势存在,包罗HTTP恳求、读取或写入文件操纵、流等。
在 Node.js 中处置异步操纵非常具有挑衅性。
HTTP 恳求期间大概会显现不成猜测的延迟(或者更糟糕的大概性是没有结果),详细取决于网络质量。尝试用 Node.js 读写文件时也有大概会发生延迟,详细取决于文件的大小。
相似于计时器和其他的很多操纵,异步操纵完成的时间也有大概是不肯定的。
在这些不一样的延迟状况之下,Node.js 需要能够有效地处置所有这些操纵。
Node.js 没法处置基于 first-start-first-handle (先开端先处置)或 first-finish-first-handle (先完毕先处置)的操纵。
之所以不克不及这样做的一个缘由是,在一个异步操纵中大概还会包括另一个异步操纵。
为第一个异步历程留出空间意味着必需先要完成内部异步历程,然后才能思考队列中的其他异步操纵。
有很多状况需要思考,因此最好的选中是拟定规则。这个规则影响了事件轮回和队列在 Node.js 中的工作方式。
让我们简要地看一下 Node.js 是怎样处置异步操纵的。
调取栈,事件轮回和回调队列
调取栈被用于跟踪当前正在施行的函数乃至从何处开端运转。当一个函数将要施行时,它会被增加到调取堆栈中。这有助于 JavaScript 在施行函数后从新跟踪其处置步骤。
回调队列是在后台操纵完成时把回调函数留存为异步操纵的队列。它们以先进先出(FIFO)的方式工作。我们将会在本文后面介绍不一样类型的回调队列。
请留意,Node.js 负责所有异步活动,由于 JavaScript 可以利用其单线程性质来阻挠发生新的线程。
在完成后台操纵后,它还负责向回调队列增加函数。 JavaScript 本身与回调队列无关。同时事件轮回会持续检查调取栈可否为空,以便可以从回调队列中提取一个函数并增加到调取栈中。事件轮回仅在施行所有同步操纵之后才检查队列。
那么,事件轮回是依照什么样的次序从队列中选中回调函数的呢?
第一,让我们看一下回调队列的五种主要类型。
回调队列的类型
IO 队列(IO queue)
IO操纵是指触及外部设备(如运算机的硬盘、网卡等)的操纵。常见的操纵包罗读写文件操纵、网络操纵等。这些操纵应当是异步的,由于它们留给 Node.js 处置。
JavaScript 没法拜访运算机的内部设备。当施行此类操纵时,JavaScript 会将其传输到 Node.js 以在后台处置。
完成后,它们将会被转移到 IO 回调队列中,来停止事件轮回,以转移到调取栈中施行。
计时器队列(Timer queue)
每个触及 Node.js 计时器功效的操纵(如 setTimeout()
和 setInterval()
)都是要被增加到计时器队列的。
请留意,JavaScript 说话本身没有计时器功效。它使用 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 施行它们。
这意味着事件轮回必需连续检查微任务队列中的此类函数,然后再进入其他队列。
- 第二个队列包括因
promises
而延迟的函数。
如你所见,在 IO 和计时器队列中,所有与异步操纵有关的内容都被移交给了异步函数。
但是 promise 不一样。在 promise 中,初始变量储备在 JavaScript 内存中(你大概已经留意到了<Pending>
)。
异步操纵完成后,Node.js 会将函数(附加到 Promise)放在微任务队列中。同时它用得到的结果来更新 JavaScript 内存中的变量,以使该函数不与 <Pending>
一起运转。
以下代码说明了 promise 是怎样工作的:
let prom = new Promise(function (resolve, reject) { // 延迟施行 setTimeout(function () { return resolve("hello"); }, 2000); }); console.log(prom); // Promise { <pending> } prom.then(function (response) { console.log(response); }); // 在 2000ms 之后,输出 // hello
关于微任务队列,需要留意一个重要功效,事件轮回在进入其他队列此前要重复检查并施行微任务队列中的函数。例如,当微任务队列完成时,或者说计时器操纵施行了 Promise 操纵,事件轮回将会在连续进入计时器队列中的其他函数此前参与该 Promise 操纵。
因此,微任务队列比其他队列具有最高的优先级。
检查队列(Check queue)
检查队列也称为即时队列(immediate queue)。IO 队列中的所有回调函数均已施行完毕后,马上施行此队列中的回调函数。setImmediate
用于向该队列增加函数。
例如:
const fs = require('fs'); setImmediate(function() { console.log('setImmediate'); }) // 假设此操纵需要 1ms fs.readFile('path-to-file', function() { console.log('readFile') }) // 假设此操纵需要 3ms do...while...
施行该程序时,Node.js 把 setImmediate
回调函数增加到检查队列。由于整个程序尚未预备完毕,因此事件轮回不会检查任何队列。
由于 readFile
操纵是异步的,所以会移交给 Node.js,之后程序将会连续施行。
do while
操纵连续 3ms。在这段时间内,readFile
操纵完成并被推送到 IO 队列。完成此操纵后,事件轮回将会开端检查队列。
尽管第一填充了检查队列,但只要在 IO 队列为空之后才思考使用它。所以在 setImmediate
此前,将 readFile
输出到操纵台。
关闭队列(Close queue)
此队列储备与关闭事件操纵关联的函数。
包罗以下内容:
- 流关闭事件,在关闭流时发出。它表示不再发出任何事件。
- http关闭事件,在效劳器关闭时发出。
这些队列被认为是优先级最低的,由于此处的操纵会在今后发生。
你肯sing不但愿在处置 promise 函数此前在 close 事件中施行回调函数。当效劳器已经关闭时,promise 函数会做些什么呢?
队列次序
微任务队列具有最高优先级,其次是计时器队列,I/O队列,检查队列,最后是关闭队列。
回调队列的例子
让我们通过一个更复杂的例子来说明队列的类型和次序:
const fs = require("fs"); // 假设此操纵需要 2ms fs.writeFile('./new-file.json', '...', function() { console.log('writeFile') }) // 假设这需要 10ms 才能完成 fs.readFile("./file.json", function(err, data) { console.log("readFile"); }); // 不需要假设,这实际上需要 1ms setTimeout(function() { console.log("setTimeout"); }, 1000); // 假设此操纵需要 3ms while(...) { ... } setImmediate(function() { console.log("setImmediate"); }); // 解决 promise 需要 4 ms let promise = new Promise(function (resolve, reject) { setTimeout(function () { return resolve("promise"); }, 4000); }); promise.then(function(response) { console.log(response) }) console.log("last line");
程序流程如下:
- 在 0 毫秒时,程序开端。
- 在 Node.js 将回调函数增加到 IO 队列此前,
fs.writeFile
在后台花费 2 毫秒。
fs.readFile
takes 10ms at the background before Node.js adds the callback function to the IO queue.
- 在 Node.js 将回调函数增加到 IO 队列此前,
fs.readFile
在后台花费 10 毫秒。 - 在 Node.js 将回调函数增加到计时器队列此前,
setTimeout
在后台花费 1ms。 - 此刻,while 操纵(同步)需要 3ms。在此期间,线程被阻挠(请记住 JavaScript 是单线程的)。
- 一样在这段时间内,
setTimeout
和fs.writeFile
操纵完成,并将它们的回调函数离别增加到计时器和 IO 队列中。
此刻的队列是:
// queues Timer = [ function () { console.log("setTimeout"); }, ]; IO = [ function () { console.log("writeFile"); }, ];
setImmediate
将回调函数增加到 Check 队列中:
js // 队列 Timer... IO... Check = [ function() {console.log("setImmediate")} ]
在将 promise 操纵增加到微任务队列此前,需要花费 4ms 的时间在后台停止解析。
最后一行是同步的,因此将会马上施行:
# 返回 "last line"
由于所有同步活动都已完成,所以事件轮回开端检查队列。由于微任务队列为空,因此它从计时器队列开端:
// 队列 Timer = [] // 此刻是空的 IO... Check... # 返回 "last line" "setTimeout"
当事件轮回连续施行队列中的回调函数时,promise
操纵完成并被增加到微任务队列中:
// 队列 Timer = []; Microtask = [ function (response) { console.log(response); }, ]; IO = []; // 当前是空的 Check = []; // 当前是在 IO 的后面,为空 # results "last line" "setTimeout" "writeFile" "setImmediate"
几秒钟后,readFile
操纵完成,并增加到 IO 队列中:
// 队列 Timer = []; Microtask = []; // 当前是空的 IO = [ function () { console.log("readFile"); }, ]; Check = []; # results "last line" "setTimeout" "writeFile" "setImmediate" "promise"
最后,施行所有回调函数:
// 队列 Timer = [] Microtask = [] IO = [] // 此刻又是空的 Check = []; # results "last line" "setTimeout" "writeFile" "setImmediate" "promise" "readFile"
这里要留意的三点:
- 异步操纵取决于增加到队列此前的延迟时间。并不取决于它们在程序中的存置次序。
- 事件轮回在每次迭代之连续检查其他任务此前,会持续检查微任务队列。
- 即便在后台有另一个 IO 操纵(
readFile
),事件轮回也会施行检查队列中的函数。这样做的缘由是此时 IO 队列为空。请记住,在施行 IO 队列中的所有的函数之后,将会马上运转检查队列回调。
总结
JavaScript 是单线程的。每个异步函数都由依靠操纵系统内部函数工作的 Node.js 去处置。
Node.js 负责将回调函数(通过 JavaScript 附加到异步操纵)增加到回调队列中。事件轮回会肯定将要在每次迭代中接下来要施行的回调函数。
理解队列怎样在 Node.js 中工作,使你对其有了更好的理解,由于队列是环境的中心功效之一。 Node.js 最受欢迎的定义是 non-blocking
(非堵塞),这意味着异步操纵可以被准确的处置。都是由于有了事件轮回和回调队列才能使此功效生效。