每当你问别人“什么是Node JS?”,经常得到的答案是“Node JS是一个运行时,在浏览器环境之外运行JavaScript”。
但是,运行时间是什么意思呢?运行时是一种软件基础设施。它拥有运行代码、处理错误、管理内存以及与底层操作系统交互的所有工具、库和特性。
Node JS拥有所有这些:
- 谷歌V8引擎运行代码
- 核心库和api,如fs, crypto, http等
- 像Libuv和事件循环这样的基础设施支持异步和非阻塞I/O操作
所以,我们现在可以知道为什么Node JS被称为运行时。
这个运行时由两个独立的依赖项组成,V8和libuv。
V8是一个引擎。在Node JS中,它执行JavaScript代码。当我们运行命令node index.js时,node JS将此代码传递给V8引擎。V8处理这些代码,执行它,并返回结果。
libuv库,使我们能够访问操作系统,执行网络、I/O或与时间相关的操作。它是Node JS和操作系统之间的桥梁。
libuv处理以下操作:
- 文件系统操作:读写文件(fs.readFile, fs.writeFile)。
- 网络:处理HTTP请求、套接字或连接到服务器。
- 定时器:管理setTimeout或setInterval等函数。
Node JS是单线程的吗?
请看下面的例子:
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, 'file.txt');
const readFileWithTiming = (index) => {
const start = Date.now();
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error(`Error reading the file for task ${index}:`, err);
return;
}
const end = Date.now();
console.log(`Task ${index} completed in ${end - start}ms`);
});
};
const startOverall = Date.now();
for (let i = 1; i <= 4; i++) {
readFileWithTiming(i);
}
process.on('exit', () => {
const endOverall = Date.now();
console.log(`Total execution time: ${endOverall - startOverall}ms`);
});
我们读取同一个文件四次,并且记录读取这些文件的时间。
我们得到这段代码的如下输出。
Task 1 completed in 50ms
Task 2 completed in 51ms
Task 3 completed in 52ms
Task 4 completed in 53ms
Total execution time: 54ms
我们可以看到,我们几乎在50毫秒的时间内完成了所有四个文件的读取。如果Node JS是单线程的,那么所有这些文件读取操作是如何同时完成的?
libuv库使用线程池。默认情况下,线程池大小为4,这意味着libuv可以一次处理4个请求。
考虑另一个场景,不是读取一个文件4次,而是读取该文件6次。
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, 'file.txt');
const readFileWithTiming = (index) => {
const start = Date.now();
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error(`Error reading the file for task ${index}:`, err);
return;
}
const end = Date.now();
console.log(`Task ${index} completed in ${end - start}ms`);
});
};
const startOverall = Date.now();
for (let i = 1; i <= 6; i++) {
readFileWithTiming(i);
}
process.on('exit', () => {
const endOverall = Date.now();
console.log(`Total execution time: ${endOverall - startOverall}ms`);
});
输出如下所示:
Task 1 completed in 50ms
Task 2 completed in 51ms
Task 3 completed in 52ms
Task 4 completed in 53ms
Task 5 completed in 101ms
Task 6 completed in 102ms
Total execution time: 103ms
假设操作1和操作2完成,线程1和线程2空闲。
你可以看到,前4次读取文件的时间几乎相同,但是当我们第5次和第6次读取这个文件时,完成读取操作的时间几乎是前4次操作的两倍。
这是因为线程池大小默认为4,所以同时处理4个读取操作,但是再读2次(第5次和第6次),libuv等待,因为所有线程都有一些工作。当四个线程中的一个完成执行时,第五次读取操作将被处理到该线程,第六次读取操作将被执行。这就是为什么它需要更多的时间。
所以,Node JS不是单线程的。
这是因为主事件循环是单线程的。这个线程负责执行Node JS代码,包括处理异步回调。它不直接处理像文件I/O这样的阻塞操作。
代码执行流程是这样的。
-
同步代码(V8):
Node.js使用V8 JavaScript引擎逐行执行所有同步(阻塞)代码。
-
委托异步任务:
像fs这样的异步操作。readFile、setTimeout或http请求被发送到Libuv库或其他子系统(如操作系统)。
-
任务执行:
像文件读取这样的任务由Libuv线程池处理,定时器由Libuv的定时器系统处理,网络调用由操作系统级api处理。
-
回调排队:
一旦异步任务完成,其关联的回调将被发送到事件循环的队列。
-
事件循环执行回调:
事件循环从队列中提取回调并逐个执行它们,以确保非阻塞执行。
您可以使用process.env更改线程池大小: process.env.UV_THREADPOOL_SIZE = 8
现在,我在想,如果我们设置高线程数,那么我们也将能够处理高数量的请求?
但是,这与我们的想法正好相反。
如果我们增加线程的数量超过一定的限制,那么它将减慢你的代码执行。
请看下面的例子:
const fs = require('fs');
const path = require('path');
// Set UV_THREADPOOL_SIZE to 100 (a high value) for this example
process.env.UV_THREADPOOL_SIZE = 100;
const filePath = path.join(__dirname, 'largeFile.txt');
// Function to simulate reading multiple files asynchronously
const readFileWithTiming = (index) => {
const start = Date.now();
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error(`Error reading the file for task ${index}:`, err);
return;
}
const end = Date.now();
console.log(`Task ${index} completed in ${end - start}ms`);
});
};
const startOverall = Date.now();
for (let i = 1; i <= 10; i++) {
readFileWithTiming(i);
}
process.on('exit', () => {
const endOverall = Date.now();
console.log(`Total execution time: ${endOverall - startOverall}ms`);
});
输出(100个线程):
Task 1 completed in 100ms
Task 2 completed in 98ms
Task 3 completed in 105ms
Task 4 completed in 95ms
Task 5 completed in 120ms
Task 6 completed in 130ms
Task 7 completed in 135ms
Task 8 completed in 140ms
Task 9 completed in 125ms
Task 10 completed in 150ms
Total execution time: 700ms
现在,当我们将线程池大小设置为4(默认大小)时,输出如下。
默认线程池大小(4个线程)
Task 1 completed in 100ms
Task 2 completed in 98ms
Task 3 completed in 105ms
Task 4 completed in 95ms
Task 5 completed in 100ms
Task 6 completed in 98ms
Task 7 completed in 102ms
Task 8 completed in 104ms
Task 9 completed in 106ms
Task 10 completed in 99ms
Total execution time: 600ms
您可以看到,总执行时间有100毫秒的差异。总执行时间(线程池大小为4)为600ms,总执行时间(线程池大小为100)为700ms。因此,线程池大小为4所花费的时间更少。
为什么高线程数!=更多的任务可以并发处理?
第一个原因是每个线程都有自己的堆栈和资源需求。如果增加线程数量,那么最终会导致内存不足或CPU资源不足的情况。
第二个原因是操作系统必须调度线程。如果有太多的线程,操作系统将花费大量时间在它们之间切换(上下文切换),这会增加开销并降低性能,而不是提高它。