Skip to content

Node.js 的运行时、异步 I/O 与事件循环

Node.js 最容易被误解的地方之一,就是很多人一听“单线程”,就以为它不适合并发。

更具体一点,Node.js 的核心特征是:JavaScript 执行主线通常是单线程的,但 I/O 不必一直阻塞这条主线。

这一篇最重要的任务,不是重复解释一遍 JavaScript 的基础执行机制,而是要回答:

  1. 为什么 JavaScript 到了 Node.js 里会表现出不同的运行时特征
  2. Node.js 的异步 I/O 到底是怎么配合事件循环工作的
  3. Node.js 的事件循环和浏览器里的事件循环,到底差异在哪里

1. 这部分内容为什么既属于 JavaScript,又属于 Node.js

这是一个特别容易混的边界。

可以这样理解:

  1. JavaScript 主线更适合讲执行上下文、调用栈、宏任务、微任务这些通用机制
  2. Node.js 主线更适合讲这些机制在 Node 运行时里是怎么落地的

也就是说:

  1. “事件循环是什么”更偏 JavaScript 基础执行机制
  2. “Node.js 里的事件循环为什么会这样调度”更偏 Node.js 运行时实现

🌟 所以更稳的拆法通常是:

  1. 先在 JavaScript 目录里建立通用认识
  2. 再在 Node.js 目录里看运行时差异、I/O 承接方式和服务端语境

2. Node.js 运行时到底在说什么

Node.js 不是只有一个 V8 引擎。

可以这样理解它的几层组成:

  1. V8:负责执行 JavaScript
  2. Node.js Runtime:负责把 JavaScript 和文件系统、网络、进程等能力连接起来
  3. 底层异步能力:负责承接大量 I/O 类任务

看一张简化图:

mermaid
flowchart TD
    A[JavaScript 代码] --> B[V8 执行]
    B --> C[Node.js Runtime]
    C --> D[文件系统 网络 定时器 进程等能力]
    D --> E[异步结果回到事件循环]

这张图最重要的意思不是让你背内部实现名词,而是先建立一个稳定认识:Node.js = JavaScript 引擎 + 运行时能力 + 异步 I/O 承接机制。

3. 为什么 Node.js 适合 I/O 密集型场景

Node.js 并不是“所有事情都在一个线程里硬跑到底”。

它更像是:

  1. JavaScript 主线程负责组织逻辑
  2. 文件读写、网络 I/O、定时器等任务交给底层机制处理
  3. 结果准备好后,再由事件循环安排回调执行

所以它尤其适合:

  1. 接口聚合
  2. 网关
  3. BFF
  4. 开发服务
  5. 构建工具和本地脚本

更具体一点,Node.js 擅长的是:让 JavaScript 主线专注组织和调度,而不是一直傻等 I/O 返回。

4. 异步 I/O 到底在解决什么问题

如果没有异步 I/O,很多服务端和工程化场景都会被“等待”拖住。

例如:

  1. 读一个文件
  2. 发一个数据库请求
  3. 等一个网络响应
  4. 等待定时器触发

如果这些动作都要求 JavaScript 主线程原地阻塞等待,就会出现:

  1. 后续逻辑无法继续推进
  2. 其他请求响应延迟增大
  3. 吞吐能力明显下降

所以异步 I/O 的核心价值是:把等待外部结果这件事,从 JavaScript 主调用栈里挪出去。

5. 一条简化执行主线

mermaid
flowchart TD
    A[执行同步代码] --> B[遇到异步任务]
    B --> C[交给底层处理]
    C --> D[主线程继续执行其他同步逻辑]
    D --> E[异步结果准备完成]
    E --> F[回调进入后续调度]
    F --> G[事件循环安排执行]

这条主线最重要的意思是:

  1. JavaScript 主线程没有消失
  2. 异步任务也不是“自动自己执行完了”
  3. 它只是先被交给运行时和底层能力承接,等结果准备好后再回到调度体系里

6. Node.js 里的事件循环到底在做什么

事件循环可以看成 Node.js 用来持续调度“当前有哪些回调可以执行”的机制

它真正解决的是:当一部分任务需要等待 I/O,而另一部分逻辑还能继续推进时,代码应该如何有序地调度执行。

如果只站在 JavaScript 通用层面,你通常会记住:

  1. 同步代码先执行
  2. 微任务优先于后续宏任务
  3. 异步回调不会立刻插队到当前调用栈中间

但到了 Node.js 里,还要再补一层认识:这些回调不是简单排成一条队列,而是会按运行时阶段进入不同调度时机。

7. Node.js 事件循环为什么常被说成“分阶段”

浏览器里讲事件循环时,很多时候会先用:

  1. 宏任务
  2. 微任务

这套模型建立第一层认识。

但在 Node.js 里,如果想继续往下理解,就要看到它更偏:阶段式调度。

可以粗略理解为几类常见阶段:

  1. timers
  2. pending callbacks
  3. poll
  4. check
  5. close callbacks

这里不用一上来把每个阶段背成规范术语,先抓主线更重要:不同来源的回调,不一定在同一个时机被处理。

8. Node.js 事件循环的简化阶段图

mermaid
flowchart TD
    A[timers] --> B[pending callbacks]
    B --> C[poll]
    C --> D[check]
    D --> E[close callbacks]
    E --> A

这张图是简化图,不是完整源码视角,但它足够帮助你建立一个稳定认识:

  1. Node.js 事件循环不是“一个大队列直接扫过去”
  2. 不同类型的回调会在不同阶段更容易得到执行机会

9. 这些阶段可以怎么理解

9.1 timers

可以看成 处理达到时间条件的定时器回调

最典型就是:

  1. setTimeout
  2. setInterval

