Node.js
Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境,使用了一个事件驱动、非阻塞式 I/O 模型,使其轻量又高效。
面试高频问题概览:
- Node.js 是单线程的吗?如何充分利用多核 CPU?
- Node.js 是如何实现多个进程监听同一个端口的?为什么不会报“端口被占用”?
- Event Loop (事件循环) 的阶段有哪些?与浏览器端有什么区别?
- Buffer 和 Stream 是什么?
- 模块加载机制 (CommonJS) 源码与循环引用问题。
架构模型与核心机制
面试必问:Node.js 究竟是不是单线程的?
严格来说,Node.js 并不是纯粹的单线程。
- 主线程是单线程:JavaScript 的执行、V8 引擎的运行、事件循环的调度,都是在同一个主线程(Event Loop 线程)上运行的。这保证了 JS 执行时没有锁的开销和线程上下文切换的负担。
- 底层工作线程池 (Thread Pool):Node.js 依赖的 C++ 库
libuv内部维护了一个线程池(默认大小为 4,可以通过UV_THREADPOOL_SIZE环境变量修改)。当主线程遇到 文件 I/O (fs)、DNS 查询 (dns.lookup)、复杂的密码学计算 (crypto) 和 压缩 (zlib) 时,主线程不会阻塞,而是将这些任务丢给 libuv 的线程池去多线程并行处理。处理完成后,再将回调推入事件循环队列。面试官追问:“把任务丢给 libuv 线程池处理,难道没有线程间通信的性能损耗吗?”
- 当然有损耗。 V8 主线程和 libuv 工作线程之间的通信确实存在 Context Switch(上下文切换)和数据拷贝的开销。
- 为什么还要这么做? 因为文件 I/O 等操作相比于内存中的 JS 执行是非常慢的(磁盘读写耗时通常是 CPU 计算的几万倍)。如果让主线程死等磁盘,整个服务器就会瘫痪(阻塞)。“两害相权取其轻”,虽然线程通信有损耗,但换来了主线程的非阻塞,保证了高并发处理能力。
- 网络 I/O 怎么处理的? 注意!网络请求(如 HTTP、Socket)是不走线程池的! 网络 I/O 直接由操作系统内核的异步非阻塞接口(如 Linux 的
epoll,macOS 的kqueue)接管,性能极高,这才是 Node.js 处理高并发网络请求的真正大杀器。- Worker Threads (工作线程):为了弥补 Node.js 处理 CPU 密集型任务(如图像处理、大规模数据计算)的短板,Node 10.5+ 引入了
worker_threads模块,允许开发者真正地在 JS 层面上创建多线程,并且它们可以共享内存 (SharedArrayBuffer)。
- V8:执行 JS 代码,提供 JS 运行环境。
- libuv:C++ 编写的事件循环和异步 I/O 库,是 Node.js 异步非阻塞的核心。它跨平台地封装了底层的系统调用(如 epoll/kqueue/IOCP)。
- Core Modules (内置模块):JS 层面的基础模块,如
fs,http,path,crypto。 - C++ Bindings:使得 JS 能够调用 C++ 代码,起到桥梁作用。
模块加载机制
- 计算绝对路径
- 加载模块
- 优先从缓存中加载
- 加载核心模块
- 按路径加载模块
- 加载第三方模块。会从当前目录的
node_modules往上查找,直到根目录。后两者会以绝对路径为 key 生成新的缓存
加载模块
得到模块的绝对路径后,就开始加载模块了。首先确定模块的后缀名,不同的文件对应不同的加载方法,以 .js 和 .json 为例
Module._extensions[".js"] = function (module, filename) {
var content = fs.readFileSync(filename, "utf8");
module._compile(stripBOM(content), filename);
};
Module._extensions[".json"] = function (module, filename) {
var content = fs.readFileSync(filename, "utf8");
try {
module.exports = JSON.parse(stripBOM(content));
} catch (err) {
err.message = filename + ": " + err.message;
throw err;
}
};
解析 js 模块的关键函数:
Module.prototype._compile = function (content, filename) {
compiledWrapper(content, this.exports, this.require, this);
};
var compiledWrapper = function (content, exports, require, module) {
eval(content);
};
模块的加载其实就是注入 exports、require、module 三个全局变量,然后执行模块的源码,最后输出模块的 exports 值
CommonJS 是如何解决循环引用问题的?
// main.js
const child = require('./child.js')
console.log('执行 main.js: ', child)
module.exports = 'main'
// child.js
const main = require('./main.js')
console.log('执行 child.js: ', main)
module.exports = 'child'
// 执行 main.js , 输出如下:
// 解析:执行到 child.js 时,main.js 还没有执行完,默认导出空对象
执行 child.js: {}
执行 main.js: child
事件循环
事件循环是 Node.js 处理异步非阻塞 I/O 的核心机制,其底层由 libuv 实现。
Node.js 与浏览器 Event Loop 的核心区别:
- 浏览器的 Event Loop 相对简单:执行一个宏任务 -> 清空微任务队列 -> 渲染 -> 执行下一个宏任务。
- Node.js 的 Event Loop 按照阶段 (Phases) 划分,每个阶段都有一个执行回调的 FIFO 队列。在 Node 11 之前,Node 会在每个阶段的所有宏任务执行完毕后,才统一执行微任务;而在 Node 11 之后,为了和浏览器保持一致,Node 变成了每执行完一个宏任务,就会立刻清空微任务队列。
事件循环概览如下所示:
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
Node.js 的事件循环是实现非阻塞 I/O 的基础。以上每个阶段都有一个 FIFO 队列来执行回调,当一个事件完成的时候,内核会通知 Node.js 将回调函数添加到队列中。每个阶段最后都会执行当前阶段产生的微任务(包括 nextTick)
timers
进入这个阶段,主线程会检查时间是否满足定时器(setTimeout、setInterval)触发的条件,如果满足就执行相应的回调函数,否则离开这个阶段
pending callbacks
此阶段执行某些系统操作的回调,例如 TCP 错误
idle、prepare
这个阶段只供 libuv 内部调用
poll
用于处理大部分 I/O 回调和等待 I/O 事件 进入 poll 且不存在到期定时器,此阶段可能会导致主线程阻塞,该阶段会分为以下两种情况:
poll队列不为空,同步执行队列poll队列为空,如果执行了setImmediate,则结束poll阶段,进入check阶段;如果没有,事件循环将阻塞并等待回调添加到poll队列中执行
一旦 poll 队列为空,事件循环将检查到期的定时器,如果有定时器准备好,就回滚到计时器阶段以执行这些计时器的回调;否则会一直停留在这个阶段,等待 I/O 请求返回结果
check
执行 setImmediate 回调
close
该阶段执行关闭请求的回调函数,比如 socket.on('close', callback)
微任务
微任务分类:
process.nextTick(优先级更高)Promise.then
微任务会在主线之后和事件循环的每个阶段之后立即执行
issues
把 setTimeout 和 setImmediate 放入同一个 I/O 回调函数内,setImmediate 总是被优先调用
fs.readFile(__filename, () => {
setTimeout(() => {
console.log("timeout");
}, 0);
setImmediate(() => {
console.log("immediate");
});
});
原因是 fs.readFile 的回调是在 poll 阶段执行的,该阶段过后会先轮到 check 阶段,即执行 setImmediate 回调,下一轮事件循环才执行 setTimeout 的回调
那为什么在没有 I/O 回调的普通代码中,这两者的执行顺序不确定呢?
- Node.js 启动,执行同步代码(初始化)。
- 执行到
setTimeout,虽然设置的是 0 毫秒,但在 Node.js 内部会被强制转换为至少 1 毫秒,加入timers阶段。 - 执行到
setImmediate,加入check阶段。 - 同步代码执行完毕,进入
Event Loop的第一个阶段(timers)。 - 关键点:如果事件循环启动得非常快(耗时不到 1ms),此时
setTimeout还没有到期,就会跳过timers阶段,一路向下走到check阶段执行setImmediate。但如果机器稍慢或前序准备耗时超过 1ms,timers阶段的定时器已经到期了,就会先执行setTimeout。这就导致了输出顺序的不确定性。
核心机制:多进程与多线程
由于 Node.js 是单线程的,为了充分利用多核 CPU 以及处理 CPU 密集型任务,Node.js 提供了完善的多进程 (child_process, cluster) 和多线程 (worker_threads) 解决方案。
👉 关于多进程/多线程的底层原理、端口共享机制以及常见面试题,请阅读专门的章节:多进程与多线程
数据处理:Buffer 与 Stream
在 Node.js 中处理文件、网络通信、音视频等底层 I/O 操作时,必定会绕不开 Buffer (堆外内存二进制缓冲区) 和 Stream (流)。它们是解决大文件处理引发 OOM 的核心机制。
👉 关于 Buffer 的内存分配机制、大文件处理策略以及高频考点“背压 (Backpressure) 问题”,请阅读专门的章节:Buffer 与 Stream
异常监控与日志管理
在单线程的 Node.js 中,一个未捕获的异常可能导致整个服务崩溃;而在生产环境中,完善的结构化日志和进程守护是排查故障的唯一救命稻草。
👉 关于 uncaughtException 的最佳实践、优雅退出、PM2 核心机制以及大厂日志管理方案(Winston/Pino, 结构化日志),请阅读专门的章节:异常监控与日志管理
跨语言与底层扩展:C++ 插件
在面临极端的性能瓶颈或需要调用底层系统库时,纯 JavaScript 往往力不从心。Node.js 允许开发者通过编写 C++ 插件 (Addons) 来打破这一限制。
👉 关于 C++ 插件的演进 (N-API)、.node 文件的加载原理,以及 WebAssembly (Wasm) 的对比,请阅读高级章节:C++ 扩展
终极进阶:高级核心概念与底层原理
在资深或专家岗位的面试中,面试官往往会跳出日常 API,考察你对 V8 引擎、模块化底层以及特定安全场景的深入理解。
👉 关于全链路追踪 (AsyncLocalStorage)、V8 引擎性能反优化、模块化底层包装机制,以及 Node 特有的安全攻防 (ReDoS / 原型链污染),请阅读终极篇:高级核心概念