Skip to content

Commit 542e2b2

Browse files
authored
fs: read small files in one thread pool round trip
fs.readFile(path) took four libuv thread pool round trips for a typical small file -- open, fstat, read and close, each its own uv_fs request with its own queue wait, completion callback and JS/C++ crossing -- and fs.promises.readFile(path) did the same through a FileHandle. For the small files applications read most, the round trips are the cost, and each occupies a slot in the pool that concurrent dns.lookup(), fs and crypto work is also queueing for. Add ReadFileJob (an AsyncWrap + ThreadPoolWork) that performs open + fstat + read-to-EOF + close as one pool task and reports the whole content, or, when the file turns out to be larger than one chunk (kReadFileBufferLength, 512 KiB), stops after fstat() and hands the fd and size back so that the existing chunked reader continues unchanged (large reads stay interleaved and abortable between chunks, and still save the fstat round trip). fs.readFile() and fs.promises.readFile() use it for path arguments without a user buffer; file descriptors, FileHandles, options.buffer and an active VFS keep their paths. Behavior is otherwise kept: same bytes for every size and encoding; open failures report syscall 'open' with the path, read failures 'read'; permission errors are delivered through the callback/promise as before; an abort that arrives while the read is in flight still wins; the job is an FSREQCALLBACK resource for async_hooks; a handed back fd is tracked exactly like one from a plain open(). Tests that asserted the internal open/fstat/read/close request chain, used readFile() as a proxy for an fstat trace event, or injected faults through FileHandle.prototype for path-based reads are adjusted to keep testing what they test (a file just over one chunk where the chain shape matters, fs.fstat() for the fstat trace, a larger file so the FileHandle path is taken). fs.readFile() of 4 KiB files at concurrency 64 goes from ~51k to ~306k files per second, and a mixed stat/readFile/dns.lookup burst from ~66k to ~312k operations per second. Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com> PR-URL: #65327 Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com> Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent d6bbf57 commit 542e2b2

12 files changed

Lines changed: 613 additions & 30 deletions

