前言
本文为我的 Node.js 系列文章的第二篇,最终会整理所有文章为 pdf 文档(有很多同学在关注我的react组件库进度,目前会利用周末迭代,欢迎共建)
本文主要依据以下的Node.js架构图, 然后挖掘其中涉及的3个知识点,其中一个是对 event loop的详解,参考了多篇国外一手的 Node.js 大牛的文章,整体来说,比网上很多东拉西扯拼凑的文章质量要高不少,对于面试推荐阅读,感谢点赞和收藏:
首先,大多数网上的文章在描述上图的左半部分流程的时候模糊不清,所以不得不自己去查询很多资料去弄清楚以下的疑问:
- 比如 fs 模块,在创建文件的时候,javascript 的语言标准里是没有相关标准的,那么需要借助 V8引擎的 c++ 的能力来完成。
问题就来了
- javascript 代码是如何跟 c++ 代码交互的?
- 上图中我们可以看到,经过编译的 javascript 代码调用了 node bindings, 问题是为什么要经过node bindings?
所以接下来我们通过实际代码案例,来了解一下上图左半部分的过程,
示例代码
javascript
const fs = require("fs")
fs.writeFile("./test.txt", "text");
上面引入 fs 模块,是用来把文本写入到文件的模块。其中调用了 writeFile 函数,也就是把文字 text 写入到了当前目录的 test.txt 文件。
好了,我们结合 Node.js 的架构图,来看看第一部分
第一部分:从业务代码到V8引擎的编译
require fs模块,其实调用的是node的内置fs模块,这个模块包含js部分和c++部分,也就是完成如下的转换:
其中 writeFile 的源码地址链接在这里
javascript
function writeFile(path, data, options, callback) {
...
if (isFd(path)) {
const isUserFd = true;
writeAll(path, isUserFd, data, 0, data.byteLength, callback);
return;
}
fs.open(path, flag, options.mode, (openErr, fd) => {
if (openErr) {
callback(openErr);
} else {
const isUserFd = false;
writeAll(fd, isUserFd, data, 0, data.byteLength, callback);
}
});
}
以上代码最终调用了 writeAll 函数,如下然后我们又去找writeAll函数的源码
发现其实它调用的是fs.write函数
javascript
function writeAll(fd, isUserFd, buffer, offset, length, callback) {
// write(fd, buffer, offset, length, position, callback)
fs.write(fd, buffer, offset, length, null, (writeErr, written) => {
...
});
}
到这里,我们需要介绍一个重点知识,就是 bindings,
bindings其实就是 c++ 的 fs 模块和 js 的 fs 模块的桥梁,它的引用出现在第58行
javascript
const binding = internalBinding('fs');
而 internalBinding 本身是通过 getInternalBinding 函数获取的,getInternalBinding 函数在 nodejs 启动的时候,v8 引擎自动生成在全局环境,所以 js 能够访问到它。
接着我们上面的fs模块继续说,这个 getInternalBinding 引入内部c++的fs模块,在 node.fs.cc 文件里,我们可以看到
c
env->SetMethod(target, "writeBuffer", WriteBuffer);
也就是我们调用 js 的 binding.writeBuffer 函数,实际上调用的是 c++ 中 的 WriteBuffer 函数。
我们再来一张流程图,来帮助我们理解上面的过程:
`
为什么node.js需要internal bingdings
上面我们可以知道,internal bingdings 其实是 javascript 调用 c++ 代码的桥梁,nodejs 把一些核心模块提前写好然后经过v8的包装,就可以让我们写业务的同学在顶层引用,所以这也是写 node.js 插件的基本原理。
而 V8 解释 javascript,简单来说就是将 javascript 拆分成 token,然后拼装成 ast 抽象语法树,再解析成字节码,最后编译为机器码运行。(详细过程请见我之前写的 关于 V8 引擎你必须知道的基础知识)
这其中,到底哪个过程 javascript 调用了 c++ 的代码插件呢?这就引申出我在知乎上看到的一个误解:
javascript 会先转化为c++,然后c++编译为机器码?
因为有些同学觉得 javascript 调用了c++的代码的话,就先把 javascript 转化为 c++,这样再调用c++插件不是理所当然吗,其实这是错的。
而要解释这个问题之前,我们必须理解一些基础知识。
首先是动态链接库 DLL(windows上的动态链接文件格式)。 他是由 C 或 C++ 使用标准编译器编译而成。 一个 DLL 可以被一个程序在运行时动态加载, DLL 包含源 C 或 C++ 代码以及可通信的 API.
在 Nodejs 中, 当编译出 DLL 的时候, 会被导出为 .node 的后缀文件. 然后可以 require 该文件, 此文件本质上是二进制文件。又因为本身 V8 引擎就是 C/C++ 写的,所以是具备调用动态链接库的能力的。
所以本质上 javascript 调用 c++ 的能力是在二进制的层面上。
V8引擎的 event loop 跟 node.js event loop 有关系吗?
其实V8引擎是有默认的 event loop 的,但是宿主环境可以自定自己的 event loop 来代替默认的 event loop。
所以在浏览器环境和 node.js 环境中,都是宿主环境自己实现的 event loop,所以不要再说 javascript 的 event loop 了,这个是错误的,你可以说浏览器的 event loop,或者说 node.js or libuv的 event loop。
重点知识:详解 Node.js 的Event loop
网上有很多关于 Event Loop 的图例,但是大部分都有瑕疵,他们并没展示 Event loop 中 nextTick 队列和微任务队列在其中是如何执行的。所以完整的图例如下, 我们也会深入讲解此图:
Node.js中的事件循环是什么?
Node.js 事件循环是一个连续运行的半无限循环。只要有待处理的异步任务,它就会运行。启动 Node.js 进程时,会初始化事件循环。如果 Node.js 在执行脚本时遇到异步操作,例如计时器、文件和网络 I/O,它会将任务移交到线程池或者操作系统处理。
大多数的 I/O 操作,例如读写文件,加解密文件,网络,是非常耗时的和消耗和消耗 cpu 的。因此,为了避免阻塞主线程,Node.js 将操作任务交给本地操作系统。操作系统可以并行这些任务(现代的操作系统内核有多线程,所以支持并行)。
当这些操作执行完成时会通知Node.js进程,事件循环负责执行这些异步操作的回调函数。主要包含以下6个阶段:
- Timers阶段,用于处理 setTimeout 和 setInterval
- Pending Callbacks 阶段,用于执行延迟回调(执行有些在 poll 阶段没有执行的回调函数)
- Idle, Prepare 阶段,用于内部操作
- Poll 阶段,用于轮询和处理例如,文件和网络 I/O 等事件
- Check 阶段,执行 setImmediate 回调
- Close 阶段,处理某些关闭事件
在事件循环的走完一轮后,如果仍有待处理的事件或异步操作,则事件循环将继续下一次循环。 否则,Node.js 进程结束。
首先,我们先来看一下上图中事件循环中心出现的"nextTick 队列"和微任务(Micro Task)队列。其实并它们不是事件循环的一部分,但是又跟事件循环紧密相关。
微任务队列
Promises、queueMicrotask 和 process.nextTick 都是 Node.js 中异步 API 的一部分。 当 Promise 解决时,queueMicrotask 以及 .then、.catch 和 .finally 回调将添加到微任务队列中。
这里需要说明的是,有些同学不清楚 queueMicrotask 这个 Node.js API 是什么,我翻译下官网的介绍:
queueMicrotask() 方法将微任务插入队列,并调用其 callback。 如果 callback 抛出异常,则将触发 process对象的 'uncaughtException' 事件。
微任务队列由 V8 管理,并且可以以类似于 process.nextTick() 队列的方式使用,后者由 Node.js 管理。 在 Node.js 事件循环的每次轮询中,process.nextTick() 队列总是在微任务队列之前处理。
好了,简单理解就是 promise 产生的 then,catch,finally 和 queueMicrotask 的回调,都属于 微任务队列。
让我们用下面的例子来说明微任务和"next tick"队列是如何被 Node.js 处理的:
javascript
setTimeout(() => {
console.log("setTimeout 1");
Promise.resolve("Promise 1").then(console.log);
Promise.reject("Promise 2").catch(console.log);
queueMicrotask(() => console.log("queueMicrotask 1"));
process.nextTick(console.log, "nextTick 1");
}, 0);
setTimeout(console.log, 0, "setTimeout 2");
setTimeout(console.log, 0, "setTimeout 3");
打印结果如下,你答对了吗?
javascript
setTimeout 1
nextTick 1
Promise 1
Promise 2
queueMicrotask 1
setTimeout 2
setTimeout 3
让我们一起来看看,为什么这样打印:
首先,3个SetTimeout间隔都是 0 秒,最终都被放入了 Timers 阶段
在上面的示例代码中,当执行第一个 setTimeout 的回调时,.then、.catch 和 queueMicrotask 回调将添加到微任务队列中。 类似地,process.nextTick 回调被添加到我们将称为"next tick"队列的队列中。 请注意,console.log 是同步的。
当处理第一个 setTimeout 的回调时,我们会先处理 next tick 队列里的回调,如果 next tick 队列的回调函数继续产生放入 next tick 队列中的回调函数,那么会一直调用,直到 next tick 队列清空。
当"next tick"队列为空时,接下来处理微任务队列。 如果微任务生成了更多的微任务,它们也会被添加到微任务队列的后面并执行。
当微任务队列也清空了,才继续执行 timer 阶段里的第二个 setTimeout 产生的回调函数。
上述的逻辑并不仅仅存在于 timer 阶段,其它的一些 event loop 阶段也会有类似逻辑,就是执行一个 阶段里的回调,然后执行 next tick 队列里的回调函数,再执行 微任务里的回调函数。
Node.js 事件循环的各个阶段
上面介绍了 Node.js 的多个事件循环有哪些阶段,其中有些阶段是 Node.js 内部使用的,对我们的编码没有什么影响,所以后续不会介绍这些阶段。
定时器阶段
与浏览器一样,Node.js 具有定时器 API,用于执行一段时间后的回调函数。 Node.js 中的定时器 API 与浏览器中的计时器 API 类似。 但是,在实现上存在一些细微的差异。
计时器 API 由 setTimeout、setInterval 和 setImmediate 函数组成。 所有三个定时器都是异步的。 事件循环的timer 阶段仅负责处理 setTimeout 和 setInterval。
另一方面,check 阶段负责调度 setImmediate 函数。 我们稍后再说 check 阶段。 setTimeout 和 setInterval 都有以下语法:
javascript
setTimeout(callback[, delay[, ...args]])
setInterval(callback[, delay[, ...args]])
- callback 是一个超过设置的时间后会调用的回调函数
- delay 是等待时间的毫秒数。 默认为一毫秒。
- args 一些可选参数
setInterval 是会循环调用,而 setTimeout 是调用一次。
下图展示了假设仅仅只有 Timers 阶段的 event loop 运行图:
为了简单起见,我们假设 timers 阶段有 3个已经到期 setTimeout 回调函数。 以下步骤描述了事件循环进入 timer 阶段时发生了设么:
- 将三个到时间的定时器回调函数添加到定时器队列中
- 事件循环执行第一个 setTimeout 回调。 如果在执行第一个回调时生成"next tick"任务或微任务,它们将被添加到对应的队列中
- 当第一个 setTimeout 回调结束后,将处理"next tick"队列中的回调函数。 如果在处理"next tick"队列时生成更多"next tick"任务,将继续被添加到这个队列继续执行。 如果生成了微任务,则将其添加到微任务队列中
- 当"next tick"队列为空时,开始执行微任务队列里的回调函数。 如果生成更多"微任务",它们将被添加到微任务队列的后面并立即处理。
- 如果"next tick"队列和微任务队列都为空时,则事件循环将执行定时器队列中的第二个回调。 这样循环往复
- 执行完所有到时间的的定时器回调函数或执行 Node.js 允许的最大数量的回调函数后,事件循环进入下一阶段
执行 JavaScript 回调函数时时,事件循环会被阻塞。 如果这个回调函数需要很长时间才能处理完成,事件循环将一直等待它完成。 由于 Node.js 主要运行在服务器端,阻塞事件循环会导致性能问题,这也是为什么尽量用异步的函数去处理任务,而不是同步的。
同样,传递给定时器的时间并不一定是准确的,万一前面有个 setTimeout 花费了很长时间,后面的 timers 里的回调任务很可能会被延迟。
Pending 阶段
在 随后会讲到的 polling 阶段, 事件循环轮询检查是否有例如文件和网络 I/O 事件。事件循环会处理这些任务,并且会把一些特定的任务(specific events)推迟到下一轮事件循环的 pending 阶段执行。例如,这些事件包括操作系统触发的 TCP 套借此错误
Idle, prepare 阶段
这个阶段主要是 Node.js 内部使用,不会对我们编写的代码产生直接影响。略过。
Poll 阶段
poll 阶段主要有两个作用:
- 第一个是执行在 poll 队列里的回调函数
- 第二是决定到底会阻塞事件循环和 处理 I/O 事件到底多长时间
当进入 event loop 的 poll 阶段,会将这些 I/O 任务放入队列中,然后执行他们,直到队列为空,或者由于一些操作系统的限制触发,例如使用到最大文件描述符数,从而抛出错误。然后再依次执行 next tick 和微任务队列,直到他们的回调也执行完毕。
然后我们再总结一下这个阶段,因为这个阶段对于开发者来说,算是最最重要的阶段:
- 如果 poll 阶段队列中已经有 I/O 任务的回调函数在队列中,然后回依次执行他们,直到队列清空。
- 如果队列中没有回调,事件循环将在 poll 阶段停留一段时间。 现在,这个具体停留时间还取决于以下几件事:
- 如果我们的代码调用了 setImmediate() ,事件循环将结束轮询阶段并继续 check 阶段以执行那些调度任务。
- 如果我们的代码没有调用 setImmediate() ,事件循环将等待回调被添加到 poll 队列中,然后立即执行它们。
一旦轮询队列为空,事件循环将检查已达到时间阈值的计时器。 如果一个或多个计时器准备就绪,事件循环将返回到计时器阶段以执行这些计时器的回调。
需要注意的是,为了保证定时器阶段按时执行,epoll 阻塞的时间需要设置为等于最快到期定时器节点的时间,要不就会让 timers 阶段里的回调延时。
Check 阶段
setImmediate 的语法如下:
javascript
setImmediate(callback[, ...args])
- callback 是调用的回调函数
- args 是可选参数,具体参数请参考 node.js 官网的技术文档
事件循环依次执行多个 setImmediate 回调函数。 在下面的示例中,事件循环将在 poll 阶段执行 fs.readFile 回调函数,因为它是一个 I/O 任务。 之后,事件循环会在本轮轮事件循环的 check 阶段立即执行 setImmediate 回调函数。 另一方面,它在事件循环的下次循环的定时器阶段处理 setTimeout。
javascript
const fs = require("fs");
let counter = 0;
fs.readFile("path/to/file", { encoding: "utf8" }, () => {
console.log(`Inside I/O, counter = ${++counter}`);
setImmediate(() => {
console.log(`setImmediate 1 from I/O callback, counter = ${++counter}`);
});
setTimeout(() => {
console.log(`setTimeout from I/O callback, counter = ${++counter}`);
}, 0);
setImmediate(() => {
console.log(`setImmediate 2 from I/O callback, counter = ${++counter}`);
});
});
打印结果( node 版本18.x ):
javascript
Inside I/O, counter = 1
setImmediate 1 from I/O callback, counter = 2
setImmediate 2 from I/O callback, counter = 3
setTimeout from I/O callback, counter = 4
同样,这里也会经历执行完一个 setImmediate 里的回调,立马去看 next tick 队列和微任务队列。
Close 阶段
在这个阶段,事件循环会执行跟关闭事件相关的事件,例如socket.on('close', fn) or process.exit()。
值得强调的是,我们可以通过调用 process.exit 方法在任何阶段终止事件循环。 Node.js 进程将退出,事件循环也将忽略之前产生的异步任务。
eventloop章节翻译自:
A complete guide to the Node.js event loop - LogRocket Blog