likes
comments
collection
share

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

作者站长头像
站长
· 阅读数 34

Node.js 是一个基于 V8 引擎的 JavaScript 运行环境,它采用事件驱动和非阻塞 I/O 模型,配合其丰富的 API 支持,提供了网络编程、文件系统等服务端功能。Node.js 使用 libuv 库来处理异步事件,使其在构建高性能的网络应用方面表现出色。

什么是 NodeJs

Node.js 是一个支持非阻塞单线程事件驱动执行的 JavaScript 平台。换句话说,它是一个执行 JavaScript 的环境(类似于浏览器)。

Node.js 通过提供与操作系统交互的 API,使 JavaScript 从一个特定领域的语言转变为一个通用编程语言。

由于其架构的特点,Node.js 更适合于 I/O 密集型任务,而在 CPU 密集型任务中则不如其他编程语言。

Node.js 包含几个重要组成部分。最重要的是引擎(v8)、用于异步操作系统的抽象层(libuv)以及提供在 C++ 基础上的函数和实用工具的 Node.js API。

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

事件驱动

在 Node.js 的语境中,事件驱动 指的是 Node.js 遵循的基本架构设计和编程范式。Node.js 应用程序围绕事件及事件处理的概念构建。

在事件驱动的架构中,程序的流程由发生的异步事件决定,并触发相应的事件处理程序或回调函数。与传统的顺序执行流程不同,其中一个操作接一个操作地执行,事件驱动的程序会随着事件的发生而作出响应。

事件驱动编程在异步环境中非常有效的原因主要是它与异步处理和实时响应的特性相辅相成。

  1. 非阻塞操作: 异步环境中,某些操作(例如文件读写、网络请求等)可能会花费较长时间。在事件驱动模型中,当执行一个潜在耗时的操作时,程序不会等待其完成,而是继续执行其他任务。只有在操作完成时,相应的事件被触发,事件处理器才会被调用。这样可以避免程序在等待耗时操作完成时被阻塞。

  2. 实时响应: 异步环境下,事件驱动模型能够实现实时响应。当某个事件发生时,程序能够立即采取相应的行动,而不需要等待其他操作的完成。这对于需要快速交互和实时更新的应用场景非常重要,例如图形用户界面(GUI)、网络通信等。

  3. 高并发处理: 在异步环境中,多个事件可以并发地发生和处理。事件驱动模型使得程序能够有效地处理大量的并发操作,而不会因为等待某个操作的完成而导致整个程序的性能下降。

想象你在举办一个烧烤聚会。在传统的同步模式下,作为主人的你可能需要一件一件事地处理:先点燃烧烤炉,等炭完全燃烧好后再烤肉,最后才能分发给客人享用。这个过程是线性和顺序的,每一步都需要前一步完成后才能进行下一步。

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

现在,如果采用事件驱动的模式,整个烧烤派对的流程会变得更加高效和互动。这里是如何操作的:

  1. 点燃烧烤炉:你点火后不需要站在那里等待炭完全燃烧,你可以设置一个“炭燃烧就绪”的提醒器。

  2. 准备其他食物:在炭燃烧的同时,你可以开始准备其他食物,比如沙拉或冷饮。

  3. 互动:你的朋友或家人可以加入准备过程,例如,有人可以负责检查饮料是否充足,另一人则准备餐具。 当“炭燃烧就绪”的提醒器响起时,意味着炭已经准备好,可以开始烧烤肉类了。你或一个指定的朋友开始烧烤,同时,其他人可以继续处理他们的任务,比如设置桌子或与客人互动。

这样的方式使得每个人都可以在他们自己的节奏下进行工作,而不是等待一个单一的任务完成。事件驱动模式使每个参与者都能及时响应各自的“事件”,整个派对的效率和互动性大大提高。

在 Node.js 中,事件可以由各种来源生成,如用户输入、网络请求、文件系统操作、计时器或外部服务。当事件发生时,Node.js 会触发关联的事件处理程序,允许程序对该事件做出反应。

为了实现事件驱动编程的概念,Node.js 通过 EventEmitter 类提供了事件发射器模式。

Event emitter

事件发射器模式(又称观察者模式)是一种广泛使用的软件设计模式,它便于不同组件或对象在事件驱动系统中进行通信和交互。它提供了一种机制,允许一个对象(事件发射器)在特定事件或行为发生时通知其他对象(事件监听器或订阅者)。

