阅读视图

发现新文章,点击刷新页面。

Node.js入门:Node.js 事件循环

大家好,我是前端理想哥!

今天是我们Node.js学习的第五节课,这节课我们来聊一聊Node.js 中一个非常重要的概念:事件循环。很多人听过它,但一提到具体原理就有点懵。别担心,今天我带你从代码角度,通俗易懂地理解事件循环的工作原理。

一、什么是事件循环?

事件循环(Event Loop)是 Node.js 能够执行非阻塞 I/O 操作的核心。尽管 Node.js 只有一个主线程,但通过将 I/O 操作交给操作系统的内核处理,Node.js 能够高效地进行并发操作。

举个例子:你可以把事件循环想象成一个老板,老板有很多任务要完成。每当有新的任务出现,老板就会安排合适的员工去做。当员工完成工作后,老板会拿到结果,再继续安排新任务。这样,老板始终能在最短的时间内处理最多的任务。

二、事件循环的6个阶段

因为Node.js底层使用的语言是libuv,一个c++语言,它用来操作底层的操作系统,然后封装了操作系统的接口,Node 的事件循环也是libuv写的,所以Node的生命周期和浏览器的还是有区别的。

因为Node是和操作系统打交道的,所以它的事件循环更加复杂。

Node.js的事件循环有6个主要阶段,执行原理也很简单,这6个阶段,每个阶段都维护了一个事件队列,当到达一个队列后,会检查队列内是否有任务(也就是看下是否有回调函数)需要执行。如果有,就依次执行,直到全部执行完毕、清空队列。

如果没有,那么就会进入到下一个队列继续检查,直到把6个阶段的队列都检查一遍,算是一个轮询。

接下来,让我们逐一了解这些阶段:

1. timers 计时器

这一阶段主要是用来存放计时器的,像 setTimeout、setInterval等的回调函数

举个例子:

setTimeout(() => {
  console.log("这是一个定时器回调");
}, 100);

定时器回调将在指定的时间之后执行, 但由于事件循环的机制,实际的执行时间可能会有所延迟。

2. pending callbacks 等待回调

这个阶段会处理那些在事件循环的其他阶段中未能及时执行的回调。这些回调通常是一些 I/O 操作的结果,比如网络请求失败、文件读取错误等。该阶段的回调并不依赖于定时器,也不属于其他阶段的任务,而是排队等待在此阶段执行。

举个例子:

const fs = require('fs');

// 异步读取文件
fs.readFile('example.txt', (err, data) => {
  if (err) {
    console.error('Error reading file');
  } else {
    console.log('File content:', data.toString());
  }
});

console.log('File reading initiated...');

解释:

  1. fs.readFile 是异步操作,当事件循环进入 poll 阶段时,文件的读取操作会被排入等待执行的队列中。

  2. 如果文件读取完成后,回调函数就会被推送到 pending callbacks 阶段,然后执行该回调输出文件内容。

  3. console.log('File reading initiated...') 是同步代码,优先执行。

执行顺序:

  1. 打印:File reading initiated...

  2. 在 pending callbacks 阶段执行 fs.readFile 的回调,并打印文件内容。

3. idle, prepare

这两个阶段是Node.js内部使用的阶段,在外部应用开发中我们很少接触到,主要是在事件循环的初期做一些准备工作,主要干两件事:

  • 空闲检测(Idle) :检查是否有任何可以执行的操作或者是否进入空闲状态,判断当前事件循环是否需要继续。
  • 内部准备(Prepare) :为后续阶段(尤其是轮询阶段 poll)的事件处理做准备。这个阶段可以看作是事件循环的一个内部“预处理”阶段,它对外部开发者没有直接的影响。

4. poll 轮询

在这个阶段,主要作用是处理 I/O 操作等待新的事件,并决定是否需要进入其他阶段(例如 timers 或 check 阶段)。

具体的流程有四步:

  1. 检索新的 I/O 事件:

