深入聊聊Node 异步和事件循环的底层实现和执行机制
Node 最初是为打造高性能的 Web 服务器而生,作为 JavaScript 的服务端运行时,具有事件驱动、异步 I/O、单线程等特性。基于事件循环的异步编程模型使 Node 具备处理高并发的能力,极大地提升服务器的性能,同时,由于保持了 JavaScript 单线程的特点,Node 不需要处理多线程下状态同步、死锁等问题,也没有线程上下文切换所带来的性能上的开销。基于这些特性,使 Node 具备高性能、高并发的先天优势,并可基于它构建各种高速、可伸缩网络应用平台。
本文将深入 Node 异步和事件循环的底层实现和执行机制,希望对你有所帮助。
为什么要异步?
Node 为什么要使用异步来作为核心编程模型呢?
前面说过,Node 最初是为打造高性能的 Web 服务器而生,假设业务场景中有几组互不相关的任务要完成,现代主流的解决方式有以下两种:
单线程串行依次执行。
多线程并行完成。
单线程串行依次执行,是一种同步的编程模型,它虽然比较符合程序员按顺序思考的思维方式,易写出更顺手的代码,但由于是同步执行 I/O,同一时刻只能处理单个请求,会导致服务器响应速度较慢,无法在高并发的应用场景下适用,且由于是阻塞 I/O,CPU 会一直等待 I/O 完成,无法做其他事情,使 CPU 的处理能力得不到充分利用,最终导致效率的低下,
而多线程的编程模型也会因为编程中的状态同步、死锁等问题让开发人员头疼。尽管多线程在多核 CPU 上能够有效提升 CPU 的利用率。
虽然单线程串行依次执行和多线程并行完成的编程模型有其自身的优势,但是在性能、开发难度等方面也有不足之处。
除此之外,从响应客户端请求的速度出发,如果客户端同时获取两个资源,同步方式的响应速度会是两个资源的响应速度之和,而异步方式的响应速度会是两者中最大的一个,性能优势相比同步十分明显。随着应用复杂度的增加,该场景会演变成同时响应 n 个请求,异步相比于同步的优势将会凸显出来。
综上所述,Node 给出了它的答案:利用单线程,远离多线程死锁、状态同步等问题;利用异步 I/O,让单线程远离阻塞,以更好地使用 CPU。这就是 Node 使用异步作为核心编程模型的原因。
此外,为了弥补单线程无法利用多核 CPU 的缺点,Node 也提供了类似浏览器中 Web Workers 的子进程,该子进程可以通过工作进程高效地利用 CPU。
如何实现异步?
聊完了为什么要使用异步,那要如何实现异步呢?
我们通常所说的异步操作总共有两类:一是像文件 I/O、网络 I/O 这类与 I/O 有关的操作;二是像 setTimeOut
、setInterval
这类与 I/O 无关的操作。很明显我们所讨论的异步是指与 I/O 有关的操作,即异步 I/O。
异步 I/O 的提出是期望 I/O 的调用不会阻塞后续程序的执行,将原有等待 I/O 完成的这段时间分配给其余需要的业务去执行。要达到这个目的,就需要用到非阻塞 I/O。
阻塞 I/O 是 CPU 在发起 I/O 调用后,会一直阻塞,等待 I/O 完成。知道了阻塞 I/O,非阻塞 I/O 就很好理解了,CPU 在发起 I/O 调用后会立即返回,而不是阻塞等待,在 I/O 完成之前,CPU 可以处理其他事务。显然,相比于阻塞 I/O,非阻塞 I/O 多于性能的提升是很明显的。
那么,既然使用了非阻塞 I/O,CPU 在发起 I/O 调用后可以立即返回,那它是如何知道 I/O 完成的呢?答案是轮询。
为了及时获取 I/O 调用的状态,CPU 会不断重复调用 I/O 操作来确认 I/O 是否已经完成,这种重复调用判断操作是否完成的技术就叫做轮询。
显然,轮询会让 CPU 不断重复地执行状态判断,是对 CPU 资源的浪费。并且,轮询的间间隔很难控制,如果间隔太长,I/O 操作的完成得不到及时的响应,间接降低应用程序的响应速度;如果间隔太短,难免会让 CPU 花在轮询的耗时变长,降低 CPU 资源的利用率。
因此,轮询虽然满足了非阻塞 I/O 不会阻塞后续程序的执行的要求,但是对于应用程序而言,它仍然只能算是一种同步,因为应用程序仍然需要等待 I/O 完全返回,依旧花费了很多时间来等待。
我们所期望的完美的异步 I/O,应该是应用程序发起非阻塞调用,无须通过轮询的方式不断查询 I/O 调用的状态,而是可以直接处理下一个任务,在 I/O 完成后通过信号量或回调将数据传递给应用程序即可。
如何实现这种异步 I/O 呢?答案是线程池。
虽然本文一直提到,Node 是单线程执行的,但此处的单线程是指 JavaScript 代码是执行在单线程上的,对于 I/O 操作这类与主业务逻辑无关的部分,通过运行在其他线程的方式实现,并不会影响或阻塞主线程的运行,反而可以提高主线程的执行效率,实现异步 I/O。
通过线程池,让主线程仅进行 I/O 的调用,让其他多个线程进行阻塞 I/O 或者非阻塞 I/O 加轮询技术完成数据获取,再通过线程之间的通信将 I/O 得到的数据进行传递,这就轻松实现了异步 I/O:
主线程进行 I/O 调用,而线程池进行 I/O 操作,完成数据的获取,然后通过线程之间的通信将数据传递给主线程,即可完成一次 I/O 的调用,主线程再利用回调函数,将数据暴露给用户,用户再利用这些数据来完成业务逻辑层面的操作,这就是 Node 中一次完整的异步 I/O 流程。而对于用户来说,不必在意底层这些繁琐的实现细节,只需要调用 Node 封装好的异步 API,并传入处理业务逻辑的回调函数即可,如下所示:
const fs = require("fs"); fs.readFile('example.js', (data) => { // 进行业务逻辑的处理 });
Nodejs 的异步底层实现机制在不同平台下有所不同:Windows 下主要通过 IOCP 来向系统内核发送 I/O 调用和从内核获取已完成的 I/O 操作,配以事件循环,以此完成异步 I/O 的过程;Linux 下通过 epoll 实现这个过程;FreeBSD下通过 kqueue 实现,Solaris 下通过 Event ports 实现。线程池在 Windows 下由内核(IOCP)直接提供,*nix
系列则由 libuv 自行实现。
由于 Windows 平台和 *nix
平台的差异,Node 提供了 libuv 作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成,保证上层的 Node 与下层的自定义线程池及 IOCP 之间各自独立。Node 在编译期间会判断平台条件,选择性编译 unix 目录或是 win 目录下的源文件到目标程序中:
以上就是 Node 对异步的实现。
(线程池的大小可以通过环境变量 UV_THREADPOOL_SIZE
设置,默认值为 4,用户可结合实际情况来调整这个值的大小。)
那么问题来了,在得到线程池传递过来的数据后,主线程是如何、何时调用回调函数的呢?答案是事件循环。
基于事件循环的异步编程模型
既然使用回调函数来进行对 I/O 数据的处理,就必然涉及到何时、如何调用回调函数的问题。在实际开发中,往往会涉及到多个、多类异步 I/O 调用的场景,如何合理安排这些异步 I/O 回调的调用,确保异步回调的有序进行是一个难题,而且,除了异步 I/O 之外,还存在定时器这类非 I/O 的异步调用,这类 API 实时性强,优先级相应地更高,如何实现不同优先级回调地调度呢?
因此,必须存在一个调度机制,对不同优先级、不同类型的异步任务进行协调,确保这些任务在主线程上有条不紊地运行。与浏览器一样,Node 选择了事件循环来承担这项重任。
Node 根据任务的种类和优先级将它们分为七类:Timers、Pending、Idle、Prepare、Poll、Check、Close。对于每类任务,都存在一个先进先出的任务队列来存放任务及其回调(Timers 是用小顶堆存放)。基于这七个类型,Node 将事件循环的执行分为如下七个阶段:
timers
这个阶段的执行优先级是最高的。
事件循环在这个阶段会检查存放定时器的数据结构(最小堆),对其中的定时器进行遍历,逐个比较当前时间和过期时间,判断该定时器是否过期,如果过期的话,就将该定时器的回调函数取出并执行。
pending
该阶段会执行网络、IO 等异常时的回调。一些 *nix
上报的错误,在这个阶段会得到处理。另外,一些应该在上轮循环的 poll 阶段执行的 I/O 回调会被推迟到这个阶段执行。
idle、prepare
这两个阶段仅在事件循环内部使用。
poll
检索新的 I/O 事件;执行与 I/O 相关的回调(除了关闭回调、定时器调度的回调和 之外几乎所有回调setImmediate()
);节点会在适当的时候阻塞在这里。
poll,即轮询阶段是事件循环最重要的阶段,网络 I/O、文件 I/O 的回调都主要在这个阶段被处理。该阶段有两个主要功能:
计算该阶段应该阻塞和轮询 I/O 的时间。
处理 I/O 队列中的回调。
当事件循环进入 poll 阶段并且没有设置定时器时:
如果轮询队列不为空,则事件循环将遍历该队列,同步地执行它们,直到队列为空或达到可执行的最大数量。
如果轮询队列为空,则会发生另外两种情况之一:
如果有
setImmediate()
回调需要执行,则立即结束 poll 阶段,并进入 check 阶段以执行回调。如果没有
setImmediate()
回调需要执行,事件循环将停留在该阶段以等待回调被添加到队列中,然后立即执行它们。在超时时间到达前,事件循环会一直停留等待。之所以选择停留在这里是因为 Node 主要是处理 IO 的,这样可以更及时地响应 IO。
一旦轮询队列为空,事件循环将检查已达到时间阈值的定时器。如果有一个或多个定时器达到时间阈值,事件循环将回到 timers 阶段以执行这些定时器的回调。
check
该阶段会依次执行 setImmediate()
的回调。
close
该阶段会执行一些关闭资源的回调,如 socket.on('close', ...)
。该阶段晚点执行也影响不大,优先级最低。
当 Node 进程启动时,它会初始化事件循环,执行用户的输入代码,进行相应异步 API 的调用、计时器的调度等等,然后开始进入事件循环:
┌───────────────────────────┐ ┌─>│ timers │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ pending callbacks │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ idle, prepare │ │ └─────────────┬─────────────┘ ┌───────────────┐ │ ┌─────────────┴─────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └─────────────┬─────────────┘ │ data, etc. │ │ ┌─────────────┴─────────────┐ └───────────────┘ │ │ check │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ close callbacks │ └───────────────────────────┘
事件循环的每一轮循环(通常被称为 tick),会按照如上给定的优先级顺序进入七个阶段的执行,每个阶段会执行一定数量的队列中的回调,之所以只执行一定数量而不全部执行完,是为了防止当前阶段执行时间过长,避免下一个阶段得不到执行。
OK,以上就是事件循环的基本执行流程。现在让我们来看另外一个问题。
对于以下这个场景:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
当服务成功绑定到 8000 端口,即 listen()
成功调用时,此时 listening
事件的回调还没有绑定,因此端口成功绑定后,我们所传入的 listening
事件的回调并不会执行。
再思考另外一个问题,我们在开发中可能会有一些需求,如处理错误、清理不需要的资源等等优先级不是那么高的任务,如果以同步的方式执行这些逻辑,就会影响当前任务的执行效率;如果以异步的方式,比如以回调的形式传入 setImmediate()
又无法保证它们的执行时机,实时性不高。那么要如何处理这些逻辑呢?
基于这几个问题,Node 参考了浏览器,也实现了一套微任务的机制。在 Node 中,除了调用 new Promise().then()
所传入的回调函数会被封装成微任务外,process.nextTick()
的回调也会被封装成微任务,并且后者的执行优先级比前者高。
有了微任务后,事件循环的执行流程又是怎么样的呢?换句话说,微任务的执行时机在什么时候?
因此,有了微任务后,事件循环的每一轮循环,会先执行 timers 阶段的一个任务,然后按照先后顺序清空 process.nextTick()
和 new Promise().then()
的微任务队列,接着继续执行 timers 阶段的下一个任务或者下一个阶段,即 pending 阶段的一个任务,按照这样的顺序以此类推。
利用 process.nextTick()
,Node 就可以解决上面的端口绑定问题:在 listen()
方法内部,listening
事件的发出会被封装成回调传入 process.nextTick()
中,如下伪代码所示:
function listen() { // 进行监听端口的操作 ... // 将 `listening` 事件的发出封装成回调传入 `process.nextTick()` 中 process.nextTick(() => { emit('listening'); }); };
在当前代码执行完毕后便会开始执行微任务,从而发出 listening
事件,触发该事件回调的调用。
一些注意事项
由于异步本身的不可预知性和复杂性,在使用 Node 提供的异步 API 的过程中,尽管我们已经掌握了事件循环的执行原理,但是仍可能会有一些不符合直觉或预期的现象产生。
比如定时器(setTimeout
、setImmediate
)的执行顺序会因为调用它们的上下文而有所不同。如果两者都是从顶层上下文中调用的,那么它们的执行时间取决于进程或机器的性能。
我们来看以下这个例子:
setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); });
以上代码的执行结果是什么呢?按照我们刚才对事件循环的描述,你可能会有这样的答案:由于 timers 阶段会比 check 阶段先执行,因此 setTimeout()
的回调会先执行,然后再执行 setImmediate()
的回调。
实际上,这段代码的输出结果是不确定的,可能先输出 timeout,也可能先输出 immediate。这是因为这两个定时器都是在全局上下文中调用的,当事件循环开始运行并执行到 timers 阶段时,当前时间可能大于 1 ms,也可能不足 1 ms,具体取决于机器的执行性能,因此 setTimeout()
在第一个 timers 阶段是否会被执行实际上是不确定的,因此才会出现不同的输出结果。
(当 delay
(setTimeout
的第二个参数)的值大于 2147483647
或小于 1
时, delay
会被设置为 1
。)
我们接着看下面这段代码:
const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); });
可以看到,在这段代码中两个定时器都被封装成回调函数传入 readFile
中,很明显当该回调被调用时当前时间肯定大于 1 ms 了,所以 setTimeout
的回调会比 setImmediate
的回调先得到调用,因此打印结果为:timeout immediate
。
以上是在使用 Node 时需要注意的与定时器相关的事项。除此之外,还需注意 process.nextTick()
与 new Promise().then()
还有 setImmediate()
的执行顺序,由于这部分比较简单,前面已经提到过,就不再赘述了。
总结
文章开篇从为什么要异步、如何实现异步两个角度出发,较详细地阐述了 Node 事件循环的实现原理,并提到一些需要注意的相关事项,希望对你有所帮助。
以上就是深入聊聊Node 异步和事件循环的底层实现和执行机制的详细内容,更多请关注其它相关文章!