事件发射器模式包含以下关键组件:

  1. Event Emitter:Event Emitter 是一个可以发出事件的对象。它提供了注册事件监听器、发出事件和管理订阅的方法。事件发射器负责跟踪已注册的事件监听器,并在适当的时候同步地向它们派发事件。

  2. Event:事件代表可以触发通知的特定动作或发生的事情。它可以是预定义的(例如,‘click’、‘change’、‘request’)或根据应用程序的需求自定义定义。

  3. Event Listener/Subscriber:事件监听器或订阅者是对接收特定事件的通知感兴趣的对象或函数。它们向事件发射器注册,并提供当相应事件发生时执行的回调函数或事件处理器。

Node.js 的大多数核心模块都是用 EventEmitter 接口构建的。例如,net.Server 的实例在新建连接时发出 connection 事件,在 stream.Readable 中,每当有数据可供处理时,就会发出 data 事件。

libuv

Libuv 通过事件循环和线程池为常见 I/O 操作(即使它们本质上是同步的)提供异步 API。这就是 Node.js 可以单线程并同时处理多个请求的方式。

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

Libuv 概述

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

libuv 是一个跨平台的 C 库,用于支持 Node.js 的异步 I/O 操作。它为 Node.js 提供了事件循环和异步 I/O 支持,是 Node.js 能够实现高性能和非阻塞 I/O 操作的关键组件。libuv 的设计采用了 Reactor 模式,使其能够高效地处理并发事件。下面将详细介绍 libuv 的主要功能和架构组成。

libuv 的架构主要由以下几部分组成:

  1. 事件循环 (Event Loop): 所有异步活动的中心,负责调度和执行所有异步事件和操作。

  2. 事件分离器 (Event Demultiplexer): 这是 libuv 与操作系统交互的部分,用于等待各种类型的 I/O 操作变得可用。它是实现非阻塞 I/O 操作的关键。

  3. 请求对象 (Request Objects): 代表一个异步操作,负责追踪操作的状态和其他详细信息。

  4. 句柄对象 (Handle Objects): 代表长期存在的资源,如打开的文件或网络套接字。

  5. 线程池 (Thread Pool): 用于执行阻塞的系统操作,如文件系统调用,这些调用通过线程池异步执行,以防止阻塞主事件循环。

在 libuv 的工作流程中,当 Node.js 应用程序执行一个异步操作时,如读取文件,libuv 将这个请求放入事件循环,并可能将其转移到线程池。然后,事件循环继续处理其他任务。一旦异步操作完成,操作系统将通知 libuv,libuv 则将对应的完成事件放入事件队列。在事件循环的下一个迭代中,对应的回调函数被执行。

如果没有待处理的异步操作或未处理的回调,事件循环将停止,Node.js 将自动退出。

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

Event demultiplexer:事件分离器的主要作用是监控一组 I/O 源(如套接字、文件描述符等),并通知应用程序哪些 I/O 源已准备好进行读取或写入操作。这样,应用程序可以在不阻塞其它操作的情况下,有效地响应 I/O 事件。

在上图中,主要描述了 libuv 是如何通过事件循环来处理 I/O 请求的流程,它的具体步骤如下所示:

  1. 接收 I/O 请求:应用程序发起一个异步 I/O 操作。

  2. 传递给事件循环:该请求被传递到事件循环。

  3. 注册句柄:事件循环使用事件分离器(Event demultiplexer)来注册一个句柄(handle),以便跟踪 I/O 请求的状态。

  4. 操作系统层面的 I/O:事件分离器通过 epoll(在 Linux)、kqueue(在 macOS)、IOCP(在 Windows)或者线程池等待 I/O 请求完成。

  5. 完成状态:一旦 I/O 操作准备好或完成,事件分离器会收到通知。

  6. 将完成的事件入队:完成的事件被放入事件队列中。

  7. 从队列中取事件:事件循环从事件队列中取出事件以进行处理。

  8. 调用事件处理器(Process the handler):与事件相关联的处理函数(事件处理器)被调用。

  9. 处理事件:

    • 事件处理器处理 I/O:应用程序代码中定义的事件处理器处理 I/O。
    • 处理完成:I/O 操作的处理完成,并且控制权返回到事件循环,以便它可以处理下一个事件。

这个流程允许 Node.js 应用程序高效地处理大量的并发 I/O 操作,同时保持非阻塞的特性,并在单个线程上运行,避免了多线程编程的复杂性。

