【面试官】:NodeJs事件循环你了解多少?我笑了,让我喝口水慢慢给你说来......

点个关注不迷路!公众号【码间舞】会持续更新技术干货,感谢大家支持,欢迎点赞、评论、留言。

引题

浏览器里的Js事件循环大家都没有问题,面试中被问起来也是能头头是道。

那么,NodeJs你是否熟悉,又是否能说清楚它的事件循环机制呢?都说它是非阻塞I/O和异步操作的,那么怎么理解呢?今天讲讲NodeJs的事件循环

NodeJs事件循环

Node.js 的事件循环(Event Loop)是它实现非阻塞 I/O 和异步操作的核心机制。它允许 Node.js 在单线程中高效地处理大量并发操作(如网络请求、文件读写等),而无需等待这些操作完成。

和传统后端语言的区别

这里说明一下,可能对于纯前端开发来说,这个很正常,没啥好奇怪的,不就是异步嘛,做完了放进任务队列里等主进程做完事儿了看看有没有任务,有就执行,没有拉倒继续等待。但是在传统的后端开发语言里,比如Java、PHP等,其实代码实按照顺序执行的(多线程除外),无论你是网络请求,I/O操作,文件读取/数据库操作等,遇到这些操作会挂起当前线程,等待执行完成后再继续执行。

一起看看NodeJs的关键组成部分

  1. 单线程与异步 API

    • JavaScript 本身是单线程的,意味着它一次只能执行一段代码。
    • Node.js 的许多核心 API(如 fs.readFile, http.request, setTimeout)是异步的。当你调用它们时,它们会立即返回,将耗时的操作(I/O)委托给 Node.js 底层(由 C++ 编写的 libuv 库)去执行,而 JavaScript 线程可以继续执行后面的代码。
  2. 事件循环的角色

    • 事件循环就像一个永不停止的调度员或循环。
    • 它的核心工作是:持续检查调用栈(Call Stack)是否为空,并在为空时,从任务队列(Callback Queues)中取出回调函数(Callbacks)放到调用栈中执行。
  3. 关键步骤

    • 调用栈: 跟踪当前正在执行的函数(后进先出)。执行同步代码的地方。
    • Node.js APIs: 提供异步功能(fs, http, timers, crypto 等)。当调用这些 API 时,它们启动操作并将回调函数注册到事件循环。
    • 任务队列(Callback Queues): 存放着等待执行的回调函数。事件循环按特定顺序检查多个队列:
      • 微任务队列: 优先级最高。
        • process.nextTick():Node.js 特有,优先级最高。
        • Promise 的回调 (then/catch/finally):优先级次高。
      • 宏任务队列: 优先级低于微任务。
        • Timers 队列:存放 setTimeout 和 setInterval 的回调。
        • Pending I/O Callbacks 队列:存放已完成 I/O 操作(如文件读取完成、网络请求收到响应)的回调。
        • Idle/Prepare 队列:内部使用。
        • Poll 阶段:检索新的 I/O 事件;执行 I/O 相关的回调(大部分 I/O 回调在这里执行);同时也会检查 Timers 队列中是否有到期的定时器回调。
        • Check 队列:存放 setImmediate() 的回调。
        • Close Callbacks 队列:存放 close 事件的回调(如 socket.on('close', ...))。
  4. 事件循环的执行流程(简化版)

    1. 执行同步代码: 初始化脚本,执行所有顶层同步代码。这可能调用异步 API。
    2. 处理微任务(高优先级):
      • 检查 nextTick 队列,执行其中所有回调。
      • 检查 Promise 队列,执行其中所有回调。
      • 重复此过程直到两个微任务队列都清空。
    3. 进入事件循环主阶段(按顺序检查各宏任务队列):
      • Timers 阶段: 检查 setTimeout 和 setInterval 设定的时间是否到期,到期则执行其回调。
      • Pending I/O Callbacks 阶段: 执行除关闭回调、定时器回调和 setImmediate() 之外的大部分 I/O 回调(例如文件读取完成、网络请求响应到达的回调)。
      • Poll 阶段(核心):
        • 如果 Poll 队列不为空:依次执行队列中的回调直到清空或达到系统限制。
        • 如果 Poll 队列为空:
          • 检查是否有 setImmediate() 安排的回调。如果有,结束 Poll 阶段,进入 Check 阶段。
          • 如果没有 setImmediate(),则在此阶段等待新的 I/O 事件到达(阻塞在此处,直到有事件发生)。同时也会检查 Timers 队列中是否有到期定时器,如果有则跳回 Timers 阶段执行。
      • Check 阶段: 执行 setImmediate() 的回调。
      • Close Callbacks 阶段: 执行 close 事件的回调(如 socket.destroy())。
    4. 回到起点: 完成一个循环后,再次检查微任务队列(执行所有 nextTick 和 Promise 回调),然后开始下一个循环(从 Timers 阶段开始)。
  5. 核心原则

    • 非阻塞: 异步操作不会卡住主线程。
    • 事件驱动: 当异步操作完成(事件发生)时,其回调被放入队列等待执行。
    • 单线程处理并发: 事件循环通过高效地在回调之间切换来处理大量并发请求。
    • 优先级: process.nextTick > Promise > Timers > Pending I/O > setImmediate > Close Callbacks。微任务在每个阶段结束后、下一个阶段开始前都会执行。(这里特别说明:procee.nextTick的优先级最高(比Promise还高),并且也是在任意事件循环切换的时候都会执行)

