Threadsasync I/Oawaitcontinuationepollkqueue

Async I/O: What `await readFile()` Actually Does

An await on an I/O call hands the request to the runtime, which hands it to the OS or a helper thread, frees the calling thread to run other work, and resumes the function as a continuation when the kernel reports completion — with mechanisms that differ between files and sockets and between Node, Python and C++.

Node.jsCPythonC++LinuxWindowsRuntime-specific
▶ InteractiveInterview question
Progress

The problem

The line const data = await readFile("big.json") does not return for 8 ms, yet the thread that executed it serves other requests during those 8 ms. A function cannot be paused halfway by the language alone; the disk cannot call JavaScript. What is holding the request, who is waiting, and how does execution come back to the next line?

Follow the await

Node.js

Walk it step by step on Node. Your async function calls readFile, which builds a request object (path, buffer, callback) and passes it to libuv, then returns a pending promise. await suspends the function: its remaining code and its local variables are captured as a continuation attached to that promise, and the function returns to the event loop. The loop is free and runs other tasks. Meanwhile a libuv pool thread calls open, read and close on the file, blocking *that* thread for the 8 ms the SSD needs. When it finishes, it signals the loop thread; the loop resolves the promise, which enqueues the continuation as a microtask; the loop runs it, and execution proceeds from the line after await with data bound.

The general shape holds for every runtime: application → runtime → OS I/O request → the calling thread does other work → I/O completes → the runtime is notified → the continuation is scheduled → it resumes. What differs is the middle: whether the OS itself can be asked to complete the operation asynchronously, or whether the runtime has to fake it with a thread that blocks.

`const data = await readFile(...)` in Node.js
  1. Application`await readFile()` — the function is split here; the remainder becomes a continuation
  2. Runtime (libuv)a request object is queued; the promise is pending; the loop thread continues with other tasks
  3. OS I/O requesta pool thread calls `open`/`read`; the kernel checks the page cache, else issues the block read
  4. Thread does other workthe loop thread handles other requests; only the pool thread is blocked
  5. I/O completesthe SSD interrupts; the kernel copies pages and wakes the pool thread
  6. Runtime notifiedthe pool thread posts the result to the loop via eventfd/`uv_async_send`
  7. Continuation resumesthe promise resolves → microtask → the code after `await` runs with `data`

Files and sockets take different roads

Node.js

For sockets, Node does not use the pool. The kernel offers readiness notification: epoll on Linux, kqueue on macOS and the BSDs, IOCP on Windows. libuv registers every socket with that facility, and the loop thread’s single epoll_wait returns the list of descriptors that have data (or space, or a pending connection). The read itself is then a non-blocking read that completes immediately from the socket buffer. One thread, no pool, tens of thousands of sockets — see I/O Multiplexing: select, poll, epoll, kqueue, IOCP.

For regular files, readiness does not exist on Linux: a file is always "readable", and the read blocks until the bytes arrive from disk. So libuv runs file operations on its 4-thread pool (UV_THREADPOOL_SIZE), and those threads simply block. The consequence is a hidden concurrency limit: the fifth concurrent readFile waits for a pool thread, not for the disk. dns.lookup (getaddrinfo), zlib and some crypto calls share the same pool. The two roads are why "Node is async" is true of the API and only partly true of the mechanism.

  • Sockets: readiness via epoll/kqueue/IOCP on the loop thread; zero pool threads.
  • Files, getaddrinfo, zlib, pbkdf2: blocking calls on the libuv pool; default 4 threads.
  • Symptom of a saturated pool: file and DNS latency climbs while sockets stay fast.

Other runtimes, other kernels

Runtime-specific

Python asyncio multiplexes sockets with selectors (epoll/kqueue) on its loop thread, and — like Node — has no async file API in the standard library; aiofiles and loop.run_in_executor push file reads to a thread pool. On Windows the default ProactorEventLoop uses IOCP, which is a genuine completion model: the kernel finishes the operation and posts the result, for files as well as sockets.

C++ picks the mechanism explicitly. Asio uses epoll/kqueue readiness on Unix-like systems and IOCP on Windows, with its own thread pool for what those cannot cover. On Linux 5.1+ `io_uring` gives a true completion interface for both files and sockets: the application places submission entries in a shared ring, the kernel performs the operations — including real disk reads without any user thread blocking — and places completions in a second ring, often without a system call per operation. IOCP has been the Windows equivalent for decades. These are what "the OS does the I/O asynchronously" means literally; epoll is only "the OS tells you when a non-blocking call would succeed".

Who waits, per runtime and I/O type
SocketsRegular filesModel
Node.js (libuv)epoll / kqueue / IOCP on the loop threadBlocking read on a pool threadReadiness (+ pool)
Python asyncio (Unix)selectors: epoll / kqueueThread pool via executor or aiofilesReadiness (+ pool)
Python asyncio (Windows)IOCPIOCPCompletion
C++ Asioepoll / kqueue / IOCPPool on Unix; IOCP on WindowsReadiness or completion
C++ io_uring (Linux ≥ 5.1)Submission/completion ringsSubmission/completion ringsCompletion
Go runtimenetpoller (epoll/kqueue) hidden behind blocking-looking callsBlocking syscall; runtime parks the goroutine and hands the thread offReadiness (+ implicit pool)