事件循环

事件循环是 Node.js 的核心,它负责管理和执行异步操作、事件处理,并确保 I/O 操作的非阻塞特性。

可以将其视为一个无限循环,不断检查事件并执行相应的回调或事件处理器。只有当没有活跃的异步操作,如待处理的回调、定时器或上一次迭代中的操作时,事件循环才会停止。

当同步代码执行时,事件循环不会运行。事件循环只有在调用堆栈为空时才会开始执行回调(事件处理器)。

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

上图是 NodeJs 时间循环的流程图,充分展示了事件循环的各个阶段及其顺序,其流程如下:

这个图是 Node.js 事件循环的流程图,展示了事件循环的各个阶段及其顺序。流程如下:

  1. 初始化循环时间:事件循环开始时,会设置一个起始时间,用于跟踪和管理定时器的执行。

  2. 执行到期的定时器:检查并执行已经到期的 setTimeout 和 setInterval 定时器回调。

  3. 检查循环是否存活:判断事件循环是否有待处理的任务。如果没有,那么事件循环将结束;如果有,事件循环将继续执行。

  4. 调用待处理的回调:执行异步操作完成后的回调函数,比如一些 I/O 操作的回调。

  5. 运行空闲句柄:执行空闲(idle)句柄的回调,这通常用于特殊的调度和背景任务。

  6. 运行准备句柄:执行准备(prepare)句柄的回调,这通常是内部使用,准备下一个阶段的操作。

  7. 轮询 I/O:等待新的 I/O 事件,执行 I/O 相关的回调,除了那些被设定为 setImmediate 的回调。

  8. 运行检查句柄:执行 setImmediate() 回调,这些是在轮询阶段之后立即执行的。

  9. 调用关闭回调:执行关闭操作的回调函数,如 socket.on('close', callback)。

  10. 更新循环时间:为了管理定时器,更新事件循环的内部时间。

  11. 再次运行到期的定时器:回到第二步,继续执行新到期的定时器回调。

这个循环会不断重复,直到没有更多的任务需要处理,事件循环随后结束,如果是 Node.js 程序,则可能导致程序退出。这个流程图非常清晰地展示了事件循环的周期性质和它如何处理不同类型的任务。

整个事件循环的执行主体是在 uv_run 中,如下所示:

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int can_sleep;

  r = uv__loop_alive(loop);
  if (!r)
    uv__update_time(loop);

  /* Maintain backwards compatibility by processing timers before entering the
   * while loop for UV_RUN_DEFAULT. Otherwise timers only need to be executed
   * once, which should be done after polling in order to maintain proper
   * execution order of the conceptual event loop. */
  if (mode == UV_RUN_DEFAULT && r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop);
    uv__run_timers(loop);
  }

  while (r != 0 && loop->stop_flag == 0) {
    can_sleep =
        uv__queue_empty(&loop->pending_queue) &&
        uv__queue_empty(&loop->idle_handles);

    uv__run_pending(loop);
    uv__run_idle(loop);
    uv__run_prepare(loop);

    timeout = 0;
    if ((mode == UV_RUN_ONCE && can_sleep) || mode == UV_RUN_DEFAULT)
      timeout = uv__backend_timeout(loop);

    uv__metrics_inc_loop_count(loop);

    uv__io_poll(loop, timeout);

    /* Process immediate callbacks (e.g. write_cb) a small fixed number of
     * times to avoid loop starvation.*/
    for (r = 0; r < 8 && !uv__queue_empty(&loop->pending_queue); r++)
      uv__run_pending(loop);

    /* Run one final update on the provider_idle_time in case uv__io_poll
     * returned because the timeout expired, but no events were received. This
     * call will be ignored if the provider_entry_time was either never set (if
     * the timeout == 0) or was already updated b/c an event was received.
     */
    uv__metrics_update_idle_time(loop);

    uv__run_check(loop);
    uv__run_closing_handles(loop);

    uv__update_time(loop);
    uv__run_timers(loop);

    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }

  /* The if statement lets gcc compile it to a conditional store. Avoids
   * dirtying a cache line.
   */
  if (loop->stop_flag != 0)
    loop->stop_flag = 0;

  return r;
}