lib/fs.js

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const {
8181

8282
const {
8383
FSReqCallback,
84+
ReadFileJob,
8485
} = binding;
8586
const { toPathIfFileURL } = require('internal/url');
8687
const {
@@ -98,6 +99,7 @@ const {
9899
const {
99100
constants: {
100101
kIoMaxLength,
102+
kReadFileBufferLength,
101103
kMaxUserId,
102104
},
103105
copyObject,
@@ -428,10 +430,70 @@ function readFile(path, options, callback) {
428430
return;
429431

430432
const flagsNumber = stringToFlags(options.flag, 'options.flag');
433+
path = getValidatedPath(path);
434+
if (options.buffer === undefined) {
435+
// Open + fstat + read + close in one thread pool round trip for files of
436+
// up to one chunk; larger files come back as an open fd + size and take
437+
// the chunked reader below (readFileAfterOneShot). `true`: a handed-back
438+
// fd will be closed through fs.close(), so track it as unmanaged.
439+
const job = new ReadFileJob(path, flagsNumber, kReadFileBufferLength, true);
440+
job.context = context;
441+
job.ondone = readFileAfterOneShot;
442+
const accessError = job.run(path);
443+
if (accessError !== undefined) {
444+
// Not scheduled: report it the way the request-based open() did.
445+
callback(accessError);
446+
}
447+
return;
448+
}
431449
const req = new FSReqCallback();
432450
req.context = context;
433451
req.oncomplete = readFileAfterOpen;
434-
binding.open(getValidatedPath(path), flagsNumber, 0o666, req);
452+
binding.open(path, flagsNumber, 0o666, req);
453+
}
454+
455+
function readFileAfterOneShot(err, buffer, fd, size, closeErr) {
456+
const context = this.context;
457+
if (err) {
458+
context.callback(err);
459+
return;
460+
}
461+
if (fd !== -1) {
462+
// (context.read() below performs the abort check for this case.)
463+
// Larger than one chunk: continue exactly like after open + fstat.
464+
context.fd = fd;
465+
context.size = size;
466+
if (size > kIoMaxLength) {
467+
return context.close(new ERR_FS_FILE_TOO_LARGE(size));
468+
}
469+
try {
470+
context.prepare();
471+
} catch (err) {
472+
return context.close(err);
473+
}
474+
context.read();
475+
return;
476+
}
477+
if (closeErr) {
478+
context.callback(closeErr);
479+
return;
480+
}
481+
if (context.signal?.aborted) {
482+
// An abort that arrived while the read was in flight wins, as it did when
483+
// it was noticed between the open/fstat/read steps.
484+
context.callback(new AbortError(undefined, { cause: context.signal.reason }));
485+
return;
486+
}
487+
let result = buffer;
488+
if (context.encoding) {
489+
try {
490+
result = buffer.toString(context.encoding);
491+
} catch (err) {
492+
context.callback(err);
493+
return;
494+
}
495+
}
496+
context.callback(null, result);
435497
}
436498

437499
function tryStatSync(fd, isUserFd) {

lib/internal/fs/promises.js

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,26 +1211,32 @@ async function readFileHandleWithUserBuffer(filehandle, options, size) {
12111211
return encoding ? buffer.toString(encoding) : buffer.subarray(0, totalRead);
12121212
}
12131213

1214-
async function readFileHandle(filehandle, options) {
1214+
async function readFileHandle(filehandle, options, knownRegularFileSize) {
12151215
const signal = options?.signal;
12161216
const encoding = options?.encoding;
12171217
const decoder = encoding && new StringDecoder(encoding);
12181218

12191219
checkAborted(signal);
12201220

1221-
const statFields = await PromisePrototypeThen(
1222-
binding.fstat(filehandle.fd, false, kUsePromises),
1223-
undefined,
1224-
handleErrorFromBinding,
1225-
);
1226-
1227-
checkAborted(signal);
1228-
12291221
let size = 0;
12301222
let length = 0;
1231-
if ((statFields[1/* mode */] & S_IFMT) === S_IFREG) {
1232-
size = statFields[8/* size */];
1223+
if (knownRegularFileSize !== undefined) {
1224+
// Handed over by readFile() together with an already open fd.
1225+
size = knownRegularFileSize;
12331226
length = encoding ? MathMin(size, kReadFileBufferLength) : size;
1227+
} else {
1228+
const statFields = await PromisePrototypeThen(
1229+
binding.fstat(filehandle.fd, false, kUsePromises),
1230+
undefined,
1231+
handleErrorFromBinding,
1232+
);
1233+
1234+
checkAborted(signal);
1235+
1236+
if ((statFields[1/* mode */] & S_IFMT) === S_IFREG) {
1237+
size = statFields[8/* size */];
1238+
length = encoding ? MathMin(size, kReadFileBufferLength) : size;
1239+
}
12341240
}
12351241
if (length === 0) {
12361242
length = kReadFileUnknownBufferLength;
@@ -2146,10 +2152,49 @@ async function readFile(path, options) {
21462152

21472153
checkAborted(options.signal);
21482154

2155+
if (options.buffer === undefined && vfsState.handlers === null) {
2156+
// Open + fstat + read + close in one thread pool round trip for files of
2157+
// up to one chunk; larger files come back as an open fd + size and are
2158+
// read by readFileHandle() as before.
2159+
path = getValidatedPath(path);
2160+
const { 0: buffer, 1: fd, 2: size } = await readFileInOneRoundTrip(path, stringToFlags(flag));
2161+
if (fd === -1) {
2162+
checkAborted(options.signal); // An abort during the read still wins.
2163+
return options.encoding ? buffer.toString(options.encoding) : buffer;
2164+
}
2165+
const filehandle = new FileHandle(new binding.FileHandle(fd));
2166+
return handleFdClose(readFileHandle(filehandle, options, size), filehandle.close);
2167+
}
2168+
21492169
const fd = await open(path, flag, 0o666);
21502170
return handleFdClose(readFileHandle(fd, options), fd.close);
21512171
}
21522172

2173+
/**
2174+
* @param {string|Buffer} path Validated path
2175+
* @param {number} flagsNumber
2176+
* @returns {Promise<[Buffer|undefined, number, number|undefined]>} [buffer, -1] or [undefined, fd, size]
2177+
*/
2178+
function readFileInOneRoundTrip(path, flagsNumber) {
2179+
return new Promise((resolve, reject) => {
2180+
const job = new binding.ReadFileJob(path, flagsNumber, kReadFileBufferLength);
2181+
job.ondone = (err, buffer, fd, size, closeErr) => {
2182+
const error = err ?? closeErr;
2183+
if (error != null) {
2184+
ErrorCaptureStackTrace(error, readFileInOneRoundTrip);
2185+
reject(error);
2186+
} else {
2187+
resolve([buffer, fd, size]);
2188+
}
2189+
};
2190+
const accessError = job.run(path);
2191+
if (accessError !== undefined) {
2192+
ErrorCaptureStackTrace(accessError, readFileInOneRoundTrip);
2193+
reject(accessError);
2194+
}
2195+
});
2196+
}
2197+
21532198
async function* _watch(filename, options = kEmptyObject) {
21542199
const h = vfsState.handlers;
21552200
if (h !== null) {

0 commit comments

Comments
 (0)