对事件循环、多线程、在 Node.js 中执行 readFile() 的顺序有疑问?
Doubts about event loop , multi-threading, order of executing readFile() in Node.js?
fs.readFile("./large.txt", "utf8", (err, data) => {
console.log('It is a large file')
//this file has many words (11X MB).
//It takes 1-2 seconds to finish reading (usually 1)
});
fs.readFile("./small.txt","utf8", (err, data) => {
for(let i=0; i<99999 ;i++)
console.log('It is a small file');
//This file has just one word.
//It always takes 0 second
});
结果:
控制台总是先打印"It is a small file" 99999次(打印完成大约需要3秒)。
然后,当它们全部打印出来后,控制台并没有立即打印"It is a large file"。 (它总是在 1 或 2 秒后打印)。
我的想法:
所以,第一个 readFile() 和第二个 readFile() 函数似乎没有 运行 并行。如果两个 readFile() 函数 运行 并行,那么我希望在 "It is a small file" 被打印 99999 次之后,
第一个 readFile() 提前完成读取(仅 1 秒),控制台将立即打印出第一个 readFile() 的回调(即 "It is a large file".)
我的问题是:
(1a) 这是否意味着只有在第二个 readFile() 的回调完成其工作后,第一个 readFile() 才会开始读取文件?
(1b) 据我了解,在 nodeJs 中,事件循环将 readFile() 传递给 Libuv 多线程。但是,我想知道它们是按什么顺序通过的。如果这两个 readFile() 函数没有 运行 并行,为什么第二个 readFile() 函数总是先执行?
(2) 默认情况下,Libuv 有四个线程 Node.js。那么,在这里,这两个 readFile() 运行 是在同一个线程中吗?在这四个线程中,我不确定是否只有一个用于readFile()。
非常感谢您抽出宝贵时间!欣赏!
文件 I/O 在 Node.js 运行 中在单独的线程中。但这不要紧。 Node.js 总是 在主线程中执行所有回调。 I/O 回调永远不会在单独的线程中执行(文件读取操作在单独的线程中完成,然后当它完成时将向主线程发出信号 运行 您的回调)。 本质上使node.js成为单线程,因为你在主线程中编写的所有代码运行(我们当然忽略了worker_threads module/API 允许您在单独的线程中手动执行代码)。
但是文件 中的字节是 并行读取的(或在您的硬件允许的情况下并行读取 - 取决于空闲 DMA 通道的数量,每个文件来自哪个磁盘等) ).并行的是等待。任何语言(node.js、Java、C++、Python 等)中的异步 I/O 基本上是一个 API,允许您 等待 并行但在单个线程中处理事件。这种并行有个词:并发。它本质上是并行等待(数据由您的硬件并行处理)但不是并行代码执行。
我认为你了解事件循环和 libuv 的行为,不要迷路。
我的回答:
1a) 当然,这两个读取文件是在两个不同的线程中执行的,我尝试 运行 你的代码用一个小文件替换一个大文件,输出是
It is a large file
It is a small file
1b) 在你的情况下,第二次调用刚刚结束,然后调用回调。
2) 正如您所说,libuv 默认有四个线程,但请确保设置 env 变量 UV_THREADPOOL_SIZE ( http://docs.libuv.org/en/v1.x/threadpool.html )
不会更改默认值
我尝试处理一个大文件和一个大文件,我的电脑读取大文件需要 23/25 毫秒,读取小文件需要 8/10 毫秒。
当我尝试读取两个进程时,进程在 26/27 毫秒内终止,这表明两个读取文件是并行执行的。
尝试测量您的代码从小文件回调到大文件回调所花费的时间:
console.log(process.env.UV_THREADPOOL_SIZE)
const fs = require('fs')
const start = Date.now()
let smallFileEnd
fs.readFile("./alphabet.txt", "utf8", (err, data) => {
console.log('It is a large file')
console.log(`From the start to now are passed ${Date.now() - start} ms`)
console.log(`From the small file end to now are passed ${Date.now() - smallFileEnd} ms`)
//this file has many words (11X MB).
//It takes 1-2 seconds to finish reading (usually 1)
// 18ms to execute
});
fs.readFile("./Whosebug.js","utf8", (err, data) => {
for(let i=0; i<99999 ;i++)
if(i === 99998){
smallFileEnd = Date.now()
console.log('is a small file ')
console.log(`From the start to now are passed ${Date.now() - start} ms`)
// 4/7 ms to execute
}
});
我无法相信节点会延迟大文件读取,直到小文件读取的回调完成,所以我对您的示例做了更多的检测:
const fs = require('fs');
const readLarge = process.argv.includes('large');
const readSmall = process.argv.includes('small');
if (readLarge) {
console.time('large');
fs.readFile('./large.txt', 'utf8', (err, data) => {
console.timeEnd('large');
if (readSmall) {
console.timeEnd('large (since busy wait)');
}
});
}
if (readSmall) {
console.time('small');
fs.readFile('./small.txt', 'utf8', (err, data) => {
console.timeEnd('small');
var stop = new Date().getTime();
while(new Date().getTime() < stop + 3000) { ; } // busy wait
console.time('large (since busy wait)');
});
}
(请注意,我用 3 秒的忙等待替换了您的 console.logs 循环)。
运行 针对节点 v8.15.0 我得到以下结果:
$ node t small # read small file only
small: 0.839ms
$ node t large # read large file only
large: 447.348ms
$ node t small large # read both files
small: 3.916ms
large: 3252.752ms
large (since busy wait): 247.632ms
这些结果看起来很合理;大文件自己读取需要约 0.5 秒,但当忙等待回调干扰 2 秒时,此后完成相对较快(约 1/4 秒)。调整繁忙等待的长度可以保持相对一致,所以我愿意说这是某种调度开销,并不一定表明大文件 I/O 还没有 运行在忙碌的等待中。
但后来我 运行 针对节点 10.16.3 使用相同的程序,这就是我得到的结果:
$ node t small
small: 1.614ms
$ node t large
large: 1019.047ms
$ node t small large
small: 3.595ms
large: 4014.904ms
large (since busy wait): 1009.469ms
哎呀!大文件读取时间不仅增加了一倍多(约 1 秒),而且在繁忙的等待结束之前,似乎根本没有 I/O 完成!也就是说,看起来主线程中的繁忙等待确实阻止了任何 I/O 发生在大文件上。
我怀疑从 8.x 到 10.x 的这种变化是节点 10 中这个 "optimization" 的结果:https://github.com/nodejs/node/pull/17054。这种将大文件的读取拆分为多个操作的更改似乎适合在一般用途情况下平滑系统性能,但在这种情况下不自然的长主线程处理/忙等待可能会加剧这种情况。据推测,如果没有主线程让步,I/O 就没有机会前进到要读取的大文件中的下一个 运行ge 字节。
看来,对于 Node 10.x,拥有一个响应式主线程(即,频繁产生,并且不会像本例中那样忙着等待)以维持是很重要的I/O 大文件读取性能。
(1a) Does this mean that the first readFile() will start to read file only after the callback of second readFile() has done its work?
没有。每个 readFile()
实际上由多个步骤组成(打开文件、读取块、读取块...关闭文件)。步骤之间的逻辑流由 node.js fs
库中的 Javascript 代码控制。但是,每个步骤的一部分是由 libuv 中使用线程池的本机线程代码实现的。
因此,第一个 readFile()
的第一步将启动,然后控制权 return 返回给 JS 解释器。然后,第二个readFile()
的第一步会被启动,然后控制return返回给JS解释器。只要 JS 解释器不忙,它就可以在两个 readFile()
操作的进度之间来回切换。但是,如果 JS 解释器确实忙了一段时间,当当前在后台进行的步骤完成时,它将停止进一步的处理。如果您想了解每个步骤的详细信息,答案末尾有完整的 step-by-step 年表。
(1b) To my understanding, in nodeJs, event loop passes the readFile() to Libuv multi-thread. However, I wonder in what order they are passed. If these two readFile() functions do not run in parallel, why is the second readFile() function always executed first?
fs.readFile()
本身并没有在libuv中实现。它作为 node.js Javascript 中的一系列单独步骤实现。每个单独的步骤(打开文件、读取块、关闭文件)都是在 libuv 中实现的,但是 fs
库中的 Javascript 控制步骤之间的顺序。因此,将 fs.readfile()
视为对 libuv 的一系列调用。当您同时进行两个 fs.readFile()
操作时,每个操作都会在任何给定时间进行一些 libuv 操作,并且每个 fs.readFile()
的一个步骤可以并行进行,因为线程池实现在利布夫。但是,在流程的每个步骤之间,控制权都会返回给 JS 解释器。因此,如果解释器在一段时间内变得忙碌,那么在安排另一个 fs.readFile()
操作的下一步方面的进一步进展就会停滞。
(2) By default, Libuv has four threads for Node.js. So, here, do these two readFile() run in the same thread? Among these four threads, I am not sure whether there is only one for readFile().
我想这在前两个解释中已经涵盖了。 readFile()
本身并没有在 libuv 的本机代码中实现。相反,它是用 Javascript 编写的,调用打开、读取、关闭操作,这些操作是用本机代码编写的,并使用 libuv 和线程池。
这是对正在发生的事情的完整说明。要完全理解,需要了解这些:
主要概念
- 单线程,non-pre-emptive node.js 运行 宁你的 Javascript 的性质(假设没有
WorkerThreads
在这里手动编码 - 它们不是' t).
- multi-threaded,
fs
模块文件的本机代码 I/O 及其工作原理。
- 当 JS 解释器忙于做某事时,本机代码异步操作如何通过事件队列传达完成信息以及事件循环调度如何工作。
异步,Non-Blocking
我想你知道 fs.readFile()
是异步的并且 non-blocking。这意味着当你调用它时,它所做的只是启动一个操作来读取文件,然后它直接进入 fs.readFile()
之后顶层的下一行代码(不是你传递给的回调中的代码)它)。
所以,你的代码的精简版基本上是这样的:
fs.readFile(x, funcA);
fs.readFile(y, funcB);
如果我们为此添加一些日志记录:
function funcA() {
console.log("funcA");
}
function funcB() {
console.log("funcB");
}
function spin(howLong) {
let finishTime = Date.now() + howLong;
// spin until howLong ms passes
while (Date.now() < finishTime) {}
}
console.log("1");
fs.readFile(x, funcA);
console.log("2");
fs.readFile(y, funcB);
console.log("3");
spin(30000); // spin for 30 seconds
console.log("4");
您会看到以下顺序之一:
1
2
3
4
A
B
或者这个顺序:
1
2
3
4
B
A
这两者中的哪一个将仅取决于两个 fs.readFile()
操作之间不确定的竞争。两者都可能发生。另外,请注意 1
、2
、3
和 4
都在任何异步完成事件发生之前被记录下来。这是因为single-threaded、non-pre-emptiveJS解释器主线程正忙于执行Javascript。在执行完 Javascript.
这一段之前,它不会从事件队列中拉出下一个事件
Libuv 线程池
您似乎已经知道,fs
模块为 运行ning 文件 I/O 使用 libuv 线程池。这独立于主 JS 线程,因此那些读取操作可以独立于进一步的 JS 执行而进行。使用本机代码,文件 I/O 将在完成安排完成回调时与事件队列通信。
两个异步操作之间的不确定竞争
因此,您刚刚在两个 fs.readFile()
操作之间创建了不确定的竞争,这两个操作可能每个 运行 都在自己的线程中。小文件更有可能在大文件之前先完成,因为大文件有更多的数据要从磁盘读取。
哪个 fs.readFile()
先完成,就会先将其回调插入到事件队列中。当 JS 解释器空闲时,它会从事件队列中挑选下一个事件。无论哪个先完成,都会先 运行 它的回调。由于小文件很可能首先完成(这是您报告的内容),它会到达 运行 回调。现在,当它 运行 正在回调时,这只是 Javascript 并且即使大文件可能完成并将其回调插入事件队列,回调也不能运行 直到小文件的回调完成。因此,它完成然后来自大文件的回调到达 运行.
一般来说,除非您完全不关心这两个异步操作的完成顺序,否则您永远不应该编写这样的代码,因为这是一场不确定的竞赛,您不能指望哪个会先完成。由于 fs.readFile()
的异步 non-blocking 性质,无法保证第一个启动的文件操作将首先完成。这与一个接一个地发出两个单独的 http 请求没有什么不同。你不知道哪一个会先完成。
一步一步的年表
这是发生的事情的分步时间顺序:
- 你打电话给
fs.readFile("./large.txt", ...)
;
- 在 Javascript 代码中,通过调用本机代码然后 returns 打开
large.txt
文件。实际文件的打开由 libuv 在本机代码中处理,完成后,一个事件将插入 JS 事件队列。
- 在该操作启动后立即,然后是第一个
fs.readFile()
returns(尚未完成,仍在内部处理)。
- 现在 JS 解释器在下一行代码处开始执行 运行s
fs.readFile("./small.txt", ...);
- 在 Javascript 代码中,通过调用本机代码然后 returns 启动打开
small.txt
文件。实际文件的打开由 libuv 在本机代码中处理,完成后,一个事件将插入 JS 事件队列。
- 在该操作启动后立即,然后是第二个
fs.readFile()
returns(尚未完成,仍在内部处理)。
- JS 解释器实际上可以自由 运行 任何后续代码或处理任何传入事件。
- 然后,一段时间后,两个
fs.readFile()
操作中的一个完成了第一步(打开文件),一个事件被插入到 JS 事件队列中,当 JS 解释器有时间时,回调叫做。由于打开每个文件的操作时间大致相同,因此 large.txt
文件的打开操作可能先完成,但这不是 gua运行teed.
- 文件打开成功后,发起异步操作,从文件中读取第一个chunk。这又是异步的,由 libuv 处理,因此一旦启动,它 return 将控制权交还给 JS 解释器。
- 第二个文件打开很可能接下来会结束,它做与第一个相同的事情,开始从磁盘读取第一个数据块,然后return将控制权交还给 JS 解释器。
- 然后,这两个块读取之一完成并将一个事件插入事件队列,当 JS 解释器空闲时,调用回调来处理它。此时,这可能是大文件或小文件,但为了解释简单起见,我们假设大文件的第一个块首先完成。它将缓冲该块,看到有更多数据要读取,并将启动另一个异步读取操作,然后 return 将控制权交还给 JS 解释器。
- 然后,另一个第一个块读取完成。它将缓冲该块并查看没有更多数据可读。此时,它将发出一个文件关闭操作,该操作再次由 libuv 处理,控制权 return 返回给 JS 解释器。
- 前两个操作之一完成(从
large.txt
读取第二个块或 small.txt
的文件关闭)并调用其回调。由于关闭操作不必实际接触磁盘(它只是进入 OS),为了便于解释,我们假设关闭操作首先完成。该关闭会触发 small.txt
的 fs.ReadFile()
结束,并为此调用完成回调。
- 因此,此时
small.txt
已完成并且 large.txt
已从其文件中读取一个块并正在等待第二个块的读取完成。
- 您的代码现在执行
for
循环,该循环需要花费任何时间。
- 到完成并且 JS 解释器再次空闲时,从
large.txt
读取的第二个文件可能已完成,因此 JS 解释器在事件队列中找到它的事件并执行回调以执行更多操作处理从该文件读取更多块。
- 读取块的过程,return将控制权交还给解释器,等待下一个块完成事件,然后继续缓冲该块,直到读取所有数据。
- 然后对
large.txt
发起关闭操作。
- 关闭操作完成后,将调用
large.txt
的 fs.readFile()
回调,您的计时代码 large.txt
将测量完成。
所以,因为 fs.readFile()
的逻辑是在 Javascript 中实现的,其中包含许多离散的异步步骤,每个步骤最终都由 libuv 处理(打开文件,读取块 - N 次,关闭文件), 会有一个整数rleaving 两个文件之间的工作。较小文件的读取将首先完成,因为它的读取操作较少且较小。完成后,大文件仍将有多个块要读取,并剩下一个关闭操作。因为 fs.readFile()
的多个步骤是通过 Javascript 控制的,所以当您在 small.txt
完成时执行长 for
循环时,您正在拖延 fs.readFile()
操作large.txt
文件也是。当该循环发生时,任何正在进行的块读取都将在后台完成,但在该小文件回调完成之前不会发出下一个块读取。
看来 node.js 有机会在像这样的竞争环境中提高 fs.readFile()
的响应能力,如果该操作完全用本机代码重写,这样一个本机代码操作就可以读取整个文件的内容,而不是在单线程主 JS 线程和 libuv 之间来回读取所有这些 t运行sitions。如果是这种情况,大 for
循环不会阻止 large.txt
的进程,因为它将完全在 libuv 线程中进行,而不是等待来自 JS 解释器的一些循环以获得到下一步。
我们可以推断,如果两个文件都能够在一个块中读取,那么长 for
循环不会造成太多延迟。两个文件都将被打开(每个文件应该花费大约相同的时间)。这两个操作都会启动对它们的第一个块的读取。较小文件的读取可能首先完成(读取的数据较少),但实际上这取决于 OS 和磁盘控制器逻辑。因为实际读取被移交给线程代码,所以两个读取将同时挂起。假设较小的读取首先完成,它将触发完成,然后在繁忙的循环中,较大的读取将完成,在事件队列中插入一个事件。当繁忙的循环结束时,对较大的文件(但仍然可以在一个块中读取的内容)唯一要做的就是关闭文件,这是一个更快的操作。
但是,当较大的文件不能在一个块中读取并且需要多个块读取时,这就是为什么它的进度真的被繁忙的循环停滞了,因为一个块完成了,但下一个块没有得到安排到忙循环完成。
测试中
所以,让我们来检验所有这些理论。我创建了两个文件。 small.txt
是 558 字节。 large.txt
是 255,194,500 字节。
然后,我编写了以下程序来为这些计时,并允许我们在小循环结束后选择进行 3 秒的自旋循环。
const fs = require('fs');
let doSpin = false; // -s will set this to true
let fname = "./large.txt";
for (let i = 2; i < process.argv.length; i++) {
let arg = process.argv[i];
console.log(`"${arg}"`);
if (arg.startsWith("-")) {
switch(arg) {
case "-s":
doSpin = true;
break;
default:
console.log(`Unknown arg ${arg}`);
process.exit(1);
break;
}
} else {
fname = arg;
}
}
function padDecimal(num, n = 3) {
let str = num.toFixed(n);
let index = str.indexOf(".");
if (index === -1) {
str += ".";
index = str.length - 1;
}
let zeroesToAdd = n - (str.length - index);
while (zeroesToAdd-- >= 0) {
str += "0";
}
return str;
}
let startTime;
function log(msg) {
if (!startTime) {
startTime = Date.now();
}
let diff = (Date.now() - startTime) / 1000; // in seconds
console.log(padDecimal(diff), ":", msg)
}
function funcA(err, data) {
if (err) {
log("error on large");
log(err);
return;
}
log("large completed");
}
function funcB(err, data) {
if (err) {
log("error on small");
log(err);
return;
}
log("small completed");
if (doSpin) {
spin(3000);
log("spin completed");
}
}
function spin(howLong) {
let finishTime = Date.now() + howLong;
// spin until howLong ms passes
while (Date.now() < finishTime) {}
}
log("start");
fs.readFile(fname, funcA);
log("large initiated");
fs.readFile("./small.txt", funcB);
log("small initiated");
然后(使用节点 v12.13.0),我 运行 它有和没有 3 秒旋转。没有自旋,我得到这个输出:
0.000 : start
0.015 : large initiated
0.016 : small initiated
0.021 : small completed
0.240 : large completed
这显示完成小和大的时间之间有 0.219 秒的差异(而 运行同时完成两者)。
然后,插入 3 秒延迟,我们得到这个输出:
0.000 : start
0.003 : large initiated
0.004 : small initiated
0.009 : small completed
3.010 : spin completed
3.229 : large completed
我们在完成小和大的时间之间有完全相同的 0.219 秒增量(而 运行同时完成两者)。这表明大 fs.readFile()
在 3 秒旋转期间基本上没有取得任何进展。它的前进完全受阻。正如我们在之前的解释中所推测的那样,这显然是因为从一个分块读取到下一个分块读取的进展是写在 Javascript 中的,而自旋循环是 运行ning 时,该进展到下一个块已被封锁,无法取得任何进展。
多大的文件使大文件排在第二位?
如果您在节点 v12.13.0 的源代码中查看 fs.readFile()
的代码,您会发现它读取的块大小为 512 * 1024,即 512k。因此,从理论上讲,如果可以在一个块中读取较大的文件,则较大的文件可能会首先完成。这是否真的发生取决于一些 OS 和磁盘实现细节,但我想我会在我的笔记本电脑上尝试它 运行 使用 SSD 驱动器的 Windows 10 的当前版本.
我发现,对于一个 255k "large" 的文件,它确实在小文件之前完成(基本上是按执行顺序)。所以,因为大文件读取在小文件读取之前开始,即使它有更多的数据要读取,它仍然会在小文件之前完成。
0.000 : start
0.003 : large initiated
0.003 : small initiated
0.007 : large completed
0.008 : small completed
请记住,这是 OS 和磁盘相关的,所以这不是 gua运行teed。
fs.readFile("./large.txt", "utf8", (err, data) => {
console.log('It is a large file')
//this file has many words (11X MB).
//It takes 1-2 seconds to finish reading (usually 1)
});
fs.readFile("./small.txt","utf8", (err, data) => {
for(let i=0; i<99999 ;i++)
console.log('It is a small file');
//This file has just one word.
//It always takes 0 second
});
结果:
控制台总是先打印"It is a small file" 99999次(打印完成大约需要3秒)。 然后,当它们全部打印出来后,控制台并没有立即打印"It is a large file"。 (它总是在 1 或 2 秒后打印)。
我的想法:
所以,第一个 readFile() 和第二个 readFile() 函数似乎没有 运行 并行。如果两个 readFile() 函数 运行 并行,那么我希望在 "It is a small file" 被打印 99999 次之后, 第一个 readFile() 提前完成读取(仅 1 秒),控制台将立即打印出第一个 readFile() 的回调(即 "It is a large file".)
我的问题是:
(1a) 这是否意味着只有在第二个 readFile() 的回调完成其工作后,第一个 readFile() 才会开始读取文件?
(1b) 据我了解,在 nodeJs 中,事件循环将 readFile() 传递给 Libuv 多线程。但是,我想知道它们是按什么顺序通过的。如果这两个 readFile() 函数没有 运行 并行,为什么第二个 readFile() 函数总是先执行?
(2) 默认情况下,Libuv 有四个线程 Node.js。那么,在这里,这两个 readFile() 运行 是在同一个线程中吗?在这四个线程中,我不确定是否只有一个用于readFile()。
非常感谢您抽出宝贵时间!欣赏!
文件 I/O 在 Node.js 运行 中在单独的线程中。但这不要紧。 Node.js 总是 在主线程中执行所有回调。 I/O 回调永远不会在单独的线程中执行(文件读取操作在单独的线程中完成,然后当它完成时将向主线程发出信号 运行 您的回调)。 本质上使node.js成为单线程,因为你在主线程中编写的所有代码运行(我们当然忽略了worker_threads module/API 允许您在单独的线程中手动执行代码)。
但是文件 中的字节是 并行读取的(或在您的硬件允许的情况下并行读取 - 取决于空闲 DMA 通道的数量,每个文件来自哪个磁盘等) ).并行的是等待。任何语言(node.js、Java、C++、Python 等)中的异步 I/O 基本上是一个 API,允许您 等待 并行但在单个线程中处理事件。这种并行有个词:并发。它本质上是并行等待(数据由您的硬件并行处理)但不是并行代码执行。
我认为你了解事件循环和 libuv 的行为,不要迷路。
我的回答:
1a) 当然,这两个读取文件是在两个不同的线程中执行的,我尝试 运行 你的代码用一个小文件替换一个大文件,输出是
It is a large file
It is a small file
1b) 在你的情况下,第二次调用刚刚结束,然后调用回调。
2) 正如您所说,libuv 默认有四个线程,但请确保设置 env 变量 UV_THREADPOOL_SIZE ( http://docs.libuv.org/en/v1.x/threadpool.html )
不会更改默认值我尝试处理一个大文件和一个大文件,我的电脑读取大文件需要 23/25 毫秒,读取小文件需要 8/10 毫秒。 当我尝试读取两个进程时,进程在 26/27 毫秒内终止,这表明两个读取文件是并行执行的。
尝试测量您的代码从小文件回调到大文件回调所花费的时间:
console.log(process.env.UV_THREADPOOL_SIZE)
const fs = require('fs')
const start = Date.now()
let smallFileEnd
fs.readFile("./alphabet.txt", "utf8", (err, data) => {
console.log('It is a large file')
console.log(`From the start to now are passed ${Date.now() - start} ms`)
console.log(`From the small file end to now are passed ${Date.now() - smallFileEnd} ms`)
//this file has many words (11X MB).
//It takes 1-2 seconds to finish reading (usually 1)
// 18ms to execute
});
fs.readFile("./Whosebug.js","utf8", (err, data) => {
for(let i=0; i<99999 ;i++)
if(i === 99998){
smallFileEnd = Date.now()
console.log('is a small file ')
console.log(`From the start to now are passed ${Date.now() - start} ms`)
// 4/7 ms to execute
}
});
我无法相信节点会延迟大文件读取,直到小文件读取的回调完成,所以我对您的示例做了更多的检测:
const fs = require('fs');
const readLarge = process.argv.includes('large');
const readSmall = process.argv.includes('small');
if (readLarge) {
console.time('large');
fs.readFile('./large.txt', 'utf8', (err, data) => {
console.timeEnd('large');
if (readSmall) {
console.timeEnd('large (since busy wait)');
}
});
}
if (readSmall) {
console.time('small');
fs.readFile('./small.txt', 'utf8', (err, data) => {
console.timeEnd('small');
var stop = new Date().getTime();
while(new Date().getTime() < stop + 3000) { ; } // busy wait
console.time('large (since busy wait)');
});
}
(请注意,我用 3 秒的忙等待替换了您的 console.logs 循环)。
运行 针对节点 v8.15.0 我得到以下结果:
$ node t small # read small file only
small: 0.839ms
$ node t large # read large file only
large: 447.348ms
$ node t small large # read both files
small: 3.916ms
large: 3252.752ms
large (since busy wait): 247.632ms
这些结果看起来很合理;大文件自己读取需要约 0.5 秒,但当忙等待回调干扰 2 秒时,此后完成相对较快(约 1/4 秒)。调整繁忙等待的长度可以保持相对一致,所以我愿意说这是某种调度开销,并不一定表明大文件 I/O 还没有 运行在忙碌的等待中。
但后来我 运行 针对节点 10.16.3 使用相同的程序,这就是我得到的结果:
$ node t small
small: 1.614ms
$ node t large
large: 1019.047ms
$ node t small large
small: 3.595ms
large: 4014.904ms
large (since busy wait): 1009.469ms
哎呀!大文件读取时间不仅增加了一倍多(约 1 秒),而且在繁忙的等待结束之前,似乎根本没有 I/O 完成!也就是说,看起来主线程中的繁忙等待确实阻止了任何 I/O 发生在大文件上。
我怀疑从 8.x 到 10.x 的这种变化是节点 10 中这个 "optimization" 的结果:https://github.com/nodejs/node/pull/17054。这种将大文件的读取拆分为多个操作的更改似乎适合在一般用途情况下平滑系统性能,但在这种情况下不自然的长主线程处理/忙等待可能会加剧这种情况。据推测,如果没有主线程让步,I/O 就没有机会前进到要读取的大文件中的下一个 运行ge 字节。
看来,对于 Node 10.x,拥有一个响应式主线程(即,频繁产生,并且不会像本例中那样忙着等待)以维持是很重要的I/O 大文件读取性能。
(1a) Does this mean that the first readFile() will start to read file only after the callback of second readFile() has done its work?
没有。每个 readFile()
实际上由多个步骤组成(打开文件、读取块、读取块...关闭文件)。步骤之间的逻辑流由 node.js fs
库中的 Javascript 代码控制。但是,每个步骤的一部分是由 libuv 中使用线程池的本机线程代码实现的。
因此,第一个 readFile()
的第一步将启动,然后控制权 return 返回给 JS 解释器。然后,第二个readFile()
的第一步会被启动,然后控制return返回给JS解释器。只要 JS 解释器不忙,它就可以在两个 readFile()
操作的进度之间来回切换。但是,如果 JS 解释器确实忙了一段时间,当当前在后台进行的步骤完成时,它将停止进一步的处理。如果您想了解每个步骤的详细信息,答案末尾有完整的 step-by-step 年表。
(1b) To my understanding, in nodeJs, event loop passes the readFile() to Libuv multi-thread. However, I wonder in what order they are passed. If these two readFile() functions do not run in parallel, why is the second readFile() function always executed first?
fs.readFile()
本身并没有在libuv中实现。它作为 node.js Javascript 中的一系列单独步骤实现。每个单独的步骤(打开文件、读取块、关闭文件)都是在 libuv 中实现的,但是 fs
库中的 Javascript 控制步骤之间的顺序。因此,将 fs.readfile()
视为对 libuv 的一系列调用。当您同时进行两个 fs.readFile()
操作时,每个操作都会在任何给定时间进行一些 libuv 操作,并且每个 fs.readFile()
的一个步骤可以并行进行,因为线程池实现在利布夫。但是,在流程的每个步骤之间,控制权都会返回给 JS 解释器。因此,如果解释器在一段时间内变得忙碌,那么在安排另一个 fs.readFile()
操作的下一步方面的进一步进展就会停滞。
(2) By default, Libuv has four threads for Node.js. So, here, do these two readFile() run in the same thread? Among these four threads, I am not sure whether there is only one for readFile().
我想这在前两个解释中已经涵盖了。 readFile()
本身并没有在 libuv 的本机代码中实现。相反,它是用 Javascript 编写的,调用打开、读取、关闭操作,这些操作是用本机代码编写的,并使用 libuv 和线程池。
这是对正在发生的事情的完整说明。要完全理解,需要了解这些:
主要概念
- 单线程,non-pre-emptive node.js 运行 宁你的 Javascript 的性质(假设没有
WorkerThreads
在这里手动编码 - 它们不是' t). - multi-threaded,
fs
模块文件的本机代码 I/O 及其工作原理。 - 当 JS 解释器忙于做某事时,本机代码异步操作如何通过事件队列传达完成信息以及事件循环调度如何工作。
异步,Non-Blocking
我想你知道 fs.readFile()
是异步的并且 non-blocking。这意味着当你调用它时,它所做的只是启动一个操作来读取文件,然后它直接进入 fs.readFile()
之后顶层的下一行代码(不是你传递给的回调中的代码)它)。
所以,你的代码的精简版基本上是这样的:
fs.readFile(x, funcA);
fs.readFile(y, funcB);
如果我们为此添加一些日志记录:
function funcA() {
console.log("funcA");
}
function funcB() {
console.log("funcB");
}
function spin(howLong) {
let finishTime = Date.now() + howLong;
// spin until howLong ms passes
while (Date.now() < finishTime) {}
}
console.log("1");
fs.readFile(x, funcA);
console.log("2");
fs.readFile(y, funcB);
console.log("3");
spin(30000); // spin for 30 seconds
console.log("4");
您会看到以下顺序之一:
1
2
3
4
A
B
或者这个顺序:
1
2
3
4
B
A
这两者中的哪一个将仅取决于两个 fs.readFile()
操作之间不确定的竞争。两者都可能发生。另外,请注意 1
、2
、3
和 4
都在任何异步完成事件发生之前被记录下来。这是因为single-threaded、non-pre-emptiveJS解释器主线程正忙于执行Javascript。在执行完 Javascript.
Libuv 线程池
您似乎已经知道,fs
模块为 运行ning 文件 I/O 使用 libuv 线程池。这独立于主 JS 线程,因此那些读取操作可以独立于进一步的 JS 执行而进行。使用本机代码,文件 I/O 将在完成安排完成回调时与事件队列通信。
两个异步操作之间的不确定竞争
因此,您刚刚在两个 fs.readFile()
操作之间创建了不确定的竞争,这两个操作可能每个 运行 都在自己的线程中。小文件更有可能在大文件之前先完成,因为大文件有更多的数据要从磁盘读取。
哪个 fs.readFile()
先完成,就会先将其回调插入到事件队列中。当 JS 解释器空闲时,它会从事件队列中挑选下一个事件。无论哪个先完成,都会先 运行 它的回调。由于小文件很可能首先完成(这是您报告的内容),它会到达 运行 回调。现在,当它 运行 正在回调时,这只是 Javascript 并且即使大文件可能完成并将其回调插入事件队列,回调也不能运行 直到小文件的回调完成。因此,它完成然后来自大文件的回调到达 运行.
一般来说,除非您完全不关心这两个异步操作的完成顺序,否则您永远不应该编写这样的代码,因为这是一场不确定的竞赛,您不能指望哪个会先完成。由于 fs.readFile()
的异步 non-blocking 性质,无法保证第一个启动的文件操作将首先完成。这与一个接一个地发出两个单独的 http 请求没有什么不同。你不知道哪一个会先完成。
一步一步的年表
这是发生的事情的分步时间顺序:
- 你打电话给
fs.readFile("./large.txt", ...)
; - 在 Javascript 代码中,通过调用本机代码然后 returns 打开
large.txt
文件。实际文件的打开由 libuv 在本机代码中处理,完成后,一个事件将插入 JS 事件队列。 - 在该操作启动后立即,然后是第一个
fs.readFile()
returns(尚未完成,仍在内部处理)。 - 现在 JS 解释器在下一行代码处开始执行 运行s
fs.readFile("./small.txt", ...);
- 在 Javascript 代码中,通过调用本机代码然后 returns 启动打开
small.txt
文件。实际文件的打开由 libuv 在本机代码中处理,完成后,一个事件将插入 JS 事件队列。 - 在该操作启动后立即,然后是第二个
fs.readFile()
returns(尚未完成,仍在内部处理)。 - JS 解释器实际上可以自由 运行 任何后续代码或处理任何传入事件。
- 然后,一段时间后,两个
fs.readFile()
操作中的一个完成了第一步(打开文件),一个事件被插入到 JS 事件队列中,当 JS 解释器有时间时,回调叫做。由于打开每个文件的操作时间大致相同,因此large.txt
文件的打开操作可能先完成,但这不是 gua运行teed. - 文件打开成功后,发起异步操作,从文件中读取第一个chunk。这又是异步的,由 libuv 处理,因此一旦启动,它 return 将控制权交还给 JS 解释器。
- 第二个文件打开很可能接下来会结束,它做与第一个相同的事情,开始从磁盘读取第一个数据块,然后return将控制权交还给 JS 解释器。
- 然后,这两个块读取之一完成并将一个事件插入事件队列,当 JS 解释器空闲时,调用回调来处理它。此时,这可能是大文件或小文件,但为了解释简单起见,我们假设大文件的第一个块首先完成。它将缓冲该块,看到有更多数据要读取,并将启动另一个异步读取操作,然后 return 将控制权交还给 JS 解释器。
- 然后,另一个第一个块读取完成。它将缓冲该块并查看没有更多数据可读。此时,它将发出一个文件关闭操作,该操作再次由 libuv 处理,控制权 return 返回给 JS 解释器。
- 前两个操作之一完成(从
large.txt
读取第二个块或small.txt
的文件关闭)并调用其回调。由于关闭操作不必实际接触磁盘(它只是进入 OS),为了便于解释,我们假设关闭操作首先完成。该关闭会触发small.txt
的fs.ReadFile()
结束,并为此调用完成回调。 - 因此,此时
small.txt
已完成并且large.txt
已从其文件中读取一个块并正在等待第二个块的读取完成。 - 您的代码现在执行
for
循环,该循环需要花费任何时间。 - 到完成并且 JS 解释器再次空闲时,从
large.txt
读取的第二个文件可能已完成,因此 JS 解释器在事件队列中找到它的事件并执行回调以执行更多操作处理从该文件读取更多块。 - 读取块的过程,return将控制权交还给解释器,等待下一个块完成事件,然后继续缓冲该块,直到读取所有数据。
- 然后对
large.txt
发起关闭操作。 - 关闭操作完成后,将调用
large.txt
的fs.readFile()
回调,您的计时代码large.txt
将测量完成。
所以,因为 fs.readFile()
的逻辑是在 Javascript 中实现的,其中包含许多离散的异步步骤,每个步骤最终都由 libuv 处理(打开文件,读取块 - N 次,关闭文件), 会有一个整数rleaving 两个文件之间的工作。较小文件的读取将首先完成,因为它的读取操作较少且较小。完成后,大文件仍将有多个块要读取,并剩下一个关闭操作。因为 fs.readFile()
的多个步骤是通过 Javascript 控制的,所以当您在 small.txt
完成时执行长 for
循环时,您正在拖延 fs.readFile()
操作large.txt
文件也是。当该循环发生时,任何正在进行的块读取都将在后台完成,但在该小文件回调完成之前不会发出下一个块读取。
看来 node.js 有机会在像这样的竞争环境中提高 fs.readFile()
的响应能力,如果该操作完全用本机代码重写,这样一个本机代码操作就可以读取整个文件的内容,而不是在单线程主 JS 线程和 libuv 之间来回读取所有这些 t运行sitions。如果是这种情况,大 for
循环不会阻止 large.txt
的进程,因为它将完全在 libuv 线程中进行,而不是等待来自 JS 解释器的一些循环以获得到下一步。
我们可以推断,如果两个文件都能够在一个块中读取,那么长 for
循环不会造成太多延迟。两个文件都将被打开(每个文件应该花费大约相同的时间)。这两个操作都会启动对它们的第一个块的读取。较小文件的读取可能首先完成(读取的数据较少),但实际上这取决于 OS 和磁盘控制器逻辑。因为实际读取被移交给线程代码,所以两个读取将同时挂起。假设较小的读取首先完成,它将触发完成,然后在繁忙的循环中,较大的读取将完成,在事件队列中插入一个事件。当繁忙的循环结束时,对较大的文件(但仍然可以在一个块中读取的内容)唯一要做的就是关闭文件,这是一个更快的操作。
但是,当较大的文件不能在一个块中读取并且需要多个块读取时,这就是为什么它的进度真的被繁忙的循环停滞了,因为一个块完成了,但下一个块没有得到安排到忙循环完成。
测试中
所以,让我们来检验所有这些理论。我创建了两个文件。 small.txt
是 558 字节。 large.txt
是 255,194,500 字节。
然后,我编写了以下程序来为这些计时,并允许我们在小循环结束后选择进行 3 秒的自旋循环。
const fs = require('fs');
let doSpin = false; // -s will set this to true
let fname = "./large.txt";
for (let i = 2; i < process.argv.length; i++) {
let arg = process.argv[i];
console.log(`"${arg}"`);
if (arg.startsWith("-")) {
switch(arg) {
case "-s":
doSpin = true;
break;
default:
console.log(`Unknown arg ${arg}`);
process.exit(1);
break;
}
} else {
fname = arg;
}
}
function padDecimal(num, n = 3) {
let str = num.toFixed(n);
let index = str.indexOf(".");
if (index === -1) {
str += ".";
index = str.length - 1;
}
let zeroesToAdd = n - (str.length - index);
while (zeroesToAdd-- >= 0) {
str += "0";
}
return str;
}
let startTime;
function log(msg) {
if (!startTime) {
startTime = Date.now();
}
let diff = (Date.now() - startTime) / 1000; // in seconds
console.log(padDecimal(diff), ":", msg)
}
function funcA(err, data) {
if (err) {
log("error on large");
log(err);
return;
}
log("large completed");
}
function funcB(err, data) {
if (err) {
log("error on small");
log(err);
return;
}
log("small completed");
if (doSpin) {
spin(3000);
log("spin completed");
}
}
function spin(howLong) {
let finishTime = Date.now() + howLong;
// spin until howLong ms passes
while (Date.now() < finishTime) {}
}
log("start");
fs.readFile(fname, funcA);
log("large initiated");
fs.readFile("./small.txt", funcB);
log("small initiated");
然后(使用节点 v12.13.0),我 运行 它有和没有 3 秒旋转。没有自旋,我得到这个输出:
0.000 : start
0.015 : large initiated
0.016 : small initiated
0.021 : small completed
0.240 : large completed
这显示完成小和大的时间之间有 0.219 秒的差异(而 运行同时完成两者)。
然后,插入 3 秒延迟,我们得到这个输出:
0.000 : start
0.003 : large initiated
0.004 : small initiated
0.009 : small completed
3.010 : spin completed
3.229 : large completed
我们在完成小和大的时间之间有完全相同的 0.219 秒增量(而 运行同时完成两者)。这表明大 fs.readFile()
在 3 秒旋转期间基本上没有取得任何进展。它的前进完全受阻。正如我们在之前的解释中所推测的那样,这显然是因为从一个分块读取到下一个分块读取的进展是写在 Javascript 中的,而自旋循环是 运行ning 时,该进展到下一个块已被封锁,无法取得任何进展。
多大的文件使大文件排在第二位?
如果您在节点 v12.13.0 的源代码中查看 fs.readFile()
的代码,您会发现它读取的块大小为 512 * 1024,即 512k。因此,从理论上讲,如果可以在一个块中读取较大的文件,则较大的文件可能会首先完成。这是否真的发生取决于一些 OS 和磁盘实现细节,但我想我会在我的笔记本电脑上尝试它 运行 使用 SSD 驱动器的 Windows 10 的当前版本.
我发现,对于一个 255k "large" 的文件,它确实在小文件之前完成(基本上是按执行顺序)。所以,因为大文件读取在小文件读取之前开始,即使它有更多的数据要读取,它仍然会在小文件之前完成。
0.000 : start
0.003 : large initiated
0.003 : small initiated
0.007 : large completed
0.008 : small completed
请记住,这是 OS 和磁盘相关的,所以这不是 gua运行teed。