Node.js is a single-threaded language that handles non-blocking I/O operations through an event loop. The following article will give you a detailed understanding of the event loop inNode. I hope it will be helpful to you!
Node.jsAs a JavaScript server runtime, it mainly deals with the network and files, without the rendering phase of the event loop in the browser. .
There is an HTML specification in the browser to define the event loop processing model, which is then implemented by each browser manufacturer. The definition and implementation of the event loop in Node.js come from Libuv.
Libuv is designed around the event-driven asynchronous I/O model and was originally written for Node.js, providing a cross-platform support library. The figure below shows its components. Network I/O is the part related to network processing. There are also file operations and DNS on the right side. At the bottom, epoll, kqueue, event ports, and IOCP are the implementations of different underlying operating systems.
When Node.js starts, it initializes the event loop and processes the provided script , the synchronous code is pushed onto the stack and executed directly. Asynchronous tasks (network requests, file operations, timers, etc.) will transfer the operation to the background for processing by the system kernel after calling the API and passing the callback function. Most current kernels are multi-threaded. When one of the operations is completed, the kernel notifies Node.js to add the callback function to the polling queue and wait for the opportunity to execute.
The left side of the picture below is the description of the event loop process on the Node.js official website, and the right side is the description of Node.js on the Libuv official website. They are both introductions to the event loop. Not everyone can see it. Source code. These two documents are usually more direct reference documents for learning about the event loop. The introduction on the Node.js official website is quite detailed and can be used as a reference for learning.
The event loop displayed on the Node.js official website on the left is divided into 6 stages. Each stage has a FIFO (first in, first out) queue to execute the callback function. The priority order of execution between stages is still clear.
The right side describes it in more detail. Before iterating the event loop, first determine whether the loop is in an active state (there are waiting asynchronous I/O, timers, etc.). If it is in an active state, start iteration, otherwise The loop will exit immediately.
Each stage is discussed separately below.
First the event loop enters the timer phase, which contains two APIs setTimeout(cb, ms) and setInterval(cb, ms ) The former one is executed only once, and the latter one is executed repeatedly.
This stage checks whether there is an expired timer function. If there is, the expired timer callback function is executed. Just like in the browser, the delay time passed in by the timer function is always longer than we expected. Later, it will be affected by the operating system or other running callback functions.
For example, in the following example we set a timer function and expect it to execute after 1000 milliseconds.
const now = Date.now(); setTimeout(function timer1(){ log(`delay ${Date.now() - now} ms`); }, 1000); setTimeout(function timer2(){ log(`delay ${Date.now() - now} ms`); }, 5000); someOperation(); function someOperation() { // sync operation... while (Date.now() - now < 3000) {} }
After calling the setTimeout asynchronous function, the program then executes the someOperation() function. Some time-consuming operations in the middle consume about 3000ms. After completing these synchronous operations, it enters an event loop and first checks the timer phase. Whether there is an expired task, the timer script is stored in the heap memory in ascending order of delay time. First, the timer function with the smallest timeout period is taken out for checking. IfnowTime - timerTaskRegisterTime > delay, the callback function is taken out Execute, otherwise continue checking. When a timer function that has not expired is detected or the maximum number of system dependencies is reached, move to the next stage.
In our example, assume that the current time after executing someOperation() function is T 3000:
Check the timer1 function, the current time is T 3000 - T > 1000, which is beyond expectations Delay time, take out the callback function to execute, and continue checking.
Check the timer2 function, the current time is T 3000 - T < 5000, which has not reached the expected delay time, and exits the timer stage at this time.
After the timer phase is completed, the event loop enters the pending callbacks phase, in which the I/O callbacks left over from the previous round of event loop are executed. According to the Libuv documentation: In most cases, all I/O callbacks are called immediately after polling for I/O, however, in some cases, calling such callbacks is deferred until the next loop iteration. After listening to it, it feels more like a legacy from the previous stage.
idle, prepare 阶段是给系统内部使用,idle 这个名字很迷惑,尽管叫空闲,但是在每次的事件循环中都会被调用,当它们处于活动状态时。这一块的资料介绍也不是很多。略...
poll 是一个重要的阶段,这里有一个概念观察者,有文件 I/O 观察者,网络 I/O 观察者等,它会观察是否有新的请求进入,包含读取文件等待响应,等待新的 socket 请求,这个阶段在某些情况下是会阻塞的。
在阻塞 I/O 之前,要计算它应该阻塞多长时间,参考 Libuv 文档上的一些描述,以下这些是它计算超时时间的规则:
如果循环使用 UV_RUN_NOWAIT 标志运行、超时为 0。
如果循环将要停止(uv_stop() 被调用),超时为 0。
如果没有活动的 handlers 或 request,超时为 0。
如果有任何 idle handlers 处于活动状态,超时为 0。
如果有任何待关闭的 handlers,超时为 0。
如果以上情况都没有,则采用最近定时器的超时时间,或者如果没有活动的定时器,则超时时间为无穷大,poll 阶段会一直阻塞下去。
很简单的一段代码,我们启动一个 Server,现在事件循环的其它阶段没有要处理的任务,它会在这里等待下去,直到有新的请求进来。
const http = require('http'); const server = http.createServer(); server.on('request', req => { console.log(req.url); }) server.listen(3000);
结合阶段一的定时器,在看个示例,首先启动 app.js 做为服务端,模拟延迟 3000ms 响应,这个只是为了配合测试。再运行 client.js 看下事件循环的执行过程:
首先程序调用了一个在 1000ms 后超时的定时器。
之后调用异步函数 someAsyncOperation() 从网络读取数据,我们假设这个异步网路读取需要 3000ms。
当事件循环开始时先进入 timer 阶段,发现没有超时的定时器函数,继续向下执行。
期间经过 pending callbacks -> idle,prepare 当进入 poll 阶段,此时的 http.get() 尚未完成,它的队列为空,参考上面 poll 阻塞超时时间规则,事件循环机制会检查最快到达阀值的计时器,而不是一直在这里等待下去。
当大约过了 1000ms 后,进入下一次事件循环进入定时器,执行到期的定时器回调函数,我们会看到日志 setTimeout run after 1003 ms。
在定时器阶段结束之后,会再次进入 poll 阶段,继续等待。
// client.js const now = Date.now(); setTimeout(() => log(`setTimeout run after ${Date.now() - now} ms`), 1000); someAsyncOperation(); function someAsyncOperation() { http.get('http://localhost:3000/api/news', () => { log(`fetch data success after ${Date.now() - now} ms`); }); } // app.js const http = require('http'); http.createServer((req, res) => { setTimeout(() => { res.end('OK!') }, 3000); }).listen(3000);
当 poll 阶段队列为空时,并且脚本被 setImmediate() 调度过,此时,事件循环也会结束 poll 阶段,进入下一个阶段 check。
check 阶段在 poll 阶段之后运行,这个阶段包含一个 API setImmediate(cb) 如果有被 setImmediate 触发的回调函数,就取出执行,直到队列为空或达到系统的最大限制。
拿 setTimeout 和 setImmediate 对比,这是一个常见的例子,基于被调用的时机和定时器可能会受到计算机上其它正在运行的应用程序影响,它们的输出顺序,不总是固定的。
setTimeout(() => log('setTimeout')); setImmediate(() => log('setImmediate')); // 第一次运行 setTimeout setImmediate // 第二次运行 setImmediate setTimeout
但是一旦把这两个函数放入一个 I/O 循环内调用,setImmediate 将总是会被优先调用。因为 setImmediate 属于 check 阶段,在事件循环中总是在 poll 阶段结束后运行,这个顺序是确定的。
fs.readFile(__filename, () => { setTimeout(() => log('setTimeout')); setImmediate(() => log('setImmediate')); })
在 Libuv 中,如果调用关闭句柄 uv_close(),它将调用关闭回调,也就是事件循环的最后一个阶段 close callbacks。
这个阶段的工作更像是做一些清理工作,例如,当调用 socket.destroy(),'close' 事件将在这个阶段发出,事件循环在执行完这个阶段队列里的回调函数后,检查循环是否还 alive,如果为 no 退出,否则继续下一次新的事件循环。
包含 Microtask 的事件循环流程图
在浏览器的事件循环中,把任务划分为 Task、Microtask,在 Node.js 中是按照阶段划分的,上面我们介绍了 Node.js 事件循环的 6 个阶段,给用户使用的主要是 timer、poll、check、close callback 四个阶段,剩下两个由系统内部调度。这些阶段所产生的任务,我们可以看做 Task 任务源,也就是常说的 “Macrotask 宏任务”。
通常我们在谈论一个事件循环时还会包含 Microtask,Node.js 里的微任务有 Promise、还有一个也许很少关注的函数 queueMicrotask,它是在 Node.js v11.0.0 之后被实现的,参见 PR/22951。
Node.js 中的事件循环在每一个阶段执行后,都会检查微任务队列中是否有待执行的任务。
Node.js 在 v11.x 前后,每个阶段如果即存在可执行的 Task 又存在 Microtask 时,会有一些差异,先看一段代码:
setImmediate(() => { log('setImmediate1'); Promise.resolve('Promise microtask 1') .then(log); }); setImmediate(() => { log('setImmediate2'); Promise.resolve('Promise microtask 2') .then(log); });
在 Node.js v11.x 之前,当前阶段如果存在多个可执行的 Task,先执行完毕,再开始执行微任务。基于 v10.22.1 版本运行结果如下:
setImmediate1 setImmediate2 Promise microtask 1 Promise microtask 2
在 Node.js v11.x 之后,当前阶段如果存在多个可执行的 Task,先取出一个 Task 执行,并清空对应的微任务队列,再次取出下一个可执行的任务,继续执行。基于 v14.15.0 版本运行结果如下:
setImmediate1 Promise microtask 1 setImmediate2 Promise microtask 2
在 Node.js v11.x 之前的这个执行顺序问题,被认为是一个应该要修复的 Bug 在 v11.x 之后并修改了它的执行时机,和浏览器保持了一致,详细参见 issues/22257 讨论。
Node.js 中还有一个异步函数 process.nextTick(),从技术上讲它不是事件循环的一部分,它在当前操作完成后处理。如果出现递归的 process.nextTick() 调用,这将会很糟糕,它会阻断事件循环。
如下例所示,展示了一个 process.nextTick() 递归调用示例,目前事件循环位于 I/O 循环内,当同步代码执行完成后 process.nextTick() 会被立即执行,它会陷入无限循环中,与同步的递归不同的是,它不会触碰 v8 最大调用堆栈限制。但是会破坏事件循环调度,setTimeout 将永远得不到执行。
fs.readFile(__filename, () => { process.nextTick(() => { log('nextTick'); run(); function run() { process.nextTick(() => run()); } }); log('sync run'); setTimeout(() => log('setTimeout')); }); // 输出 sync run nextTick
将 process.nextTick 改为 setImmediate 虽然是递归的,但它不会影响事件循环调度,setTimeout 在下一次事件循环中被执行。
fs.readFile(__filename, () => { process.nextTick(() => { log('nextTick'); run(); function run() { setImmediate(() => run()); } }); log('sync run'); setTimeout(() => log('setTimeout')); }); // 输出 sync run nextTick setTimeout
process.nextTick 是立即执行,setImmediate 是在下一次事件循环的 check 阶段执行。但是,它们的名字着实让人费解,也许会想这两个名字交换下比较好,但它属于遗留问题,也不太可能会改变,因为这会破坏 NPM 上大部分的软件包。
在 Node.js 的文档中也建议开发者尽可能的使用 setImmediate(),也更容易理解。
更多node相关知识,请访问:nodejs 教程!!
The above is the detailed content of An article to learn more about the event loop in Node. For more information, please follow other related articles on the PHP Chinese website!