Node.js 的特点详解:异步非阻塞 I/O、事件循环与单线程模型
Node.js 的特点详解:异步非阻塞 I/O、事件循环与单线程模型
本篇指南以《千古前端图文教程》中 02-Node.js的特点 一文为核心骨架,系统讲解 Node.js 的四大核心特点——异步非阻塞 I/O 模型、事件循环、单线程,以及由此带来的轻量与高效,并结合仓库内 Node.js 系列文档与代码示例做纵深解读。读完本文,你将理解 Node.js 为何能用"一个主线程"扛住高并发请求,也能客观认识它在实际生产中的局限与劣势。
Node.js 的四大特点总览
在 01-Node.js介绍 中我们知道,Node.js 是基于 Chrome V8 引擎的 JavaScript 运行环境,采用事件驱动、非阻塞式 I/O 的模型,因此轻量又高效。具体到本篇文章,可以把 Node.js 的特点归纳为以下四点:
- 异步、非阻塞 I/O 模型:I/O 操作不会阻塞主流程的执行;
- 事件循环:通过事件循环机制调度异步任务;
- 单线程:主线程只有一个,配合内部线程池完成高并发处理;
- 总结:轻量和高效:性能与效率非常高。
这四个特点环环相扣:正是"单线程 + 异步非阻塞 + 事件循环"的组合,才造就了 Node.js 轻量高效的特质。下面逐一展开。
特点一:异步、非阻塞的 I/O 模型
传统模型的对比:一个请求一个线程
要理解 Node.js 的异步模型,先看传统的服务端模型。以 Java 为代表的传统服务端语言,采用的模式是:一个请求开启一个线程,请求处理完毕后就关闭这个线程。这种"每请求一线程"(thread-per-request)的模型直白易懂,但线程的开销和数量都有限,在高并发场景下容易成为瓶颈。
而 Node.js 完全没有采用这种模型,它本质上就是一个单线程运行时。
阻塞 vs 非阻塞:读文件为例
异步 I/O 也叫非阻塞 I/O。以读文件为例:传统的语言基本都是"读取完毕才能进行下一步操作",这就是阻塞;而 Node.js 的非阻塞方式是——把读文件的请求交出去,不会阻塞下一步操作,等到文件读取完毕,回调函数自动被执行,而不是在等待(详见 事件驱动和非阻塞机制)。
仓库 05-Node.js内置模块:fs文件模块 给出了最直观的对照。fs 模块对文件的几乎所有操作都有同步和异步两种形式,例如 readFile() 和 readFileSync():
异步读取(非阻塞,不等待结果):
const fs = require('fs');
fs.readFile('hello.txt', 'utf8', (err, data) => {
if (err) {
// 失败
console.log(err)
} else {
// 成功
console.log('异步读取数据:' + data)
}
});
同步读取(阻塞,必须等待结果返回):
const fs = require('fs');
try {
const data = fs.readFileSync('hello.txt', 'utf8');
console.log(data);
} catch(e) {
// 文件不存在,或者权限错误
throw e;
}
二者的核心区别,恰好就是 Node.js 异步模型的要点:
- 同步调用会阻塞代码的执行,异步则不会;
- 异步调用会将读取任务下达到任务队列,直到任务执行完成才会回调;
- 异常处理方面:同步必须使用
try...catch方式,异步可以通过回调函数的第一个参数接收错误(这是 Node.js 生态中"错误优先"回调约定的由来,后面会讲到)。
回调函数与"错误优先"约定
Node.js 中大量采用异步操作(asynchronous operation),即任务不是马上执行,而是插入到任务队列的尾部,等前面的任务运行完后再执行,从而提高代码的响应能力。异步操作的结果,通过回调函数(callback)返回给调用方。
由于异步操作中无法通过 try...catch 捕获异常——当回调函数运行时,前期的操作早已结束,错误的执行栈已经不存在了——所以 Node.js 社区统一约定:回调函数的第一个参数默认接收错误信息,第二个参数才是真正的回调数据,便于外界获取调用的情况(参见 事件驱动和非阻塞机制):
foo1('赵小黑', 19, function(error, data) {
if(error) throw error;
console.log(data);
});
这种"错误优先"的回调约定贯穿 Node.js 内置模块(如 fs、http、net 等)和绝大多数第三方库,是阅读 Node.js 代码必须掌握的第一条规则。
特点二:事件循环(Event Loop)
主线程的"往返调度"职责
有了异步任务,就必须有"谁来安排它们按顺序执行"的调度机制,这就是事件循环。用一句话概括:Node 将所有的阻塞操作交给内部线程池实现,Node 主线程本身,主要就是不断地往返调度(详见 事件驱动和非阻塞机制)。
也就是说,主线程并不亲自去读写文件、访问网络,而是把这些耗时操作派发出去,然后专心在事件循环中"巡视"——看看有哪些异步任务已经完成、可以回调了,再安排它们进入主线程执行。
Node.js 事件循环中的六个队列
浏览器的 Event Loop 依据的是 HTML5 规范,而 Node.js 的 Event Loop 是由 Node.js 底层的 libuv 规定的(libuv 是一个专注于异步 I/O 的跨平台库)。在 Node.js 的事件循环中,共有六个队列:微任务两个队列,宏任务四个队列(详见 12-事件循环机制、宏任务和微任务):
一、微任务队列:
- 顺序 1:next tick queue。比如
process.nextTick; - 顺序 2:other queue。比如 Promise 的
then回调、queueMicrotask。
二、宏任务队列:
- 顺序 3:timer queue。比如
setTimeout、setInterval; - 顺序 4:poll queue。比如 I/O 事件;
- 顺序 5:check queue。比如
setImmediate; - 顺序 6:close queue。比如
close事件。
宏任务与微任务的执行规则是:同步任务 → 微任务 → 宏任务;并且在执行任何一个宏任务之前,都会先查询微任务队列中是否还有任务需要执行——当前宏任务执行之前,必须保证微任务队列是空的。
这个模型解释了 Node.js 为什么"异步但不乱序":所有异步任务都在事件循环的统一调度下有序执行,同一个时刻主线程只执行一个任务,这正是单线程模型的调度基础。
平台差异:libuv 的抽象封装
由于 Windows 与 *nix 平台在底层异步机制上的差异(Windows 使用 IOCP,*nix 使用自定义线程池),Node 提供了 libuv 作为抽象封装层,保证上层的 Node 与下层的自定义线程池及 IOCP 之间各自独立(详见 事件驱动和非阻塞机制)。这也是 Node.js 能够跨平台提供一致异步体验的关键设计。
特点三:单线程(主线程 + 线程池)
"单线程"到底指什么
你可能会疑问:一个线程如何服务于大量请求、如何处理高并发?
这里的"单线程",指的是 Node 的主线程只有一个。但主线程并不是"一个人干所有活":
- 主线程:用于接收客户端请求,但不会处理具体的任务,以确保主线程不被阻塞;
- 线程池:Node 的背后还有一个线程池,线程池会处理长时间运行的任务(比如 I/O 操作、网络操作);
- 队列 + 事件循环:线程池里的任务,是通过队列和事件循环的机制来执行的——这正是前面两个特点的落地点。
所以更准确的图景是:单线程的"主线程"负责调度与分发,真正干重活的是背后的线程池,二者通过事件循环衔接,共同支撑高并发。
为什么 Node 不采用多线程模型
既然线程池能干活,为什么不让 JS 直接开多线程?因为在服务端传统模型里,多线程本身也有显著的弊端(详见 事件驱动和非阻塞机制):
缺点一:创建与切换线程代价高
- 创建线程耗费资源;
- 线程数量有限;
- CPU 在不同线程之间切换,存在上下文切换(context switch),非常耗时。
所谓的多线程其实都是"假并行":对于单核 CPU 而言,多线程无非是在抢占 CPU 资源,线程与线程之间需要切换和调度,这是很耗费资源的。
缺点二:线程间的数据同步麻烦
- 线程之间共享某些数据、同步某个状态都很麻烦。比如 A 线程要访问 B 线程的变量,就需要复杂的同步机制来保证安全。
Node.js 的做法是用"异步 + 事件循环"取代"线程抢占",主线程永远不被阻塞,从而用最小的资源开销获得高吞吐。
底层支撑:V8 引擎与 libuv
单线程的 JS 之所以能驱动后端能力,离不开两个底层组件(详见 01-Node.js介绍):
- V8 引擎:编译和执行 JS 代码、管理内存、垃圾回收,是 JavaScript 的虚拟机(用 C++ 编写);
- libuv:专注于异步 I/O 的跨平台类库,由 Node.js 最初的作者 Ryan Dahl 为 Node.js 编写(用 C 编写),扩展了 JS 在后端的能力(如 I/O 操作、文件读写、数据库操作等)。
正是这一架构,让 JS 既可以在前端做 DOM 操作,又可以在后端调用操作系统资源,成为"最简单的全栈式语言"。
总结:轻量和高效
综合以上三点,Node.js 的性能和效率非常高。
与 PHP、JSP 等传统方案相比,Node.js 跳过了 Apache、Nginx、IIS 等 HTTP 服务器,它自己不建设在任何服务器软件之上,也没有 web 容器,许多设计理念与经典架构(LAMP = Linux + Apache + MySQL + PHP)有着很大不同,可以提供强大的伸缩能力(参见 01-Node.js介绍)。加上 npm 这一全球最大的开源库生态系统,Node.js 在轻量服务和高效开发上优势明显,也是前端同学进入后端领域、实现前后端语法统一的最佳入口。
使用 Node.js 时的劣势
Node.js 并非银弹。原文档 02-Node.js的特点 明确指出,使用 Node.js 时有以下劣势:
1、程序运行不稳定,可能会出现服务不可用的情况
Node.js 主线程单线程的特性意味着:一旦主线程上运行的代码抛出未捕获异常、或某个同步任务长时间占用主线程,整个服务可能直接不可用——不像多线程模型那样"挂掉一个线程还有别的线程顶上"。
此外还有来自 V8 的内存限制:Node 中通过 JavaScript 使用内存时,只能使用部分内存(64 位系统下约为 1.4GB,32 位系统下约为 0.7GB),这会导致 Node 无法直接操作大内存对象(详见 01-Node.js介绍)。原因在于 Node 基于 V8 构建,JS 对象基本都通过 V8 自己的方式分配和管理,而这套内存机制原本是为浏览器页面场景设计的。在 Node 中做内存密集型任务时,需要格外小心。
2、程序运行效率较低,每秒的请求数维持在一个较低的水平
异步回调的代码,相比传统同步代码,不容易阅读、不容易调试、不容易维护(详见 事件驱动和非阻塞机制)。尤其是多个异步任务存在依赖关系时的层层嵌套,会形成著名的回调地狱(callback hell):
do1(function() {
do2(function() {
do3(function() {
do4(function() {
do5(function() {
do6()
});
});
});
});
});
回调地狱让代码结构混乱、排错成本上升,也会间接拖累开发与运行效率。这正是 ES6 引入 Promise、再演进到 async/await 的动机——仓库 06-Promise入门详解 与 05-回调函数 对该演进有完整讲解。比如用 Promise 封装 fs.readFile,就能把嵌套回调改写成链式调用:
const fs = require('fs');
function fsRead(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, { flag: 'r', encoding: "utf-8" }, (err, data) => {
if (err) {
//失败执行的内容
reject(err)
} else {
//成功执行的内容
resolve(data)
}
})
})
}
var promise1 = fsRead('hello1.txt')
promise1.then(res1 => {
console.log(res1);
return fsRead('hello2.txt');
}).then(res2 => {
console.log(res2);
return fsRead('hello3.txt');
}).then(res3 => {
console.log(res3);
})
3、前端同学对服务器端的技术不太熟悉
Node.js 生态中的大量使用者是前端工程师,而服务器端开发还绕不开框架设计、开发调试、数据库操作、高并发处理、性能优化、进程管理、系统运维等硬核知识(参见 01-Node.js介绍 的论述)。这些技能的积累需要时间,也是前端转全栈路上的现实门槛。
延伸阅读
如果你希望继续深入学习 Node.js,推荐按仓库内的顺序阅读以下文档:
- 01-Node.js介绍:Node.js 的定义、架构(V8 + libuv)、发展历史与应用场景;
- 03-Node.js开发环境安装:安装与运行环境的搭建;
- 04-Node.js模块化规范:CommonJS:模块化机制;
- 05-Node.js内置模块:fs文件模块:本文异步/同步 I/O 例子的出处;
- 事件驱动和非阻塞机制:异步回调、进程与线程、libuv 平台的深入原理;
- 12-事件循环机制、宏任务和微任务:事件循环六队列与宏任务/微任务的执行顺序。