一个不恰当的比喻

想象一个餐厅(Node.js 应用)只有一个服务员(JavaScript 线程)和很多顾客(请求)。

  • 同步点餐: 顾客点一个简单的菜(同步操作),服务员立刻记下(执行),顾客等着上菜(阻塞)。
  • 异步点餐: 顾客点一个复杂的菜(异步 I/O,比如现烤披萨)。
    • 服务员把订单交给厨房(libuv/系统线程池),然后立刻去服务下一位顾客(非阻塞)。
    • 厨房(后台线程)开始做披萨。
    • 当披萨做好(事件完成),厨房会把做好的披萨(回调函数)放在出餐口(任务队列)。
    • 服务员(事件循环)在服务完当前顾客后,会不断检查出餐口(事件循环的各个阶段)。看到披萨好了,就立刻端给那位顾客(执行回调函数)。

事件循环就是那个服务员高效地在顾客之间穿梭、检查订单状态并上菜的工作流程。

理解事件循环对于编写高效、无阻塞的 Node.js 代码至关重要,尤其是在处理高并发场景时。

报告!我有问题!

看到上面的简单讲解后,你是否有些疑问?

在这个事件循环中的poll阶段里,poll的队列存放的是哪些事件的回调?

在 Node.js 的事件循环中,Poll(轮询)阶段的队列存放的是已完成或新到达的 I/O 事件的回调函数。这些 I/O 操作通常包括:

  1. 文件系统操作(fs模块)的回调

    • 例如:fs.readFile(), fs.writeFile(), fs.readdir() 等操作完成后的回调。
    • 当文件读取/写入结束时,其回调会被放入 Poll 队列。
  2. 网络 I/O 的回调

    • 例如:HTTP 请求响应(http/https 模块)、TCP/UDP 套接字(net/dgram 模块)的数据到达事件。
    • 当服务器收到客户端请求或数据包时,关联的 'data', 'connection' 等事件的回调会进入 Poll 队列。
  3. 操作系统级 I/O 事件

    • 如管道(pipes)、子进程(child process)通信(process.stdin, child_process.stdout)等操作完成的回调。
  4. 部分系统信号的回调

    • 某些与 I/O 相关的系统信号(如 SIGIO)的处理函数。

Poll 阶段的工作逻辑:

  1. 检查 Poll 队列是否为空:

    • 如果队列不为空:同步执行队列中的所有回调(直到队列清空或达到系统限制)。
    • 如果队列为空:事件循环会在此阶段等待新的 I/O 事件到来(此时线程可能阻塞)。同时做两件事:
      • 检查是否有到期的定时器(setTimeout/setInterval),如果有则跳转到 Timers 阶段执行定时器回调。
      • 检查是否有 setImmediate() 安排的回调,如果有则结束 Poll 阶段,进入 Check 阶段。
  2. 超时机制: Poll 阶段的阻塞等待不是无限的。它受以下因素影响:

    • 下一个定时器的到期时间(避免错过定时器回调)。
    • 是否存在 setImmediate() 回调(如果有,则减少等待时间)。

报告!我还有问题!

那poll阶段和Pending I/O Callbacks 阶段是否存在冲突呢,这两个阶段有什么区别?

Poll 阶段和 Pending I/O Callbacks 阶段确实容易让人困惑,因为它们本质上处理的都是 I/O 事件回调,但处于不同的生命周期阶段和错误处理流程中。理解它们的区别关键在于理解 I/O 操作的完成状态和事件循环的错误处理机制。

  1. Pending I/O Callbacks 阶段 (待处理的 I/O 回调)
    • 目的: 专门处理 上一轮循环中某些 I/O 操作尝试执行时发生非致命错误或需要延迟执行的回调。
    • 存放的回调: 主要是操作系统报告的错误回调或某些需要延迟到下一轮循环处理的特殊回调。常见例子:
      • TCP 错误: 例如尝试连接一个拒绝连接的端口 (ECONNREFUSED),或者写操作时对方关闭了套接字 (ECONNRESET, EPIPE)。
      • DNS 解析错误: 查询一个不存在的域名 (ENOTFOUND)。
    • 关键特性:
      • 错误处理导向: 这个队列主要处理操作失败的回调。
      • 上一轮遗留: 存放的是上一轮事件循环中发生的、但被标记为需要延迟到Pending阶段处理的操作结果。
  2. Poll 阶段 (轮询阶段)
    • 目的: 处理 本轮循环中成功完成或新到达的 I/O 事件回调。这是 I/O 回调处理的主力军。
    • 存放的回调: 成功完成的 I/O 操作的回调。常见例子:
      • 文件读取完成 (fs.readFile 回调)。
      • HTTP 服务器收到完整请求 ('request' 事件回调)。
      • Socket 接收到新数据 ('data' 事件回调)。
      • 数据库查询成功返回结果 (数据库驱动回调)。
      • 成功的 DNS 解析结果。
    • 关键特性:
      • 成功处理导向: 主要处理操作成功完成的回调。
      • 本轮核心: 是事件循环的核心阶段,负责收集并执行本轮新产生的、准备好的 I/O 回调。
      • 主要队列: 绝大多数正常的、成功的 I/O 回调都在此阶段执行。
      • 阻塞等待: 如果队列为空,事件循环会在此阶段阻塞等待新的 I/O 事件到来(这是 Node.js 实现高性能的关键之一,避免 CPU 空转)。
      • 调度枢纽: 在此阶段会检查定时器是否到期 (决定是否跳回 Timers 阶段) 和 setImmediate 是否存在 (决定是否进入 Check 阶段)。
