Node.js 底层原理 #3 - 深入探究事件循环
在上一篇文章中,我们讨论了调用栈、栈帧、栈溢出以及其他一些 JS 相关的知识。我们了解了引擎与 JavaScript 的关系,以及所有代码的执行实际上是如何在 JS 运行时中运行的。
现在我们将进入另一个领域——事件循环,并理解为什么所有 JS 运行时和 JS 引擎都提供事件循环。首先,让我们深入了解它的核心。
Libuv
什么是 libuv?为什么我们需要它?
Libuv 是一个开源库,它处理线程池、信号发送、进程间通信以及实现异步任务所需的所有其他功能。Libuv 最初是为 Node.js 本身开发的,作为 的一个抽象libev
,但现在已有多个项目在使用它。
大多数人认为 libuv 就是事件循环本身,但事实并非如此,libuv 实现了一个功能齐全的事件循环,而且也是 Node 其他几个关键部分的所在地,例如:
- TCP 和 UDP
net
套接字的封装 - 异步 DNS 解析
- 异步文件和文件系统操作(就像我们在这里做的一样)
- 文件系统事件
- 工业控制计算机
- 子进程和 shell 控制
- 线程池
- 信号处理
- 高分辨率时钟
这主要是 Node.js 使用它的原因,它是围绕每个操作系统的几个关键部分的完整抽象,并且整个运行时有必要与其周围环境进行交互。
事件循环
让我们暂时抛开 Node.js 环境。在浏览器中,用纯 JavaScript 编写时,如果调用栈中有一个长时间运行的函数,会发生什么?那些需要一段时间才能完成的函数,比如复杂的图像处理或耗时的矩阵变换?
在大多数语言中,你应该不会遇到问题,因为它们是多线程的。然而,在单线程语言中,这是一个非常严重的问题。因为虽然调用堆栈中有函数要执行,但浏览器实际上无法执行任何其他操作,而且浏览器不仅仅处理 HTML 和 CSS,还有其他一些功能,例如渲染引擎,它会绘制屏幕以绘制你用标记编写的任何内容。这意味着,如果你有长时间运行的函数,你的浏览器实际上会暂停该页面的所有执行。这就是为什么大多数浏览器将标签页视为线程或独立进程,这样就不会因为一个标签页冻结所有其他标签页。
另一个可能引发的问题是,浏览器就像一个控制欲极强的“老大哥”,如果某个标签页响应时间过长,它们就会抛出一个错误,询问你是否要终止该网页。所以……这可不是我们能拥有的最佳用户体验,对吧?另一方面,复杂的任务和长时间运行的代码才是我们创建优秀软件的关键,那么,如何才能在不惹恼“老大哥”的情况下完成这些任务呢?异步回调,正是 Node.js 的核心所在。
异步回调
大多数 JavaScript 应用程序的工作原理是将单个.js
文件加载到内存中,然后所有神奇的事情都发生在这个入口点执行之后。这可以分为几个构建块,即“现在”块和“稍后”块。通常,这些块中只有一个是“现在”块,这意味着它将在主线程中执行(将调用推送到调用堆栈),而所有其他块都将稍后执行。
异步编程最大的问题是,大多数人认为“稍后”指的是“现在”到之后一毫秒之间的某个时间,这完全是谎言。JavaScript 中所有计划稍后执行并完成的操作并不一定严格发生在主线程之后,根据定义,它们会在完成时完成。这意味着你无法立即得到你想要的答案。
例如,让我们进行一个调用 API 的简单 AJAX 调用:
const response = call('http://api') // call() is some http request package, like fetch
console.log(response)
由于 AJAX 调用在调用后不会立即完成 - 执行 HTTP 握手、获取数据、下载数据需要一些时间...... - 所以此调用将在稍后的状态下完成,因此响应尚未分配值,这意味着我们的console
函数将打印undefined
。
一种简单的“等待”响应的方式是回调。回调自编程之初就是一种自动调用的函数,它会作为参数传递给另一个函数,该函数将在“现在”之后执行并/或返回其值。所以,回调本质上就是一种说法:“嘿,当你得到这个值时,就调用这个回调”。让我们改进一下我们的例子:
const response = call('http://api', (response) => {
console.log(response)
})
这基本上意味着,当调用结束时,(response) => void
将自动调用一个带有签名的匿名函数,因为调用返回了响应,所以这个参数被传递给回调函数。现在我们就可以在响应中记录日志了。
在我们的第一个代码示例中,调用readFile
,我们基本上把它转换成一个 Promise,Promise 是一种代码,它会在后续状态返回它的值,然后打印出来,我们正在异步读取一个文件。但它究竟是如何工作的呢?
事件循环内部
在 ES6 之前,JS 核心中实际上从未内置任何形式的共识或异步概念。这意味着 JS 会收到你执行某些异步代码的命令,并将其发送给引擎,然后引擎会点赞并回复“我以后会研究一下”。所以,引擎中既没有内置任何关于“稍后”如何执行的命令,也没有内置任何逻辑。
JS 引擎实际上并非独立于一切运行。它们运行在所谓的托管环境中。这个环境可以是 JS 运行的任何地方,例如浏览器、Node.js,或者由于 JS 几乎无处不在,它也可以是一个烤面包机或飞机。每个环境都各不相同,每个环境都有自己的技能和能力,但它们都有一个事件循环。
事件循环实际上负责 JS 引擎的异步代码执行,至少是调度部分。它调用引擎并发送待执行的命令,同时也将引擎返回的响应回调排队,以便之后调用。因此,我们开始理解 JS 引擎只不过是一个按需执行的环境,无论 JS 代码是否运行。围绕着它的所有东西,即环境,即事件循环,都负责调度 JS 代码的执行,这些代码被称为事件。
现在让我们回到readFile
代码。运行代码时,该readFile
函数被包装到一个 Promise 对象中,但本质上,它readFile
是一个回调函数。所以我们只分析这部分:
fs.readFile(filePath, function cb (err, data) => {
if (err) return reject(err)
return resolve(callback(data))
})
看到我们有一个回调函数了(err, data) => string
吗?这基本上是告诉引擎对文件执行一个读取操作。然后,JS 引擎会告诉托管环境,它将暂时暂停执行这段代码,但是,一旦环境(事件循环)收到响应,它就会安排这个匿名回调函数(cb
)尽快执行。然后,环境(在我们的例子中是 Node.js)会设置为监听文件操作的响应,当响应到达时,它会cb
通过将函数插入事件循环来安排执行。
让我们回顾一下旧图表:
Web API 本质上是线程,开发者无法访问,只能调用它们。通常,这些是内置于环境本身的部分,例如,在浏览器环境中,这些 API 可能是document
、XMLHttpRequest
或setTimeout
,它们大多是异步函数。在 Node.js 中,这些 API 就是我们在指南第一部分中看到的 C++ API。
简而言之,每当我们像setTimeout
在 Node.js 中一样调用一个函数时,这个调用都会被发送到另一个线程。所有这些都由 libuv 控制和提供,包括我们正在使用的 API。
让我们放大事件循环部分:
事件循环只有一个任务:监视调用堆栈以及所谓的回调队列。一旦调用堆栈为空,它会从回调队列中取出第一个事件并将其推送到调用堆栈,从而有效地运行它。对于这次从队列中取出回调并将其执行到调用堆栈的迭代,我们将其命名为tick
。
让我们举一个更简单的例子来展示事件循环实际上是如何工作的:
console.log('Node.js')
setTimeout(function cb() { console.log(' awesome!') }, 5000)
console.log(' is')
这应该会在控制台中以分隔行的形式打印出“Node.js is awesome!”。但这是怎么发生的呢?让我们一步一步来运行它:
- 状态为空,调用堆栈为空,没有被调用
console.log('Node.js')
被添加到调用堆栈
console.log('Node.js')
被执行
console.log('Node.js')
从堆栈中移除
setTimeout(function cb() {...}
被添加到调用堆栈
setTimeout(function cb() {...}
执行。环境会创建一个计时器作为 Web API 的一部分。该计时器将处理倒计时
setTimeout(function cb() {...}
本身已完成并从调用堆栈中删除
console.log(' is')
被添加到调用堆栈
console.log(' is')
被执行
console.log(' is')
从调用堆栈中删除
- 至少 5000 毫秒后,计时器完成并将
cb
回调函数推送到回调队列
- 事件循环检查堆栈,如果堆栈为空,它将从回调队列中弹出事件并将其推入堆栈
cb
执行并添加console.log(' awesome!')
到调用堆栈中
console.log(' awesome!')
被执行
console.log(' awesome!')
从堆栈中移除
cb
从堆栈中移除
正如我们之前提到的,ES6 指定了事件循环的行为方式,所以现在从技术上讲,调度已经属于 JS 引擎的职责范围,而引擎不再仅仅扮演一个托管环境的角色。这主要是因为 ES6 引入了原生的 Promises,正如我们稍后会看到的,它需要对调度操作和队列进行一些细粒度的控制。
一旦调用堆栈和所有队列都为空,事件循环就会终止该过程。
值得注意的是,回调队列与调用堆栈类似,是另一种数据结构,即队列。队列的行为类似于堆栈,但区别在于它们的顺序。堆栈框架被推送到堆栈顶部,而队列项则被推送到队列末尾。堆栈的弹出遵循 LIFO 原则,而队列遵循 FIFO(先进先出)原则,这意味着弹出操作将从队列的头部(即最旧的队列)弹出。
稍后并不一定意味着“稍后”
在上面的代码中,需要注意的一点是,回调函数执行完成后,不会自动将其放入事件循环队列。setTimeout
这是一个 Web API,它的唯一作用是设置一个计时器,以便稍后执行其他函数。计时器到期后,环境会将回调函数放入事件循环回调队列,以便将来的某个 tick 可以将其拾取并启动到调用堆栈中。setTimeout
所以,我们setTimeout(cb, 1000)
期望cb
函数在 1000 毫秒后被调用,对吗?没错,但实际上并非如此。这只是在说:“嘿!我注意到你的请求了,所以 1000 毫秒后我会把你的cb
函数放到队列里”。但请记住,队列的顺序与堆栈不同,所以回调会被添加到队列的末尾,这意味着队列中可能还有其他事件之前被添加——所以你的回调必须等待所有事件完成才能被处理。展示这种异步疯狂如何运作的最佳示例之一是将超时函数设置为 0。你自然希望这个函数在添加到代码后很快就能执行,对吗?错了。
console.log('Node.js')
setTimeout(() => console.log('is'), 0)
console.log('Awesome!')
我们首先想到的是:“打印的代码应该Node.js is Awesome!
只有三行”,但实际情况并非如此。将超时设置为 0 只会将其回调的执行推迟到调用堆栈清空后的下一个时刻。事实上,我们的反应应该是一句类似尤达大师的短语:
Node.js
Awesome!
is
微任务和宏任务
这就是为什么 ES6 对 JS 中的异步执行如此重要,它标准化了我们所知的关于异步的一切,使它们都能以相同的方式运行,并且还添加了另一个概念,称为“微任务队列”——或“作业队列”。它是回调队列(现在称为“宏任务队列”)之上的一层,您在使用 Promises 时很可能会遇到它。
简而言之,微任务队列是一个附加到事件循环中每个 tick 末尾的队列。因此,在事件循环的 tick 期间发生的某些异步操作不会导致在宏任务队列中添加新的回调,而是会在当前 tick 的微任务队列末尾添加一个项目(称为“微任务”或“作业”)。这意味着,现在您可以放心,您可以在微任务队列中添加稍后执行的功能,并且它会在您的 tick 之后立即执行,在宏任务队列中的任何内容出现之前。
由于微任务对代码的操作没有任何限制,因此微任务可能会在同一个队列的末尾无休止地添加另一个微任务,从而导致所谓的“微任务循环”,使程序无法获得所需的资源,并阻止其在下一个 tick 继续运行。这相当于while(true)
在代码中运行一个循环,只不过是异步的。
为了防止这种饥饿,引擎内置了称为的保护process.maxTickDepth
,其值设置为 1000,在同一个 tick 中调度并运行 1000 个微任务后,再运行下一个宏任务。
setTimeout(cb, 0)
是一种“解决方法”,用于添加在队列执行后立即添加的回调,就像微任务一样,但是,微任务是一种更加清晰和明确的排序规范,这意味着事情将稍后执行,但会尽快执行。
根据WHATVG规范,在事件循环的一个 tick 内,宏任务队列中应该处理一个(且恰好一个)宏任务。该宏任务完成后,所有其他可用的微任务都应在同一 tick 内处理。由于微任务可以加入其他微任务的队列,因此,如果微任务队列中有微任务,则应依次运行这些微任务,直到微任务队列为空。如下图所示:
并非所有任务都是微任务,以下是一些微任务的示例:
process.nextTick
- 承诺
Object.observe
这些是宏任务:
setTimeout
setInterval
setImmediate
- 任何 I/O 操作
我们以此代码为例:
console.log('script start')
const interval = setInterval(() => {
console.log('setInterval')
}, 0)
setTimeout(() => {
console.log('setTimeout 1')
Promise.resolve()
.then(() => console.log('promise 3'))
.then(() => console.log('promise 4'))
.then(() => {
setTimeout(() => {
console.log('setTimeout 2')
Promise.resolve().then(() => console.log('promise 5'))
.then(() => console.log('promise 6'))
.then(() => clearInterval(interval))
}, 0)
})
}, 0)
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'))
这将记录:
script start
promise 1
promise 2
setInterval
setTimeout 1
promise 3
promise 4
setInterval
setTimeout 2
setInterval
promise5
promise6
如果我们一步一步地进行下去,我们会得到如下结果:
第一个刻度
- 第一个
console.log
将被压入调用堆栈并执行,然后它将被弹出 setInterval
被安排为一项任务setTimeout 1
被安排为一项任务- 两个“then”都
Promise.resolve 1
被安排为微任务 - 由于堆栈为空,因此运行微任务
- 调用堆栈将两个
console.log
表达式压入堆栈并弹出 - 打印“承诺 1”和“承诺 2”
- 调用堆栈将两个
我们的宏任务队列有:[
setInterval
,setTimeout 1
]
第二次滴答
- 微任务队列为空,
setInterval
处理程序可以运行。- 调用堆栈运行并弹出
console.log
表达式 - 打印“setInterval”
setInterval
之后安排另一次setTimeout 1
- 调用堆栈运行并弹出
我们的宏任务队列有:[
setTimeout 1
,setInterval
]
第三次滴答
- 微任务队列保持为空
setTimeout 1
处理程序已运行- 调用堆栈运行并弹出
console.log
表达式 - 打印“setTimeout 1”
- “Promise 3”和“Promise 4”处理程序被安排为微任务
- Promises 3 和 Promises 4 的处理程序均已运行
- 调用堆栈运行并弹出两个
console.log
表达式 - 打印“承诺 3”和“承诺 4”
- 下一个处理程序为承诺 3 和 4 安排一项
setTimeout 2
任务
- 调用堆栈运行并弹出
我们的宏任务队列有:[
setInterval
,setTimeout 2
]
第四刻
- 微任务队列为空,
setInterval
处理程序正在运行,setInterval
紧接着将另一个微任务加入队列setTimeout
我们的宏任务队列有:[
setTimeout 2
,setInterval
]
setTimeout 2
处理程序已运行- Promise 5 和 6 被安排为微任务
- 运行承诺 5 和承诺 6 的处理程序
- 调用堆栈又收到两个
console.log
调用 - 打印“承诺 5”和“承诺 6”
- 清除间隔
我们的宏任务队列有:[]
这就是为什么关注事情的底层工作原理很重要,否则我们永远不会知道 Promises 的执行速度比回调更快。
文章来源:https://dev.to/_staticvoid/node-js-under-the-hood-3-deep-dive-into-the-event-loop-135d