但要注意:setTimeout(fn, 0) 从来不等于“立刻执行”。`

它更准确的意思通常是:最早在满足条件并轮到对应调度阶段时执行。

9.2 poll

可以看成 等待并处理 I/O 相关结果的一个关键阶段

它和 Node.js 的“擅长 I/O”关系很大,因为很多文件、网络等异步结果,都会和这一块调度关系密切。

9.3 check

可以看成 给 setImmediate 这类回调更明确执行时机的一段调度阶段

所以这也是为什么:

  1. setImmediate
  2. setTimeout(fn, 0)

虽然都不是同步立即执行,但它们的调度语境并不完全一样。

9.4 close callbacks

更偏向处理资源关闭后的相关回调。

这类内容在初学阶段不用抠得太细,先知道:Node.js 不只是处理“普通异步任务”,它还要处理资源生命周期。

10. setTimeoutsetImmediateprocess.nextTick 怎么理解

这三个名字很容易一起出现,但职责并不一样。

10.1 setTimeout

可以看成 在满足延时条件后,等待后续调度执行

它关注的是:

  1. 时间条件是否满足
  2. 后续是否轮到对应阶段执行

10.2 setImmediate

可以看成 更偏向当前轮 I/O 之后尽快安排的一次执行

它在 Node.js 里更有自己的运行时语境,这也是为什么浏览器学习里通常不会把它作为重点。

10.3 process.nextTick

可以看成 优先级非常高的一次“当前阶段后尽快执行”的安排

它特别值得单独记住,是因为:

  1. 它是 Node.js 特有语境里的能力
  2. 它通常会比普通 Promise 微任务更早得到处理机会

所以它使用过多时,可能带来一个问题:后续 I/O 回调迟迟得不到机会。

11. process.nextTick 和 Promise 微任务是什么关系

这是 Node.js 里一个非常容易混的点。

在 JavaScript 通用主线里,你通常会记:

  1. 当前同步代码结束后,会尽快处理微任务

但在 Node.js 里,还要额外注意:

  1. process.nextTick 队列优先级通常更高
  2. Promise 微任务也重要,但它们不是和 nextTick 完全同一层概念

工程上更实用的理解方式是:如果 nextTick 用得过重,它甚至可能把普通微任务和 I/O 回调都压后。

要注意的是:

  1. 不要只记“谁先谁后”的结论
  2. 更要知道 process.nextTick 是 Node.js 运行时里的特殊调度工具

12. Node.js 和浏览器事件循环有什么不同

两边都有事件循环,但关注点不一样。

维度浏览器Node.js
核心目标页面交互与渲染协作服务端与脚本环境下的 I/O 调度
典型异步来源DOM 事件、定时器、网络请求、渲染文件 I/O、网络 I/O、定时器、进程与系统资源
调度感知更常先讲宏任务 / 微任务更常继续细化到阶段模型
页面渲染没有
特有 APIMutationObserver、DOM 事件process.nextTicksetImmediate

这张表最重要的意思是:

  1. 底层都有“异步结果回到 JavaScript 主线”的需求
  2. 但浏览器关心的是页面和渲染
  3. Node.js 关心的是 I/O、服务和资源调度

13. 为什么 CPU 密集型任务不适合直接堆在主线程

Node.js 虽然擅长 I/O,但如果你把大量 CPU 计算直接压在 JavaScript 主线程上,会出现:

  1. 事件循环被占住
  2. 后续请求响应延迟升高
  3. 整体吞吐和响应体验变差

所以更具体一点:Node.js 擅长的是高并发 I/O 协调,不等于擅长长时间霸占 CPU 的计算任务。

这也是为什么工程里常常要区分:

  1. I/O 密集型任务
  2. CPU 密集型任务

Node.js 对前者通常更友好,对后者则要更谨慎设计。

14. 工程上最容易踩的坑

14.1 误把 Node.js 事件循环和浏览器事件循环当成完全同一套东西

它们有相通主线,但 Node.js 的运行时阶段感更强。

14.2 误把 setTimeout(fn, 0) 理解成“马上执行”

它不是马上执行,而是:最早在满足条件并轮到后续调度时执行。

14.3 不区分 setImmediateprocess.nextTick

这两个名字都常被理解成“赶紧执行一下”,但它们的语境和优先级并不一样。

14.4 在主线程里堆太多 CPU 计算

这会直接把事件循环卡住,最后让 Node.js 最擅长的 I/O 协调能力也发挥不出来。

15. 一份检查清单

理解 Node.js 运行时和事件循环时,至少可以自查下面这些问题:

  1. 我现在遇到的是同步逻辑,还是 I/O 逻辑
  2. 这段代码为什么不会立刻拿到异步结果
  3. 当前回调更可能在什么调度阶段得到处理
  4. 我现在讨论的是 JavaScript 通用机制,还是 Node.js 运行时差异
  5. 这类任务更偏 I/O 密集,还是 CPU 密集

16. 小结

如果把这一篇压缩成几句话,可以记住:

  1. JavaScript 主线负责解释执行机制的通用基础
  2. Node.js 主线负责解释这些机制在服务端运行时里的具体落地
  3. Node.js 的核心优势,在于用事件循环和异步 I/O 高效协调大量等待型任务
  4. Node.js 事件循环比浏览器更强调阶段式调度
  5. process.nextTicksetImmediate 这类 API 是理解 Node.js 差异的关键入口

如果只压缩成一句话,可以记住:

Node.js 不是重新发明了一套 JavaScript 执行机制,而是在同样的语言基础上,提供了更偏 I/O 和服务端场景的运行时调度模型。

基于 VitePress 构建的个人技术笔记。