这段代码是 libuv 的 uv_run 函数,它是事件循环的主体执行函数。该函数的作用是根据提供的模式(mode),在给定的事件循环(loop)中执行异步任务。下面是代码的具体执行流程:

  1. 判断事件循环是否存活:使用 uv__loop_alive 函数检查事件循环中是否有活跃的任务(如异步 I/O、定时器、准备好的句柄等)。

  2. 初始化和更新时间:如果事件循环不活跃,则使用 uv__update_time 更新内部时间。如果事件循环是活跃的,并且是默认模式(UV_RUN_DEFAULT),则在进入主循环之前更新时间并执行到期的定时器。

  3. 主循环:while 循环是事件循环的主体,它会在还有活跃的任务并且没有停止标志的情况下继续执行。

  4. 判断是否可以休眠:通过检查挂起队列和空闲句柄队列是否为空,来决定事件循环是否可以在没有即时任务处理时进入休眠状态。

  5. 处理挂起的任务:调用 uv__run_pendinguv__run_idleuv__run_prepare 分别运行挂起的回调、空闲句柄和准备句柄。

  6. 轮询 I/O:

    • 计算超时时间,如果是 UV_RUN_ONCE 模式且可以休眠,或者是 UV_RUN_DEFAULT 模式,则调用 uv__backend_timeout 获取超时时间。

    • 调用 uv__io_poll 轮询 I/O 事件,并根据超时时间等待事件。

  7. 避免事件循环饥饿:如果挂起队列不为空,则反复运行 uv__run_pending 以处理立即回调,避免事件循环由于处理过多非 I/O 任务而饿死。

  8. 更新指标和执行检查与关闭任务:

    • 调用 uv__metrics_update_idle_time 更新空闲时间指标。

    • 运行 uv__run_check 执行检查句柄的任务。

    • 运行 uv__run_closing_handles 执行关闭句柄的任务。

  9. 再次更新时间和运行定时器:更新时间并运行到期的定时器。

  10. 检查事件循环状态: 再次调用 uv__loop_alive 检查事件循环是否还有活跃的任务。

  11. 控制循环终止:根据运行模式,可能在执行一次后(UV_RUN_ONCE)或不等待事件立即返回(UV_RUN_NOWAIT)时退出循环。

  12. 重置停止标志:如果设置了停止标志,则在循环结束时重置该标志。

函数最后返回一个整数 r,这个返回值表示事件循环在函数退出时是否还有活跃的任务。如果 r 非零,表示事件循环还活跃;如果 r 为零,则表示事件循环中没有更多的任务,并且 libuv 可能会终止事件循环。这个函数控制着 Node.js 中所有异步活动的生命周期。

事件循环的概述如下流程图所示:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

在 Node.js 的事件循环中,有六个主要阶段,每个阶段具体功能如下:

  1. 定时器:处理由 setTimeout() 和 setInterval() 安排的时间到期的回调。

  2. 待处理回调:处理在轮询阶段完成后需要延后执行的 I/O 操作的回调。

  3. 空闲和准备:Node.js 执行 libuv 内部操作,并进行轮询阶段的前置准备工作。

  4. 轮询:计算 I/O 阻塞时间,并在 I/O 事件准备就绪时执行相关回调,这是事件循环中对 I/O 操作响应的关键阶段。

  5. 检查:立即执行通过 setImmediate() 安排的回调。

  6. 关闭回调:执行与关闭操作相关的回调,如监听 socket 的 'close' 事件的回调。

timers

定时器用于设定一个时间阈值,一旦过去这段时间,就可以执行相应的回调函数。定时器不能保证回调函数精确在预定时间执行,但会尽可能在该时间之后最早可行的时刻运行。不过,操作系统的任务调度或是先前的回调处理可能会导致定时器回调的执行有所延迟。