poll 阶段会检查是否有新的 I/O 操作完成。例如,网络请求、文件操作、数据库查询等。如果有这些操作完成,它会把相关的回调放入事件队列,并在 poll 阶段执行它们。

  1. 执行已排队的 I/O 回调:

在 poll 阶段,Node.js 会执行所有排队的 I/O 回调,直到队列为空或者达到系统的最大回调数量限制。

  1. 阻塞等待:

如果没有新的 I/O 事件,poll 阶段会阻塞,等待新的事件发生。在此期间,Node.js 会挂起当前线程,直到有事件可以处理。这样避免了 CPU 空转,提高了资源利用效率。

  1. 决定是否跳到 check timers 阶段:

如果没有 I/O 事件可处理,poll 阶段会直接跳到 check 阶段去执行 setImmediate() 调度的回调,或者跳回到 timers 阶段去执行定时器回调。

举个例子:

假设我们有一个 Node.js 程序,监听一个文件变化,并在文件发生变化时触发回调。

const fs = require('fs');

// 使用 fs.watch 监听文件变化
fs.watch('somefile.txt', (eventType, filename) => {
  console.log(`File changed: ${filename}`);
});

console.log('Waiting for file change...');

我们从Node事件循环的角度来分析下这段代码的执行:

    1. Node.js 启动事件循环后,进入 poll 阶段。
    1. fs.watch() 是一个 I/O 操作,它会在底层操作系统等待文件变化的事件。
    1. 当文件发生变化时,操作系统会通知 Node.js 内核,Node.js 会把文件变化事件排入事件队列。
    1. 在 poll 阶段,Node.js 检测到文件变化事件,并执行回调 console.log()。

5. check

这个阶段的作用是执行通过 setImmediate() 调度的回调。这个阶段通常在 poll 阶段之后执行,主要负责执行那些被标记为“立即执行”的回调。

举个例子:

假设你使用 setImmediate() 调度了一个回调函数:

setImmediate(() => {
  console.log('这是setImmediate回调');
});

这个回调函数会在事件循环进入 check 阶段时执行,无论 poll 阶段是否还有事件未处理。setImmediate() 的回调总是会在轮询阶段结束后立即执行。

6. close callbacks

这个阶段用于处理与资源关闭相关的回调,主要是处理如网络连接、文件句柄或其他需要清理和关闭的资源。

举个例子:

const fs = require('fs');

// 打开一个文件流
const stream = fs.createWriteStream('example.txt');

// 写入数据
stream.write('Hello, world!');

// 监听 'close' 事件
stream.on('close', () => {
  console.log('文件已关闭');
});

// 关闭文件流
stream.end();  // 这时会触发 'close' 事件,并执行回调

在这个例子中,当调用 stream.end() 关闭文件流时,'close' 事件会被触发,然后回调函数会在 close callbacks 阶段执行,输出 '文件已关闭'。

三、关键概念:

在Node时间循环中,有几个关键概念:

  1. setImmediate()setTimeout()有什么不同?
  2. process.nextTick() 是什么?为什么要使用 process.nextTick()
  3. process.nextTick()setImmediate()有什么不同?

这些都是关键概念,因为篇幅有限,我们将放在下节视频,一个个给大家讲清楚。

四、总结

Node.js 的事件循环机制让它能够高效地处理 I/O 操作和异步任务。通过合理使用 process.nextTick()、setImmediate() 和 setTimeout(),我们可以精确控制回调的执行时机,避免阻塞主线程。

对于开发者来说,理解事件循环的工作原理非常重要。它不仅能帮助我们优化性能,还能避免一些常见的代码陷阱。

今天的分享就到这里,大家如果有任何问题,欢迎在评论区留言,我们一起讨论!别忘了点赞、收藏、关注哦!下次见!

如果你也对 Node.js 感兴趣,记得关注理想哥,我们一起深入探索。

Node.js系列:事件驱动的核心机制事件循环

❤️ 事件循环:处理异步操作的机制