What a continuation is

Conceptual

"Resumes the function" hides real machinery. The compiler or interpreter turns an async function into a state machine: each await is a state, the locals live in a heap-allocated frame instead of on the stack, and "resume" means calling the state machine with the result and the saved state number. That frame is the per-task cost from Threads versus Async versus Processes — hundreds of bytes to a few kB — and it is why an async task is cheap to keep suspended: there is no stack to keep, only the frame. The call stack at resumption is *new*: the continuation runs from the event loop, not from the original caller, which is why stack traces across await are short and why runtimes add async stack traces artificially.

It also explains a subtle rule: code after an await runs on whichever thread the loop schedules it on. In JavaScript that is always the same thread. In C# and in some C++ frameworks it may be a different thread of a pool, and thread-local state or thread-affine resources (a UI, a database connection bound to a thread) break across the await.

Costs and where it breaks

Asynchronous I/O does not make an individual operation faster: the SSD still takes 100 µs and the round-trip still takes its RTT. It makes the *thread* available during the wait, which raises the number of operations in flight per thread from one to thousands. That is a throughput and memory win, not a latency win, and it has overheads of its own — a promise, a frame, a queue insertion and a microtask per operation — that dominate for tiny operations on fast paths, which is why high-performance servers batch and why io_uring was designed to amortise syscalls.

The failure modes are the mirror of the mechanism: a pool too small for file work, a loop blocked by a handler that forgot to await, a continuation that resumes after the object it needed was closed, and unbounded in-flight operations that exhaust descriptors or memory because nothing applied backpressure — see What Happens When the Receiver Is Slow.

Key points

  • await on I/O splits the function into a continuation, hands the request to the runtime and returns the thread to the loop; the continuation runs when the runtime is told the I/O completed.
  • Sockets use kernel readiness (epoll/kqueue/IOCP) on the loop thread; regular files on Linux have no readiness, so Node and asyncio block a pool thread instead.
  • Node’s libuv pool has 4 threads by default and also serves getaddrinfo, zlib and some crypto; it is a hidden concurrency limit.
  • True completion-based async I/O exists — IOCP on Windows, io_uring on Linux — and C++ can use it directly; asyncio uses IOCP on Windows.
  • A continuation is a heap-allocated state-machine frame, which is why suspended tasks are cheap and why stack traces break at await.
  • Async I/O raises operations in flight per thread, not the speed of one operation.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why can one thread have thousands of I/O operations in flight?

Because the waiting is done by the kernel (readiness or completion queues) or by a few pool threads, and each suspended task costs only a small frame, not a stack.

Why does Node need a thread pool for files but not sockets?

Linux reports readiness for sockets but treats regular files as always ready; a file read blocks until the disk answers, so something has to block, and libuv makes a pool thread do it.

Why does io_uring exist if epoll already works?

epoll cannot cover files, needs one syscall per ready descriptor to actually read, and reports readiness rather than results; io_uring lets the kernel do the whole operation and batches submissions and completions through shared memory.

What await actually does

What await actually does
`const data = await readFile(...)` — the thread is free the whole time the disk is working.
Event-loop thread — what it is doing at each step
1req #1enters handler
2req #1continuation parked
3req #2parse JSON (CPU)
4req #2respond · req #3
5req #3awaits a DB query
6req #3parked
7req #1resumes, responds
Application. The handler calls readFile and awaits. From the code's point of view it "waits here" — but the function actually returns to the runtime, leaving a continuation.
Node.jsNode: sockets use epoll/kqueue/IOCP (readiness, no extra threads); regular-file reads go through the libuv thread pool (4 threads by default), because Linux cannot make ordinary file reads non-blocking through epoll.
CPythonPython asyncio: sockets via the selectors module (epoll); file I/O has no native async path and is delegated to a thread pool (loop.run_in_executor / aiofiles) — the same shape as Node.
Linuxio_uring changes the model: the application submits read requests to a ring and the kernel completes them asynchronously — true async file I/O with no pool thread. Node (via libuv 1.45+) and Rust/C++ runtimes are adopting it.
// This is not "blocking the thread". It is:
const data = await readFile('cfg.json')   // 1. hand the op to the runtime
                                          // 2. return to the event loop
use(data)                                 // 3. run later, as a microtask, when done
1/7 · ApplicationNode.js

How it fails

What the failure looks like from inside real software.

  • A Node service reading many files sees latency step up at exactly 4 concurrent reads; the SSD is idle — the libuv pool is saturated.
  • dns.lookup latency spikes during heavy file I/O: both share the same 4-thread pool.
  • A continuation resumes after the request was aborted and writes to a closed socket: EPIPE / ERR_STREAM_WRITE_AFTER_END.
  • An await inside a loop over 100,000 URLs starts all of them at once because the loop did not await in sequence or bound concurrency; the process hits EMFILE or the remote rate limit.
  • A C# or C++ continuation resumes on a different pool thread and touches thread-local state that belongs to the original thread.
  • A Python coroutine awaits a synchronous file read wrapped in a fake coroutine; nothing is off-loop and the loop stalls for the disk latency.