const fs = require("node:fs");

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile("/path/to/file", callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

当事件循环进入轮询阶段时,它有一个空队列( fs.readFile() 尚未完成),因此它将等待剩余的毫秒数,直到达到最快计时器的阈值。在等待 95 毫秒通过时, fs.readFile() 完成读取文件,其回调(需要 10 毫秒完成)被添加到轮询队列并执行。回调完成后,队列中不再有回调,因此事件循环将看到已达到最快计时器的阈值,然后回循环到计时器阶段以执行计时器的回调。在此示例中,你将看到调度计时器与执行其回调之间的总延迟为 102 毫秒或者 101 毫秒。

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

为了防止轮询阶段占用事件循环过多资源,libuv(实现了 Node.js 事件循环和平台所有异步行为的 C 库)还设置了一个硬性的最大值(依赖于系统),超过这个最大值之后,它将停止轮询更多的事件。

pending callbacks

Pending callbacks 阶段主要涉及系统操作的回调。这一阶段执行一些系统级别的回调,例如 TCP 错误类型的操作。例如,如果一个 TCP socket 在尝试连接时接收到 ECONNREFUSED 错误,那么相应的回调将在这个阶段被执行。

在大多数情况下,这些回调是由系统底层操作触发的,主要是那些不适合在其他阶段执行的 I/O 回调。比如,某些重新尝试连接的操作,或者是某些特殊情况下的错误处理。

poll

Poll 阶段是事件循环中最重要的一部分,因为它处理了大量的 I/O 事件。在这个阶段,Node.js 会查看是否有 timer(定时器)到期,以及执行 I/O 回调。

  • 处理到期的 timer:如果存在到期的 timer(如 setTimeout 或 setInterval 设定的时间已到),Node.js 将会优先处理这些事件,然后执行相应的回调。

  • 执行 I/O 回调:除了 timer 之外,poll 阶段还负责处理其他类型的 I/O 回调(如文件读写、网络通信等),这些回调是由底层 OS 异步提供的。

Poll 阶段也会决定事件循环是否继续运行。如果 poll 队列为空(即没有更多的事件处理),并且没有设定任何将来的 timer,Node.js 事件循环可能会退出。但如果 poll 队列不为空或有即将到期的 timer,Node.js 将继续执行队列中的回调。

check

check 阶段是事件循环的一个关键组成部分,专门用于处理由 setImmediate() 调度的回调函数。这个阶段允许开发者在当前轮询周期尽快执行代码,而不必等待下一个事件循环周期。

事件循环在处理完所有的 I/O 事件(如网络通信、文件 I/O 等)后,如果存在通过 setImmediate() 安排的回调,就会进入 check 阶段。在这个阶段,事件循环将执行所有排队的 setImmediate() 回调。

与 Timers 相比:setTimeout() 或 setInterval() 安排的回调会在 timers 阶段执行,它们基于指定的延迟时间触发。相反,setImmediate() 安排的回调无需等待特定的时间,只是等待当前轮询周期的活动处理完成。

与 Poll 相比:Poll 阶段主要处理 I/O 操作的回调。如果在 Poll 阶段没有可执行的 I/O 操作或 timer 到期,事件循环将检查是否有 setImmediate() 回调等待执行,并进入 check 阶段。

它的使用场景主要有以下两个方面:

  1. 异步操作后的立即处理:在执行完一系列异步操作后,可能需要立即执行某些清理或后续任务,使用 setImmediate() 可以确保这些任务在当前事件循环迭代结束后立即开始执行。

  2. 避免阻塞:对于可能会长时间运行的操作,可以分割任务,并使用 setImmediate() 来避免阻塞事件循环,从而提高应用的响应性。

close callbacks

close callbacks 阶段是处理一些需要立即关闭的操作的阶段。这个阶段专门用于执行那些因为关闭操作而触发的回调,比如 socket 或 handle 被突然关闭时的回调。

当 Node.js 中的某个对象(如 TCP sockets、文件描述符等)被关闭并且需要进行一些清理操作时,相关的关闭事件会在这个阶段被处理。这包括执行任何注册到这些对象的 close 事件监听器。

close callbacks 阶段专注于处理因为资源被关闭而需要立即执行的操作。这与其他阶段(如 poll 或 check)处理的常规 I/O 或延时操作不同,close callbacks 更多的是与资源释放和异常处理相关联。

微任务和宏任务

为了跟踪与异步操作相关的所有处理程序,事件循环使用了每个阶段的队列。对我们 Node.js 开发者来说,有 6 种主要类型的队列很重要。我们可以将其分为两类 —— 宏任务队列和微任务队列。

  1. 宏任务:定时器队列(setTimeout() 和 setInterval() 的回调),I/O 队列(用于网络/磁盘/子进程的回调),检查队列(setImmediate() 的回调),关闭队列(关闭事件的回调)。

  2. 微任务:包含 nextTickQueue(process.nextTick() 的回调)和 promises 队列(Promise 的回调)。它优先级高于宏任务队列,在每个宏任务队列之间执行。从 Node.js v11 版本开始,nextTickQueue 将在每个立即和定时回调之后运行。

这些队列的执行顺序:nextTick -> Promises -> 定时器 -> I/O -> check -> close。

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

nextTick 与 setImmediate

process.nextTick() 和 setImmediate() 是 Node.js 中两个非常重要的函数,它们都用于安排回调函数在特定时机执行,但它们的执行时机和使用场景有显著的不同。

process.nextTick()

process.nextTick() 函数将回调函数放入 nextTickQueue,这是一个微任务队列。这意味着 process.nextTick() 的回调会在 Node.js 事件循环的当前阶段之后、下一个阶段之前执行。无论你在事件循环的哪个阶段调用 process.nextTick(),它的回调总是在当前操作完成后、任何 I/O 事件(包括定时器)或其他宏任务发生之前执行。

process.nextTick()的特点主要有以下三个方面:

  1. process.nextTick() 允许开发者确保在继续下一个事件循环阶段之前,立即处理给定的回调。

  2. 这种机制可以用来处理错误,清理不需要的资源,或者在事件循环继续之前做一些紧急的调整。

  3. 使用 process.nextTick() 可能会导致 I/O 饿死,因为如果递归调用 nextTick(),它会无限制地增加回调到 nextTickQueue,从而可能导致事件循环永远到达不了 poll 阶段。

setImmediate()

setImmediate() 函数将回调函数放入 check 队列,这是一个宏任务队列。setImmediate() 的回调会在当前轮询阶段的所有 I/O 事件处理完成后执行。如果在一个 I/O 循环(如从文件读取数据)之后调用 setImmediate(),其回调会在所有排队的 I/O 事件处理之后、任何由 setTimeout() 或 setInterval() 安排的定时器之前执行。

  1. setImmediate() 设计用来检查 poll 阶段后的操作,它在事件循环的 check 阶段执行。

  2. 这个函数适合于需要确保执行顺序的场景,特别是在执行了一系列 I/O 操作后。

  3. 与 process.nextTick() 相比,setImmediate() 更少可能阻塞 I/O 操作,因为它属于事件循环的后续阶段。

总的来说,process.nextTick() 适用于在当前事件循环迭代中尽可能早地运行代码,而 setImmediate() 适合于在当前事件循环的所有 I/O 操作或定时器之后执行代码。两者的选择取决于你的具体需求:如果需要立即执行代码以确保顺序或逻辑正确性,使用 process.nextTick();如果你想在当前事件循环的其他任务完成后执行代码,以避免阻塞 I/O 或其他计时器事件,使用 setImmediate()。

相关案例

由于 nextTick 具有插队的机制,nextTick 的递归会让事件循环机制无法进入下一个阶段. 导致 I/O 处理完成或者定时任务超时后仍然无法执行, 导致了其它事件处理程序处于饥饿状态. 为了防止递归产生的问题, Node.js 提供了一个 process.maxTickDepth (默认 1000)

const fs = require("fs");

function wait(time) {
  let date = Date.now();
  while (Date.now() - date < time) {
    //
  }
}

function nextTick() {
  process.nextTick(() => {
    wait(20);
    console.log("nextTick");
    nextTick();
  });
}

const lastTime = Date.now();

setTimeout(() => {
  console.log("timers", Date.now() - lastTime + "ms");
}, 0);

nextTick();

如下图所示:

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

此时永远无法跳到 timer 阶段去执行 setTimeout 里面的回调方法, 因为在进入 timers 阶段前有不断的 nextTick 插入执行. 除非执行了 1000 次到了执行上限,所以上面这个案例会不断地打印出 nextTick 字符串。

setTimeout 与 setImmediate

setTimeout()

setTimeout() 函数用于在指定的延时后执行一个回调函数。延时是以毫秒为单位的,这使得你可以精确控制何时执行代码。setTimeout() 的行为跨平台相对一致,使其成为跨环境编程的一个可靠选择。

setTimeout() 可以设定一个明确的延迟时间,即使设置为 0 或非常短的时间,Node.js 也不保证立即执行,因为它受到事件循环中其他活动(如 I/O 事件处理)的影响。

setImmediate()

setImmediate() 函数用于安排在当前事件循环周期的 I/O 事件处理之后立即执行一个回调。这意味着它通常用于在执行栈清空后、但在事件循环继续之前执行代码。

setImmediate() 安排的任务是在当前事件循环周期的所有 I/O 事件处理之后执行,它更适合于需要立即但不紧急执行的任务。

相关案例

如下代码所示:

setTimeout(() => {
  console.log("setTimeout");
});

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

最终输出结果如下图所示:

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

在同一个事件循环阶段,setImmediate() 与 setTimeout() 设为 0 的行为可能会因 Node.js 的版本和运行环境的不同而有所不同。一般来说,setImmediate() 和 setTimeout() 设为 0 的回调执行顺序不可预测,因为它取决于事件循环的具体性能表现,如回调注册的时间点和它们之间的相对时间差。

setTimeout(..., 0) 旨在尽快在计时器阶段执行,但是 0 实际上不代表“立即”执行;它只是告诉 JS 引擎尽可能快地调度回调,最小延迟约为 1 毫秒(取决于环境)。

首先进入的是 timers 阶段,如果我们的机器性能一般,如果我们的机器性能一般,那么进入 timers 阶段,1ms 已经过去了,那么就是说 setTimeout(fn,0) == setTimeout(fn,1),并且在进入 timers 阶段时已经超过 1 毫秒,这个定时器就会被执行。如果还不足 1 毫秒,定时器不会执行。

如果 timers 阶段的定时器都已处理或者没有定时器到期,事件循环进入 poll 阶段。在 poll 阶段,Node.js 会检查是否有新的 I/O 事件,如果有,它将等待这些事件的回调执行完毕。如果没有等待处理的 I/O 事件,事件循环将检查 setImmediate 的队列。

如果 poll 阶段之后队列为空,事件循环进入 check 阶段,这里执行所有通过 setImmediate() 安排的回调。

在处理完 setImmediate() 的回调后,如果还有时间,事件循环可能会回到 timers 阶段开始新一轮的检查。

总的来说,如果在进入 timers 阶段时,1 毫秒已经过去,那么 setTimeout(fn, 0) 的回调将会首先执行。如果不足 1 毫秒,那么事件循环将继续到 poll 阶段,可能直接过渡到 check 阶段执行 setImmediate() 的回调,之后再在下一个事件循环周期处理 setTimeout 的回调。

nextTick 与 Promise

process.nextTick() 会在当前宏任务的末尾和下一个宏任务的开始之前执行,且在所有微任务之前执行。这意味着 process.nextTick() 总是优先于 Promise 回调执行。

Promise 回调作为微任务,会在所有由 process.nextTick() 安排的任务之后、在同一事件循环迭代的下一个宏任务之前执行。

直接上代码:

process.nextTick(() => {
  console.log("nextTick1");
});

new Promise((resolve, rej) => {
  resolve(1);
}).then((res) => {
  console.log(res);
});

最终输出结果如下图所示:

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

最终案例

接下来我们将使用一段代码来对事件循环做一个总结:

console.log("timeout0");

new Promise((resolve, reject) => {
  resolve("resolved");
}).then((res) => console.log(res));

new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("timeout resolved");
  });
}).then((res) => console.log(res));
process.nextTick(() => {
  console.log("nextTick1");
  process.nextTick(() => {
    console.log("nextTick2");
  });
});