目的:使Nodejs能够在单线程环境下高效运行

一、事件循环运行机制:依赖libuv库

事件循环机制是基于libuv库(一个多平台的异步I/O的库)构建的;

libuv为Nodejs提供了高效的事件驱动的I/O操作能力,libuv负责在底层进行实际的操作调度,当这些操作完成时,通过事件循环将对应的回调函数在合适的阶段进行调用;事件循环依赖kibuv实现高效的异步操作

比如在定时器管理方面,libuv 提供了精准的定时器机制,让事件循环能够准确地在合适的时间执行定时器回调函数(像setTimeoutsetInterval相关的回调)。

在非阻塞 I/O 操作上,事件循环借助 libuv 可以在等待 I/O 完成的同时处理其他事务,避免了线程的大量阻塞,提高了程序的整体性能。

二、事件循环的6个阶段:每个阶段都对应一个任务队列

当事件循环进入某个阶段时, 将会在该阶段内执行回调,直到队列耗尽或者回调的最大数量已执行, 那么将进入下一个处理阶段

1. 定时器Timers阶段:执行setTimeout 和setInterval的回调函数

当设定的时间到达后,回调函数会被添加到 定时器阶段的任务队列中。(定时任务不一定按照设定的时间执行)

2.I/O回调阶段:主要用于处理各种I/O操作(如文件读取,网络请求等)完成后的回调函数

当一个I/O操作完成后, 其对应的回到函数就会被添加到这个任务队列中; 比如fs.readFile,文件读取完成后的回调函数就会在这个阶段会执行

3.闲置阶段:这是一个内部使用的过渡阶段

  • 主要用于一些内部操作和准备工作,一般开发者很少直接涉及这个阶段的具体操作

4.轮询(Poll)阶段:事件循环的关键,主要有两个功能

  • 等待新I/O事件到来
  • 处理定时器到期后的任务(如果定时器阶段没来得及处理)

如果没有新的I/O事件并且定时器也没有到期任务,这个阶段会阻塞等待

5.检查(check)阶段:主要用于执行setImmediate的回调函数

  • 在当前轮询阶段结束后立即执行

6.关闭事件回调阶段:TPC服务器对象关闭时,对应的关闭回调函数

  • 例如关闭一个服务器套接字段后,用于清理资源等的关闭回调函数会在这个阶段被调用;
    • 如:socket.on('close', ...)

三、任务队列和执行顺序

微任务:

  • process.nextTick: 会在当前操作完成后立即执行,在微任务之前执行
  • promise.then
  • queueMicrotask():是标准的微任务

宏任务:

  • setTimeout、setInterval
  • IO事件
  • 检查阶段的setImmediate
  • 关闭事件

执行顺序:

  • nextTick microtask queue
  • other microtask queue
  • timer queue
  • poll queue
  • check queue
  • close queue

微任务会在当前执行栈为空的时候立即执行,宏任务会根据事件循环的阶段顺序来执行

其他:

queueMicrotask 与 process.nextTick 的区别?

  • process.nextTick 会在当前操作完成后立即执行,甚至在事件循环的下一个阶段开始之前,而且在微任务之前执行。
  • queueMicrotask 是标准的微任务,会在当前事件循环的微任务队列中等待,在当前执行上下文的同步代码和 process.nextTick 之后,但在宏任务之前执行。

setTimeoutsetImmediate的输出顺序

  • 遇到setTimeout,虽然设置的是0毫秒触发,但实际上会被强制改成1ms,时间到了然后塞入times阶段;
  • 先进入times阶段,检查当前时间过去了1毫秒没有,如果过了1毫秒,满足setTimeout条件,执行回调,如果没过1毫秒,跳过
  • 跳过空的阶段,进入check阶段,执行setImmediate回调

这里的关键在于这1ms,如果同步代码执行时间较长,进入Event Loop的时候1毫秒已经过了,setTimeout先执行,如果1毫秒还没到,就先执行了setImmediate

❌