Node.js 中的事件循环

109 阅读8分钟

Node.js 中的事件循环

事件循环是 Node.js 中非常重要的一个概念,它是 Node.js 异步非阻塞 I/O 模型的核心。理解事件循环对于我们编写高效的 Node.js 代码非常有帮助。

什么是事件循环?

首先,我们来理解一下什么是事件循环。简单来说,事件循环是一种处理程序执行流程的机制,它在等待和发送事件或消息的过程中保持程序的运行。在 Node.js 中,事件循环是实现异步非阻塞 I/O 模型的关键部分,它使得 Node.js 可以在单线程中处理大量并发操作。

Node.js 的事件循环是如何工作的?

Node.js 的事件循环是基于 libuv 库实现的。libuv 是一个跨平台的异步 I/O 库,它提供了事件循环和其他一些底层功能,使得 Node.js 能够在各种操作系统上运行。

先介绍一下事件队列。事件队列是一个存储待处理事件的数据结构。在 Node.js 中,当一个异步操作完成时,对应的回调函数会被添加到事件队列中。事件循环的任务就是不断地检查事件队列,一旦队列中有事件,就取出事件并执行对应的回调函数。

Node.js 的事件循环分为以下六个阶段,循环执行:

1.timers

此阶段负责处理 setTimeout 和 setInterval 的回调函数。事件循环会检查 timers 队列,执行已到期的定时器回调函数。实际的执行时间可能会因为系统负载、代码阻塞等原因而有所延迟。

2.I/O callbacks

此阶段负责处理大部分 I/O 相关的回调函数,例如网络请求、文件操作等。这些回调函数通常是由上一次循环中的 poll 中监听到的。一些特殊的 I/O 回调(如 TCP 错误)会直接在 poll 阶段处理。

3.idle/prepare

此阶段主要用于 libuv 内部的调度和准备工作,这里不做详细介绍。

4.poll:

Poll IO 是 Libuv 最重要的一个阶段,可以说 Libuv 的驱动引擎。此阶段负责监控 I/O 事件,如文件读写、网络请求等回调都是在这个阶段处理的,所以这也是最复杂的一个阶段。事件循环会在此阶段等待新的 I/O 事件,并将对应的回调函数添加到 I/O callbacks 队列。如果没有新的 I/O 事件,事件循环会继续检查其他阶段。

其中IO 观察者是 Poll IO 阶段最重要的数据结构,它本质上是封装了 fd、回调函数等。结合事件驱动模块,就可以实现对 fd 的处理,例如网络数据的读写。实现上这里不再深入,先来看看效果吧:

const startTime = new Date();

setTimeout(function f1() {
  console.log("setTimeout", new Date(), new Date() - startTime);
}, 200);

console.log("start", startTime);

const fs = require("fs");

fs.readFile("./xxx", "utf-8", function fsFunc(err, data) {
  const fsTime = new Date();
  console.log("fs", fsTime);
  while (new Date() - fsTime < 300) {} // 模拟代码执行时阻塞情况
  console.log("结束阻塞", new Date());
});
  • 执行全局上下文,打印「start + 时间」

  • 查看是否有异步任务

  • 有,进入 event-loop,刚进入时由于还没到 200 毫秒,所以不会执行 setTimeout 的回调,一步步进入到 poll 阶段

  • poll 开始轮询,检查各队列是否有任务需要执行,这个时候上述代码有两种情况

    • 读取文件执行结果比定时器的时间快,则先将 fsFunc 放入 poll 队列并执行,且必须执行完毕后才会往下执行,也就是说执行 fsFunc 的过程中,当时间已经到了 200 毫秒的时候,f1 函数会被放入 timers 队列当中,但不会执行。
    • 定时器的时间比读文件的时间短,则先将 f1 放入 timers 队列,然后 poll 开始往下,一直到 timers,执行掉 f1 后再往下执行到 poll
  • timers队列(poll 队列)清空,回到poll队列,没有任务,等待一会

  • event-loop检查没有其他异步任务了,结束线程,整个程序over退出。

5.check

此阶段负责处理 setImmediate 的回调函数。事件循环会检查 check 队列,执行所有的 setImmediate 回调函数。需要注意的是,setImmediate 的回调函数会在当前事件循环迭代结束时执行,而不是在下一个事件循环迭代开始时。

这里有个问题就是 setTimeout(0),和 setImmediate谁先执行的问题,我们看下面这段代码。

setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

多次反复运行,打印顺序不固定,这是因为 setTimeout 的间隔数最小填1,虽然下边代码填了0。但实际计算机执行当1ms算。

第一种情况:等到了 timers 里这段时间,可能还没有 1ms 的时间,定时器任务间隔时间的条件不成立所以timers里还没有回调函数。继续向下到了 check 队列里,这时候setImmediate的回调函数早已等候多时,直接执行。而再下次eventloop到达timers队列,定时器也早已成熟,才会执行setTimeout的回调任务。于是顺序就是「setImmediate -> setTimeout」。

第二种情况:但也有可能到了timers阶段时,超过了1ms。于是计算定时器条件成立,setTimeout的回调函数被直接执行。eventloop再向下到达check队列执行setImmediate的回调。最终顺序就是「setTimeout -> setImmediate」了。

6.close callbacks

此阶段负责处理关闭事件的回调函数,例如 socket 的 'close' 事件。事件循环会检查 close callbacks 队列,执行所有的关闭事件回调函数。

事件循环会按照上述顺序依次检查各个阶段的队列。如果某个阶段的队列中有任务,事件循环就会执行这个任务的回调函数。如果队列为空,事件循环就会移动到下一个阶段。在检查完所有的阶段后,如果所有的队列都为空,事件循环就会结束,Node.js 程序就会退出。如果有新的事件被添加到队列中,事件循环就会继续。

上述所列出的均为宏任务,在事件循环的各个阶段之间,Node.js 会检查并执行微任务队列中的所有任务,直到队列清空。微任务包括 process.nextTick、Promises 等。这意味着微任务的优先级高于宏任务,它们会在当前宏任务结束后、下一个宏任务开始前执行。例如:

setImmediate(() => {
  console.log('setImmediate');
});

process.nextTick(() => {
  console.log('nextTick 1');
  process.nextTick(() => {
    console.log('nextTick 2');
  })
})

console.log('global');

Promise.resolve().then(() => {
  console.log('promise 1');
  process.nextTick(() => {
    console.log('nextTick in promise');
  })
})

// 执行结果如下
//global
//nextTick 1
//nextTick 2
//promise 1
//nextTick in promise
//setImmediate

事件循环是如何在单线程的 JavaScript 中实现的

严格意义来说 JavaScript 并没有事件循环这个概念,事件循环是 JavaScript 的运行时(Node.js、浏览器等)提供的一种能力。

那什么是 JavaScript 的运行时呢,以 Node.js 为例,Node.js 是基于 V8 引擎的可拓展性,拓展出来的一个运行时。用 C++ 的一些代码拓展 V8 引擎,比如拓展了 libuv,C++ 起到一个胶水的作用,把 V8 引擎和 libuv 结合了起来。也就是说,Node.js 主要是由 V8 和 libuv 等一些第三方库组成的。

深入什么是事件循环这个问题,事件循环的实现依赖于 libuv 库,这是一个由 Node.js 自身开发的跨平台 C 语言的库,提供了事件循环和异步 I/O 支持。在 Node.js 的 C++ 层面,libuv 会创建一个事件循环,并启动一个无限循环,不断地检查和处理事件队列中的事件。

虽然 JavaScript 是单线程的,但 Node.js 和 libuv 不是。在 Node.js 中,有一些任务(如文件 I/O、DNS 查询等)是由 libuv 的线程池处理的。线程池中的线程可以并行执行,这使得 Node.js 能够在单线程的 JavaScript 中实现并发。

在 JavaScript 层面,当调用一个异步 API(如 fs.readFile 或 http.get)时,Node.js 会在 C++ 层面调用对应的 libuv API。这个 API 会启动一个异步操作,如读取文件或发送网络请求。然后,libuv 会将这个操作交给操作系统或线程池处理,自己则立即返回,继续执行其他的代码。这就是非阻塞的含义:即使操作需要花费很长时间,也不会阻塞程序的执行。

当异步操作完成时,操作系统或线程池会通知 libuv。然后,libuv 会将对应的回调函数添加到事件队列。在下一个事件循环迭代中,事件循环会取出这个回调函数并执行它。这就是如何在单线程的 JavaScript 中实现"并发"的:虽然在任何给定的时间点,只有一个任务在执行,但由于异步操作的执行是非阻塞的,因此可以同时处理大量的异步操作。