特性 Pending I/O Callbacks 阶段 Poll 阶段
主要目的 处理上一轮遗留的I/O 错误/延迟回调 处理本轮成功完成/新到达的I/O 回调
回调状态 失败或需要延迟处理的操作 成功完成的操作
生命周期 处理上一轮循环发生的事件 处理本轮循环发生的事件
队列常见内容 TCP错误 (ECONNREFUSED, EPIPE等), DNS错误 文件读取完成, 网络请求到达, 数据库响应等
在事件循环中的角色 错误处理/边缘情况处理 核心 I/O 处理主力
队列是否常空 通常是空的 (除非频繁遇到特定错误) 是 I/O 密集型应用的主要工作队列
在循环中的位置 Timers 阶段之后, Poll 阶段之前 Pending 阶段之后, Check 阶段之前
阻塞行为 不会阻塞 会阻塞等待新 I/O 事件 (当队列空时)

实际上,这两个阶段不存在功能上的冲突。它们是事件循环处理 I/O 回调流水线上的不同环节,处理的是 I/O 操作不同状态(失败/成功) 和不同时间点(上一轮/本轮) 的回调。

Pending 阶段是 I/O 操作的错误处理路径。当一个 I/O 操作在底层 (libuv) 执行过程中遇到特定类型的错误(通常是操作系统报告的错误),这个错误信息及其回调不会直接进入 Poll 队列,而是被安排到下一轮循环的 Pending 阶段处理。这确保了错误处理在一个可预测的阶段进行

成功完成的 I/O 操作,其回调会直接进入 Poll 队列,在当前轮或下一轮的 Poll 阶段被执行(取决于 I/O 完成时事件循环处于哪个阶段)。

在每一轮事件循环中,顺序是:Timers -> Pending I/O -> Poll -> Check -> Close Callbacks。所以,Pending I/O 阶段总是在 Poll 阶段之前执行。这意味着上一轮遗留的错误回调会在本轮新的成功 I/O 回调之前得到处理。

又一个不恰当的比喻

  • Pending I/O Callbacks 阶段: 是"问题包裹处理区"。这里专门处理上一班次遗留的或新发现的破损包裹(错误)、地址不清的包裹(特殊延迟情况)。分拣员会优先处理这些特殊包裹(错误回调),尝试解决(调用错误处理函数)或退回(报告错误)。

  • Poll 阶段: 是"主分拣线"。这里处理的是正常到达、地址清晰、完好无损的包裹(成功完成的 I/O 回调)。分拣员(事件循环)会高效地处理这些包裹(执行回调)。如果主线上暂时没包裹了,分拣员会停下来等待新包裹(阻塞等待新 I/O 事件)。

总之:

  • Poll 阶段 是你编写 I/O 操作(fs.readFile, http.request 等)成功回调时,预期代码主要被执行的地方。它是事件循环吞吐量的核心。

  • Pending I/O Callbacks 阶段 是一个底层错误处理机制,处理的是相对少见的、由操作系统或 libuv 报告的特定 I/O 错误。对于大多数应用开发者来说,感知不到这个阶段的存在,除非你在处理非常底层的网络错误。

写了好多,我自己都要吐了,内容有点多,可以点赞收藏,今后慢慢看。哦对了!收藏了,等于会了【手动狗头】!

相关推荐
mCell8 分钟前
Go 并发编程基础:从 Goroutine 到 Worker Pool 实践
后端·性能优化·go
Python智慧行囊1 小时前
Flask 框架(一):核心特性与基础配置
后端·python·flask
ん贤2 小时前
如何加快golang编译速度
后端·golang·go
摸鱼仙人~4 小时前
Spring Boot 参数校验:@Valid 与 @Validated
java·spring boot·后端
思无邪66754 小时前
从零构建搜索引擎 build demo search engine from scratch
后端
Littlewith4 小时前
Node.js:创建第一个应用
服务器·开发语言·后端·学习·node.js
墨菲安全4 小时前
Node.js Windows下路径遍历漏洞
windows·node.js·路径遍历漏洞
itsoo5 小时前
2.5万字!一文搞懂稳定性建设要怎么做?
后端
一眼万年045 小时前
Nginx Master-Worker 进程间的共享内存是怎么做到通用还高效的?
后端·nginx·面试