首页 / Node.js 教程 / Node.js 工作机制

Node.js 教程

Node.js 工作机制

本教程共 76 篇 · 第 5 篇 · 更新于 2026-07-25 · 约 7 分钟阅读

Node.js事件循环libuv非阻塞IO单线程

5. Node.js 工作机制

本节目标:Node.js 内部是怎么跑起来的——V8 引擎怎么执行你的代码,libuv 怎么搞定异步 I/O,以及事件循环的大致轮廓。不用深挖源码,但要建立正确的心智模型。

整体架构一张图

Node.js 不是一块铁板,它分好几层。最简化的视角:

你的 JavaScript 代码

Node.js 核心模块 (fs, http, net...)

C++ 绑定层 (Node API)

├─ V8 引擎(执行 JS)
├─ libuv(异步 I/O + 事件循环)
├─ c-ares(DNS 解析)
├─ OpenSSL(加密)
└─ zlib(压缩)

操作系统内核 (epoll/kqueue/IOCP)

你写的每一行 JavaScript,最终都要靠这些底层组件配合才能完成。理解它们各自的分工,写代码时才知道瓶颈在哪、异常从哪来。


V8 引擎:把 JS 翻译成机器码

V8 是 Google 开发的 JavaScript 引擎,Chrome 浏览器也在用它。Node.js 选 V8 做执行引擎,是因为它够快。

传统的解释型语言(比如早期的 PHP、Python)是边读边执行,速度天花板很明显。V8 走的是 JIT(Just-In-Time)编译 路线:拿到 JavaScript 代码后,先快速编译成机器码执行,同时后台分析哪些代码被频繁调用(热点代码),再进一步优化成更高效的机器码。

你可以把 V8 想象成一个翻译官。第一次见面时它给你快速口译,听着还行;聊多了发现你总在说某几句话,它就开始精心准备更精准的高级翻译版本。

在 Node.js 里查看当前 V8 版本:

console.log(process.versions.v8);

V8 还负责垃圾回收(GC)。你创建的对象不再被引用时,V8 会自动回收内存。v24 的 V8 已经内置了更先进的增量标记和并发清理,大幅减少 GC 时的程序停顿。

Tip

V8 的 GC 策略对内存密集型应用影响很大。如果你发现程序偶尔卡顿,可能是 GC 在干活。node --max-old-space-size=4096 可以调大堆内存上限,但治标不治本,根本办法是减少内存泄漏和不必要的大对象持有。


libuv:异步 I/O 的幕后功臣

V8 只管执行 JavaScript,它不知道怎么读文件、怎么发网络请求。这些脏活累活交给 libuv——一个用 C 写的跨平台异步 I/O 库。

libuv 的核心职责:

  • 提供事件循环(Event Loop)的实现
  • 把文件系统、DNS、网络等操作委托给操作系统
  • 管理一个线程池,处理那些操作系统不支持纯异步的 I/O(比如文件读写)

这里有个常见的误解:Node.js 的「单线程」指的是 JavaScript 代码执行在单线程里,但底层 I/O 并不是单线程完成的。libuv 会根据需要用多线程去真正执行读写操作,完成后把结果塞回事件队列,通知你的回调函数。


事件循环:Node.js 的心脏

事件循环(Event Loop)是 Node.js 能高并发的根本原因。它就是一个不断转圈的调度器,按固定顺序检查各个阶段有没有待办事项。

简化版的事件循环阶段:

   ┌───────────────────────────┐
   │        timers 阶段         │  ← setTimeout / setInterval 到期回调
   └─────────────┬─────────────┘
   ┌─────────────┴─────────────┐
   │   pending callbacks 阶段    │  ← 系统级 I/O 延迟回调
   └─────────────┬─────────────┘
   ┌─────────────┴─────────────┐
   │   idle, prepare(内部用)   │
   └─────────────┬─────────────┘
   ┌─────────────┴─────────────┐
   │        poll 阶段           │  ← 核心:获取新的 I/O 事件,执行回调
   └─────────────┬─────────────┘
   ┌─────────────┴─────────────┐
   │        check 阶段          │  ← setImmediate 回调
   └─────────────┬─────────────┘
   ┌─────────────┴─────────────┐
   │     close callbacks 阶段    │  ← socket 关闭等清理回调
   └───────────────────────────┘

每个阶段转完后,还会去检查两个「插队队列」:

  • process.nextTick() 的回调
  • Promise 的微任务(microtasks)

