持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第8天,点击查看活动详情
什么是nodejs?
nodejs 是一个基于 chrome V8 引擎的 javascript 运行环境。
nodejs 使用了一个事件驱动、非阻塞式 I/O 的模型。
javascript 代码运行起来需要一个环境,比如浏览器环境,它内部包含了一个 chrome V8 引擎用来解析执行 javascript 代码。
后来,nodejs作者 Dahl 基于 chrome V8 引擎开发出了nodejs,这使得 JavaScript 代码可以脱离浏览器环境运行。
还有,VS Code 编辑器是用 electron做的,它里面就内嵌了一个chrome V8 引擎,这样我们就能用 JavaScript 开发桌面应用了。
那在 nodejs 中运行 JavaScript 跟在浏览器中运行 JavaScript 有什么不同呢?
其实它们几乎没有什么不同,只有一些 api 不同:
- nodejs 没有浏览器的api,即document, window等
- nodejs 加了很多 nodejs的 api
对于开发者来说,你在浏览器中写javascript,控制的仅仅是浏览器。如果你在nodejs中写JavaScript 控制的是整个计算机。
对于node来说,你可以控制整个计算机
nodejs 适用场景
传统的 apache 服务器应对高并发就是开启多个进程,一个请求就开启一个进程。下面以餐馆为例:
一个客人点菜,就有一个服务员和一个厨师对应,只有前一个客人的菜做好了,服务员和厨师才能服务下一个。当客人很多的时候,只有增加厨师和服务员。但是一个cpu能增加的进程数量是有限度的,不能一直增加下去。同时,当厨师做饭的时候服务员是空闲的,也就是厨师做饭是I/O操作,服务员帮客人点菜是CPU操作,CPU操作很快就完成了,就在那里空闲等厨师做好菜。
nodejs是怎么做的呢?
nodejs只雇佣一个服务员,当客人来的时候就点菜,点完菜客人就去坐着。然后服务员把菜单给厨师,厨师开始做菜。服务员继续招待下一个客人点菜,这个服务员就一直闲不下来。
因此,nodejs 适用于高并发、I/O 密集的场景,不适用CPU密集场景。如果是CPU密集,比如点菜,你点一个满汉全席,点菜就要花很多时间,那厨师就等着了,所以node不适合于处理CPU密集的场景。
什么是 I/O 操作(Input/Output Operations)
I/O 操作指的是计算机系统中输入(Input)和输出(Output)的操作,也就是计算机与外部设备(如硬盘、网络、键盘、显示器等)之间进行数据传输的过程。在 Node.js 里,常见的 I/O 操作包括文件读写、网络请求与响应等。
特点
-
耗时性:I/O 操作通常比较耗时,因为外部设备的读写速度远远低于 CPU 的运算速度。例如,从硬盘读取文件或者通过网络发送数据都需要一定的时间来完成物理层面的数据传输。
-
阻塞与非阻塞:
- 阻塞 I/O:当执行阻塞 I/O 操作时,程序会暂停执行后续代码,直到 I/O 操作完成。这会导致 CPU 处于空闲状态,浪费计算资源。
- 非阻塞 I/O:非阻塞 I/O 允许程序在进行 I/O 操作的同时继续执行后续代码,当 I/O 操作完成后会通过回调函数等方式通知程序。Node.js 采用事件驱动和非阻塞 I/O 模型,能够高效处理大量并发的 I/O 操作。
Node.js 采用单线程的事件循环机制来处理非阻塞 I/O 操作,但实际上底层会借助线程池来完成具体的 I/O 任务,这里的线程池由 libuv 库提供支持。
执行流程
-
主线程:当在 Node.js 代码中发起一个非阻塞 I/O 操作(如文件读取、网络请求)时,主线程会将该 I/O 任务交给 libuv 的线程池处理。之后,主线程不会等待 I/O 操作完成,而是继续执行后续的代码。
-
线程池:libuv 的线程池通常默认包含 4 个线程(可以通过
UV_THREADPOOL_SIZE环境变量调整线程数量),线程池中的线程会负责执行具体的 I/O 操作。例如,当执行文件读取操作时,线程池中的某个线程会与操作系统进行交互,从磁盘读取数据。 -
事件循环:当 I/O 操作完成后,线程池会将完成的消息反馈给 Node.js 的事件循环。事件循环会将对应的回调函数添加到任务队列中,等待主线程空闲时执行该回调函数,以处理 I/O 操作的结果。
const fs = require('fs');
fs.readFile('example.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});
console.log('继续执行其他代码');
在上述代码中,fs.readFile 是一个非阻塞 I/O 操作,主线程发起该操作后会继续执行 console.log('继续执行其他代码'),而文件读取任务会由线程池中的线程完成,完成后通过回调函数处理读取结果。
那Nodejs是如何处理网络 I/O 操作的呢?
在 Node.js 中,网络 I/O 操作(比如接收 HTTP 请求、发送网络请求等)并不是由 Node.js 自己完成的,而是委托给 操作系统内核 来处理。
操作系统内核是计算机的核心部分,它直接管理硬件资源(比如网络接口卡、文件系统等)。操作系统内核提供了高效的机制来处理网络通信,比如:
- 接收和发送网络数据包。
- 管理网络连接的状态。
- 处理底层的协议(如 TCP/IP)。
Node.js 通过调用操作系统的 API(如 Linux 的 epoll 或 Windows 的 IOCP)来委托这些任务。
操作系统内核在处理网络 I/O 操作时,会监控网络连接的状态。当数据就绪(比如收到了客户端发送的数据,或者数据库返回了查询结果),操作系统会通知 Node.js。
如何通知?
操作系统会通过一种机制(比如 epoll 或 IOCP)将"数据就绪"的事件放入事件队列中。Node.js 的事件循环会不断检查这个队列,发现有新事件时,就会触发相应的回调函数。
当有新的网络连接建立、数据到达或连接关闭等情况发生时,会触发相应的事件,如下面代码中的data, end, connection, node.js 可以通过监听这些事件来执行特定的回调函数。
var server = http.createServer(function (req, res) {
var method = req.method; // 获取请求方法
if (method === 'POST') {
req.on('data', function (chunk) {
// 接收到部分数据
console.log('chunk', chunk.toString().length);
});
req.on('end', function () {
// 接收数据完成
console.log('end');
res.end('OK');
});
}
// 其他请求方法暂不关心
});
server.on('connection', (socket) => {
console.log('有新的连接');
});
这种方式使得 Node.js 能够在处理大量并发网络连接时,不会因为某个 I/O 操作的阻塞而影响其他操作的执行。
总之,对于网络 I/O 操作,在 Linux 系统上,Node.js 借助操作系统的 epoll 机制;在 macOS 和其他 BSD 系统上,使用 kqueue 机制;在 Windows 系统上,使用 IOCP(Input/Output Completion Ports)机制。这些机制允许 Node.js 在不使用线程池的情况下高效地处理大量并发的网络连接,网络 I/O 操作通常由操作系统内核直接处理,当有数据就绪时,事件循环会得到通知并处理相应的事件。
什么是 CPU 操作
在 Node.js 里,CPU 操作主要由主线程完成。
Node.js 基于单线程的事件循环机制运行,默认情况下,所有的 JavaScript 代码(包括 CPU 密集型操作)都是在主线程上执行的。这意味着如果在代码中进行大量的计算、复杂的数据处理等 CPU 操作,会阻塞事件循环,使得其他任务(如 I/O 操作的回调处理、新的网络请求响应等)无法及时得到处理,从而影响整个应用程序的性能和响应能力。
function factorial(n) {
if (n === 0 || n === 1) {
return 1;
}
return n * factorial(n - 1);
}
// 执行一个较大数的阶乘计算,这是一个 CPU 密集型操作
const result = factorial(20);
console.log(result);
// 在阶乘计算完成之前,下面的定时器回调不会被执行
setTimeout(() => {
console.log('定时器回调执行');
}, 100);
为了避免 CPU 密集型操作阻塞主线程,Node.js 从版本 10 开始引入了 worker_threads 模块,允许创建多个工作线程来并行执行 CPU 密集型任务。工作线程可以独立于主线程运行,从而释放主线程来继续处理事件循环中的其他任务。