Node.js 运行时心智:事件循环、libuv 与非阻塞 I/O

很多人第一次接触 Node.js 时,会把它当成「能在服务器上跑 JavaScript 的引擎」。
这一篇先不急着讲怎么写 HTTP 服务,而是想搞清楚几件事:Node 和浏览器里的 JS 有什么本质不同、事件循环在 Node 里扮演什么角色,以及「单线程 + 非阻塞 I/O」在实际工程里意味着什么。

可以先把几个概念分开:

  • JavaScript:一门语言规范;
  • V8:把 JS 编译执行的引擎(Chrome 里也在用);
  • Node.js:在 V8 之上叠了一层 libuv 和各类系统绑定,提供文件、网络、子进程等能力,并约定了一套 事件驱动、异步优先 的编程模型。

所以 Node 的核心不是「能在服务端跑 JS」,而是:

  • 把 I/O 从阻塞调用里挪出去,用回调 / Promise / async 把结果在合适时机接回来。

常说「Node 是单线程」,容易误解成「整个进程只有一个线程干活」。

更准确的说法是:

  • 执行你写的 JS 代码的那条线程,在默认模型里通常是一条(主线程上的 JS);
  • 但底层 libuv 会用到线程池(例如部分 fs、DNS、crypto 等),网络 I/O 在多数平台由操作系统异步完成

因此:

  • CPU 密集的纯 JS 循环会占满这条执行线程,其他回调排队等不到机会跑 → 表现为「整进程卡死」;
  • I/O 密集时,等待数据的时间不占用 JS 线程,线程可以去处理其他回调 → 这是 Node 最擅长的场景。

可以把 libuv 理解成:

  • 负责和操作系统打交道:epoll、kqueue、IOCP 等;
  • 维护一个 事件循环:在每一轮里检查「哪些 I/O 完成了、哪些定时器到期了」;
  • 在需要时把任务丢给 线程池 执行,再在线程结束后把结果挂回主线程队列。

对写业务代码的人来说,通常不需要直接调 libuv,但要记住:

  • 「异步」不等于「没有成本」:线程池大小、任务排队、回调堆积都会影响延迟;
  • 「异步」也不等于「并行算力」:大计算仍要拆到 worker_threads 或子进程里。

不必背每一阶段名字,先建立一个够用的心智模型即可:

  • 一轮循环里,大致会处理:
    • 到期的定时器;
    • I/O 回调(网络、已完成的异步操作);
    • setImmediate(在定时器与 I/O 之间的细节顺序与平台有关);
    • close 回调等收尾。
  • process.nextTick 的微任务队列会在阶段之间插进来,优先级很高——滥用会「饿死」I/O。

工程上的启示:

  • 不要在 nextTick 里做重逻辑;
  • 需要精确顺序时,要意识到「nextTick 先于 Promise 微任务」这类细节,但日常更推荐用 Promise/async 保持可读性。

典型模式:

  • 发起读文件、连数据库、发 HTTP 请求时,调用立即返回,真正的结果在回调或 Promise 里;
  • 主线程继续处理其他任务。

如果误用 同步 API(例如 fs.readFileSync):

  • 会在当前线程阻塞,直到磁盘读完——在服务端高并发场景下很容易成为瓶颈。

因此有一条实践底线:

  • 默认用异步 API;同步 API 只用于启动阶段、CLI 工具、或明确知道并发很低的脚本。
  • 都是「事件驱动 + 微任务」的大框架;
  • Node 多了 libuv 的阶段划分、nextTick、与系统 I/O 的深度绑定
  • 调试「为什么顺序和我想的不一样」时,要同时考虑 Node 文档里的顺序规则是否混用了同步阻塞调用
  • Node = V8 + libuv + 绑定,价值在于 非阻塞 I/O 与事件驱动
  • 「单线程」主要指 跑 JS 的主线,底层仍有线程池与系统异步 I/O;
  • 工程上要自觉区分 I/O 密集CPU 密集,后者必须另找并行手段,而不是指望「异步魔法」。

把这套心智模型立住之后,再去看模块系统、Stream、HTTP 服务,会自然知道「为什么这样设计」而不是只记 API。