ADR-263: A File Operation on an Idle Runtime Runs on the Calling Thread

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)

1. Question

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.

2. How others do it

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.

3. Options, weighed

Option A - unchanged: every file operation goes through the runtime

Option B - the runtime decides: on an idle runtime, the calling thread

Option C - the compiler decides: a program with no overlap and no spawn is lowered blocking

Option D - make the hand-off cheaper, and decide nothing

The 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()
}

Weighed

  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).

4. Decision

D1 - A file operation on an idle runtime runs on the calling thread.

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).

D2 - “Nothing else in flight” is exact, and the runtime counts it.

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:

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.

D3 - The normal path stops doing work it does not need.

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.

D4 - D1 is built only if it measures.

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.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.