process.nextTick(() => {
  console.log("nextTick3");
});

console.log("sync");

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

最终代码输出结果如下图所示:

NodeJs的事件循环和浏览器的事件循环区别可大着咧✌️✌️✌️

它的执行顺序如下所示:

  1. 同步输出 timeout0" 和 sync:

    • timeout0
    • sync
  2. process.nextTick 的所有回调:

    • nextTick1
    • nextTick2
    • nextTick3
  3. 所有微任务(Promise.then 回调):

    • resolved
  4. 所有 timers 回调(setTimeout):

    • timeout2

    • 一段时间后(依赖于具体的 setTimeout 延迟执行),"timeout resolved" 将在其自己的事件循环迭代中打印。

参考资料

总结

Node.js 的事件循环包含多个明确的阶段,如 timers、poll 和 check,专门设计用于处理不同类型的 I/O 和计时任务,同时提供 process.nextTick() 和 setImmediate() 这样的特有函数来允许开发者精确控制代码的执行时机。这样的设计支持了 Node.js 在后端服务中处理大量并发 I/O 操作的能力。

浏览器的事件循环则主要区分为宏任务和微任务,用来协调用户界面事件、脚本执行、渲染任务等。这确保了用户界面的高响应性和平滑的视觉效果。浏览器事件循环还密切关注页面的渲染时机,确保在适当的时候更新视图,从而优化用户的交互体验。

转载自:https://juejin.cn/post/7361059833263259698
评论
请登录