one call · four syscalls · 41 operations
Reading a file is not
an operation. It is a queue.
The fs module has two shapes worth knowing, and they answer
different questions. One is the arc: what actually happens between
fs.readFile(path, cb) and your callback, which turns out to be four separate
syscalls, a thread that is borrowed and returned four times, and a buffer allocated before a
single byte has been read. The other is the surface: 41 operations, three flavours, and a set
of edges where the intuitive thing and the correct thing come apart.
13
Steps in the arc
41
Operations
13
Close-ups
12
Sharp edges
v22.22.2
Measured on
The call returns immediately
schematicThe four sub-steps are read from Node’s own read path rather than traced — this container has no
strace. What is measured is the call count: 56 of the 98 functions on the fs namespace behave exactly this way.A string is not a file
schematicThe error codes and their
errno values are measured on this container by provoking each one. The four-component walk is the kernel’s documented behaviour, not an instrumented trace.Into C++, still on your thread
schematicThe per-call costs are medians over 2,000 iterations on a 4 KB file with the page cache warm. They compare the API paths, not the storage.
The work queue, and four threads
schematicOccupancy is measured, not quoted: each read targets a named pipe, and opening the writing end with
O_NONBLOCK fails with ENXIO unless a reader is already blocked in open(2). Every index was probed in its own process, because probing releases the thread it measures.open(2)
recorded runEvery flag row is measured by opening a file containing
ORIGINAL and reading back what survived. The O_* values are this platform’s, read from fs.constants.What the number actually is
schematicThe append and overwrite results are measured: the same three 5-byte writes through two descriptors, once with
'a' and once with 'w'.fstat, and a second trip through the queue
recorded runMeasured with
UV_THREADPOOL_SIZE=1 and two reads: the second could not start, then started as soon as the first one’s open finished — while the first readFile was still pending.The buffer is allocated before the read
recorded runByte offsets and ArrayBuffer lengths are read back from real buffers. The chunk counts come from a stream run over a 3 MB file.
read(2), in a loop
recorded runAll timings are medians of 20 runs with the page cache warm, so they compare API paths rather than storage. The chunk cap is a constant in Node’s read path; the syscall sequence is read from that source, not traced.
close(2), and what happens when it is missed
recorded runDescriptor counts are read from
/proc/self/fd; the ceiling is reached by opening in a loop until it fails.The completion goes back to the loop
schematicThe handover is Node’s architecture rather than a measurement. What is measured is the consequence: in every pool run, submission order and completion order disagreed.
Your callback runs
schematicFrame counts are measured by provoking an
ENOENT three ways in one process and counting the lines below the message.Nothing in Node survives
schematicThe
fsync comparison is measured on a 2 MB write. Page-cache occupancy is not directly observable from a Node process — the bar is the argument being made, not a reading.