这两个队列的优先级极高,能把当前阶段插队的任务全部清完,才进入下一阶段。


执行顺序实测

光看图容易晕,跑段代码感受下:

import { setImmediate } from 'node:timers';

console.log('1. 同步代码 start');

setTimeout(() => console.log('2. timer'), 0);

setImmediate(() => console.log('3. immediate'));

Promise.resolve().then(() => console.log('4. promise'));

process.nextTick(() => console.log('5. nextTick'));

console.log('6. 同步代码 end');

输出:

1. 同步代码 start
6. 同步代码 end
5. nextTick
4. promise
2. timer
3. immediate

解读:

  1. 同步代码永远最先执行,这点没有任何悬念。
  2. 同步代码跑完后,先清 nextTick 队列,再清 Promise 微任务队列。
  3. 然后进入 timers 阶段,执行 setTimeout 回调。
  4. 最后到 check 阶段,执行 setImmediate
Note

setTimeout(..., 0)setImmediate 的执行顺序不是绝对的。如果程序启动后很快进入 poll 阶段,setImmediate 可能比 setTimeout(..., 0) 先执行。想深入了解可以看第 15 章,那里专门讲这三兄弟的恩怨。


阻塞事件循环的代价

事件循环转得顺,你的应用就能同时处理成千上万个请求。但如果某个回调函数里写了耗时计算,事件循环就被卡住了。

import { createServer } from 'node:http';

function heavyComputation() {
  let sum = 0;
  for (let i = 0; i < 1e10; i++) {
    sum += i;
  }
  return sum;
}

createServer((req, res) => {
  const result = heavyComputation();
  res.end(`Result: ${result}`);
}).listen(3000);

这段代码在计算期间,服务器一个别的请求都处理不了。所有连接都干等着,这就是阻塞事件循环

解决思路:

  • 把计算拆成小块,用 setImmediate 分批执行
  • worker_threads 把计算丢到独立线程(第 35 章细讲)
  • 如果是视频转码这类重活,直接调外部服务或者子进程
Warning

生产环境里一个常见的坑:有人把 JSON.parse 用在几 MB 的大 JSON 上,或者在循环里同步读写大文件。这些操作看起来 harmless,实际上能把事件循环卡住几百毫秒,请求延迟瞬间爆炸。


线程池与文件 I/O

网络 I/O(比如 HTTP 请求)在 Linux 上可以用 epoll 纯异步完成,但文件 I/O 不一样。大多数操作系统没有提供完善的异步文件 API,所以 libuv 默认用一个 4 线程的线程池 来处理文件操作。

这意味着:如果你同时发起大量文件读写,即使代码写的是异步 API,底层也可能在排队等线程池里的线程空闲。

import { readFile } from 'node:fs';

// 这 5 个文件读操作,底层可能在 4 个线程里并发执行
// 第 5 个要稍微等一等
readFile('a.txt', cb);
readFile('b.txt', cb);
readFile('c.txt', cb);
readFile('d.txt', cb);
readFile('e.txt', cb);

可以通过环境变量 UV_THREADPOOL_SIZE 调大线程池,默认 4,最大 1024:

UV_THREADPOOL_SIZE=16 node app.js

但别无脑拉到 1024,线程也有上下文切换开销。一般情况下 16-32 够用了。


版本差异速览

不同 Node.js 版本在底层实现上有不少进化:

  • v18:引入了全局 fetch,底层基于 undici,走独立的 HTTP 客户端逻辑,不再依赖旧的 http.request
  • v20:V8 升级带来更好的性能,事件循环的定时器精度有改进。
  • v24 LTS:V8 进一步优化,权限模型(Permission Model)更成熟,诊断工具链更完善。
  • v26 Current:继续跟进最新 V8,实验性特性更多。

对于大多数开发者来说,不用关心每个版本的 V8 具体改了什么。知道「升级 Node.js = 升级 V8 = 更快 + 新语法支持」就够了。


给初学者的建议

你不需要把 libuv 的源码读一遍才能写好 Node.js。但这几个概念必须刻进脑子:

  1. JavaScript 执行是单线程的,别在主线程做重计算。
  2. I/O 操作(文件、网络、数据库)尽量用异步 API。
  3. 回调函数执行有先后顺序,同步 > nextTick > Promise > timers > immediate。
  4. 事件循环一旦卡住,全站受影响。

这些心智模型建立好了,后面学 Promise、async/await、Stream 都会轻松很多。

下一部分我们进入模块系统——这是 Node.js 工程化的基石。