Status: Accepted
Date: October 1, 2026
Answers: whether a file operation with nothing else in flight still goes
through the runtime’s I/O, which costs a program that never overlaps about
3,100-3,500 instructions a read
(#106)
Related:
ADR-055 (one async lowering, a pause is a suspension point) -
kept. ADR-303 D3 (files go to io_uring or a worker, sockets to
readiness) - narrowed by D1; D5 (four runtime settings and no fifth) - kept.
ADR-292 (only overlap and spawn run anything side by side) -
what makes D2’s condition exact. ADR-178 D2 (a staging decision
enters the compiler only with a measured crossover) - D4.
ADR-258 (the four weights)
When a program reads or writes a regular file and the runtime has nothing else in flight, does the operation still go through the runtime’s I/O - the ring or a worker - or does it run on the thread that asked?
The emitted program is async at both settings of user_parallelism
(ADR-055), and every file operation is handed to the runtime’s
I/O so that two of them in an overlap cost nothing (ADR-303
D3). Measured on a program that never overlaps - one file read a thousand
times, three .awaits deep - the .awaits cost 3 to 25 instructions a read
and the hand-off costs about 3,100-3,500, roughly 2.8 times what the whole
read costs in user space when it is a plain read(2). A program that never
overlaps pays that on every read for a capability it does not use.
| Language / tool | Its answer | What it costs there | Source |
|---|---|---|---|
| Go | a goroutine makes a file syscall on its own OS thread; if the call blocks, the runtime’s monitor hands the scheduler to another thread after about 20 µs. Sockets go through the netpoller | a thread hand-off only for a call that actually blocks | from memory, not checked |
| Java (virtual threads) | file I/O blocks the carrier thread, and the JDK compensates with another carrier while it lasts; sockets go through a poller | the same shape as Go’s | JEP 444; from memory, not checked |
| Node (libuv) | every file operation goes to a thread pool | the hand-off on every operation | from memory |
| Rust, Tokio | tokio::fs is spawn_blocking: a thread pool, every time |
the same; small reads are commonly done with std::fs instead |
from memory |
| Kotlin | Dispatchers.IO: a thread pool |
the same | from memory |
Zig (new Io) |
the program chooses one I/O implementation, blocking or evented, as a whole | nothing per call; the choice is the program’s | from memory, not checked |
io_uring |
a read whose data is in the page cache is completed while it is submitted | the bookkeeping, which is most of what the hand-off measured here costs | from memory |
The runtimes built around lightweight threads - Go, Java’s virtual threads - do the syscall on the calling thread and pay for a hand-off only when one is needed. The ones built around a pool pay it every time, and their users route around it. What does not transfer: Go and Java decide after the call blocks, with a monitor thread; a Nikaia program says where it overlaps (ADR-292), so the runtime can decide before, exactly, with a count.
overlap and no spawn is lowered blockingThe program a developer writes is the same under all four:
for path in paths {
total = total + fs::read_to_string(path, fs::Root::Anywhere).len()
}
| D1 the special case pays | D2 the compiler works | D3 no cost for the common case | D4 consistent with itself | If it is wrong | |
|---|---|---|---|---|---|
| A | the common case - no overlap - pays for the special one | - | 3,100-3,500 instructions a read | one road | nothing to undo |
| B | only an operation that overlaps pays the hand-off | nothing for the developer to write | the read is a read(2) on the caller’s thread |
the compiler still decides what a program means; the runtime already decides how (io-method = auto, ADR-303 D3) |
one branch in std; removing it is the same branch |
| C | as B, decided per program | as B | as B, and the futures go too, which measure at nothing | two lowerings, and std twice - every pausing function in both shapes, which ADR-055 refused; a library cannot know whether its caller overlaps |
a second backend to keep |
| D | as A, smaller | - | less, not none | - | nothing to undo |
B against C is the owner’s question in one line: Nikaia tries to be clever when
it compiles. It is, here too - the compiler decides what overlaps, and that is
what B’s condition reads. What B leaves to the runtime is the mechanism, which
the runtime already chooses (ADR-303 D3: the choice is made at
startup, inside std, and a compiler could not have made it at all). C buys
nothing B does not - the futures cost nothing - and costs a second lowering.
D is not an alternative to B but its first step: part of the cost is the normal path doing work it need not do (D3).
A read or a write of a regular file, made when the runtime has nothing else
in flight (D2), is performed on the thread that asked, as a blocking syscall,
and its future is ready when it is first polled. Anything else - a socket, a
pipe, standard input, a file that turns out not to be a regular one, an
operation made while something else is in flight - goes through the runtime as
before. It holds at both settings of user_parallelism and whatever
io-method says; it is a rule of std and adds no setting
(ADR-303 D5).
The operation is made by main itself - the future block_on drives, not a
task beside it and not a task on the pool - and:
user_parallelism = yes;overlap or a select is being polled. A block’s
branches are no tasks: they are polled one after another on the block’s own
thread (ADR-292 D4), so when its first branch starts a read
nothing is in flight yet. Made on the calling thread, that read would end
before the second branch began - the sum ADR-292 D6 refuses;The runtime already counts outstanding operations for its drain at exit
(ADR-297 D6, ADR-303 D10), and D1 asks that count
together with the rest; what the count did not cover - the ring’s own slots,
the blocks being polled, whether the future being polled is main - is
counted beside it rather than guessed at. Every part answers no when in
doubt, which costs a read the hand-off and never costs an overlap anything.
Nothing can make the answer false before the read is done. A task is
started by a spawn the program wrote or by block_on itself, and with
main alone the only code of the program running is the caller, which is
about to block; the workers, the poller and the clock only answer operations
already counted, and there are none. That holds at both settings of
user_parallelism.
Because overlapping is written in the source (ADR-292), a
program that never writes overlap, select or spawn is idle at every file
operation it makes, and one that does is idle exactly where nothing it started
is still running and no block is open around the call.
Measured function by function, the read’s buffer is zeroed
before the kernel fills it, and fs::read_to_string builds the text of the
error it might report on every call. Neither is the hand-off’s cost, and both
are removed before D1 is measured, so that D1 is credited only with what it
saves.
ADR-178 D2’s rule: with D3 in, measure.rs’s
what_an_await_costs_a_program_that_never_overlaps is run with and without
D1, and D1 enters std only if it saves at that point.
It did: over 1,000 reads of a small file, what a read costs for being async
fell from about 2,600 to about 770 instructions at user_parallelism = no
and from about 2,550 to about 930 at yes, on the completion path
(runtime-cost §7.4).
A lost because the common case pays for the rare one, which is the first
weight. C lost because it needs a second lowering and a second std to save
what B saves with a branch. D is kept, as D3.
cat data | prog): the bytes are there at once,
as a file’s are, and a program reading lines pays the hand-off per chunk.
That is measured before anything is decided
(#423). The cheap
half is done: a chunk is a pipe’s capacity, 64 KiB, and no longer
BufReader’s 8 KiB, which took 4 M piped lines from 791 to 414 ms (still one
read a hop, so a slow pipe hands over what is there). Zeroing the buffer
measured as nothing. Whether a calling-thread path for a full pipe saves
what is left is still open.io_uring’s own submission path is made cheaper beyond D3.