Node.js 运行时心智:事件循环、libuv 与非阻塞 I/O
很多人第一次接触 Node.js 时,会把它当成「能在服务器上跑 JavaScript 的引擎」。
这一篇先不急着讲怎么写 HTTP 服务,而是想搞清楚几件事:Node 和浏览器里的 JS 有什么本质不同、事件循环在 Node 里扮演什么角色,以及「单线程 + 非阻塞 I/O」在实际工程里意味着什么。
Node.js 是什么:不是语言,是一套运行时
可以先把几个概念分开:
- 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:事件循环与线程池的粘合层
可以把 libuv 理解成:
- 负责和操作系统打交道:epoll、kqueue、IOCP 等;
- 维护一个 事件循环:在每一轮里检查「哪些 I/O 完成了、哪些定时器到期了」;
- 在需要时把任务丢给 线程池 执行,再在线程结束后把结果挂回主线程队列。
对写业务代码的人来说,通常不需要直接调 libuv,但要记住:
- 「异步」不等于「没有成本」:线程池大小、任务排队、回调堆积都会影响延迟;
- 「异步」也不等于「并行算力」:大计算仍要拆到
worker_threads或子进程里。
事件循环:粗略阶段(够用即可)
不必背每一阶段名字,先建立一个够用的心智模型即可:
- 一轮循环里,大致会处理:
- 到期的定时器;
- I/O 回调(网络、已完成的异步操作);
setImmediate(在定时器与 I/O 之间的细节顺序与平台有关);close回调等收尾。
process.nextTick的微任务队列会在阶段之间插进来,优先级很高——滥用会「饿死」I/O。
工程上的启示:
- 不要在
nextTick里做重逻辑; - 需要精确顺序时,要意识到「nextTick 先于 Promise 微任务」这类细节,但日常更推荐用 Promise/async 保持可读性。
非阻塞 I/O 在代码里长什么样
典型模式:
- 发起读文件、连数据库、发 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。