ADR-055: The Emitted Rust Is async Where a Function Can Pause

Status: Accepted Related: ADR-288 D1 and D2 (the sync property, which is this decision’s input — already inferred, per function, in the safe direction), ADR-023 D8 (the same shape one property over: throws becomes a ?, and this makes can pause become an .await), ADR-303 D3 and D4 (the I/O and the runtime this drives, and why the executor is ours), ADR-037 D2 (user_parallelism, which decides how many threads the executor has and nothing about what a program means), ADR-005 §1 (written over implicit async from the start), ADR-297 (Cleanup, whose whole reason is that any function may pause), ADR-292 D6 (which rests on this), ADR-004 D2 (the rule this puts under the most pressure), Part I 1.2 (what a switch may change), Part II 11.2 (the sentence this makes true)

1. Context

The language is specified as implicitly async, so the lowering has to be. ADR-005 §1 reasons about “an I/O suspension point, which implicit async inherits free from async fn’s”. ADR-297 exists because “any function may pause at I/O”. ADR-288 infers, for every function, whether it can pause. A lowering with no async in it makes a pause a thread that blocks.

For most of what a program does, the difference is invisible: a blocking read and an awaited read return the same bytes. It becomes visible at exactly one place, and that place is spawn.

spawn is where a blocking lowering stops being harmless. Part II 11.2 says a task at user_parallelism = no is “interleaved on the same thread”, and Part I 1.2 says a build switch changes how a program runs and never what it computes. Two synchronous Rust closures cannot interleave on one thread — there is no point at which either yields — so with a synchronous lowering spawn at no has only these readings:

Each of the three costs something the specification already promised. So the lowering is what has to move.

2. Decisions

D1 — A function is a plain fn exactly where sync says so, and an async fn otherwise.

The input already exists and needs nothing new inferred. ADR-288 D1 computes, per function, over the call graph, resolving into a library’s ledger: “a function is sync when every call it makes resolves, and every callee is sync“. That property is “cannot pause”, which is exactly the question async fn asks.

And its conservatism is already the safe direction, which is why this is a lowering change and not an analysis change. ADR-288 D2 takes the claim away from a function whose call the compiler cannot resolve — so an unresolved call makes a function not sync, which here makes it async. An async fn that never awaits is a future that finishes on its first poll; a plain fn that needed to pause is a program that does not compile. The error the inference can make is the cheap one.

A sync a source asserts stays asserted (ADR-288 D4) and stays a plain fn. An assertion that is wrong is already refused.

D2 — A call to a function that can pause gets an .await, and that is not resolution.

let text = fs::read_to_string(path)      // `read_to_string` can pause
let text = nikaia_std::fs::read_to_string(path).await;

The precedent is one property over. ADR-023 D8 makes a call to a throws function carry a ? — punctuation the emitter writes where the ledger says the callee can fail, and nothing the source spells. This is the same mechanism reading the neighbouring column: where the ledger says the callee can pause, the call carries an .await.

So ADR-296 D17’s “name for name, and the emitter resolves nothing” survives intact. .await is not a name and nothing was looked up to place it: the ledger was consulted about the callee, which is what the ledger is for, and it is the same consultation the ? already makes.

D3 — The executor is ours, and it drives the I/O that is already there.

ADR-303 D3 provides the I/O: completion for files, readiness for everything else, both behind one std surface. D4 starts it before the program’s first statement.

So a second runtime is not bound. A tokio or a smol beside rt would be two event loops in one process, which is what D7 is about for a foreign crate and is no better when we do it to ourselves. What is needed is an executor — a task queue and a waker — over the completion and readiness sources that exist. That is the smaller half of an async runtime.

rt::start is the thing that drives main to completion rather than the thing that starts threads beside it.

D4 — user_parallelism decides how many threads the executor has, and nothing else.

setting executor
no one thread — the program’s own. Tasks interleave on it at their suspension points.
yes the pool ADR-303 D4 already starts.

This is what makes Part II 11.2’s sentence true, and the reason for this answer over the ones in §1: at no a task really is interleaved on the same thread, because a suspension point is a place where the executor may run something else rather than a thread that blocks.

And it keeps Part I 1.2 exactly: the set of schedules narrows from “any” to “one at a time”, and a program’s meaning does not change. no remains ADR-037 D2’s “nothing the user wrote ever runs concurrently” — interleaved is not concurrent.

D5 — spawn hands back a TaskHandle, and .join() is an .await.

let handle = spawn fn { process(path) }
let result = handle.join()

The body becomes a future the executor owns, and the captures move into it, which is Part I 8.3’s @detached rule and Rust’s move closure meeting at the same place. .join() awaits the handle. Both are the same at either setting, which is Part II 11.2’s “uniform API”.

A task nobody joins still runs: the executor owns it, so nothing is dropped by a program that spawns and forgets — which is Part I 8.2’s own example.

D6 — What this costs is written down rather than discovered.

Every signature that can pause changes shape. A reader comparing the two languages sees async fn where the Nikaia said nothing, which puts ADR-004 D2’s “the text a person reads is the text that is compiled” under more pressure than any other lowering does. It is still honest — the function can pause, and Nikaia’s answer to “which ones” is sync, which is written where it is true rather than where it is not. But the generated Rust stops looking like a transcription in this one respect, and that is a real cost.

Three of async Rust’s sharp edges arrive with it, and none is hypothetical:

sync is load-bearing. It decides a function’s shape in the emitted program. That is what ADR-288 was for, and it means a regression in the inference is a regression in the output rather than in a note.

3. What rests on this

spawn, and with it Part II 11.2, ADR-312 D1’s task half, ADR-312 D7, Part II 12.2’s counter, and ADR-292 D2’s overlap.

ADR-292 D6 in particular. It says “a branch is started up to its first suspension point before any branch that cannot suspend is run” — a sentence about suspension points, which D1 and D2 put in the emitted program, so D6’s schedule is something an executor can be asked for.

4. What this does not decide

5. Rejected

6. How it is lowered

Five steps, each testable on its own.

  1. The executor in rt, over the completion and readiness sources ADR-303 D3 provides. At no, rt::exec: a task queue, a Waker over an atomic flag, block_on, a Slot a task’s value leaves through. At yes, rt::pool: a queue of futures over the worker count — a pool of closures cannot hold a future to be polled again, which is what a task that pauses is.

    At yes a task may be polled on a thread that did not start it, so its future must be Send (D6), a task is an Arc cell a waker pushes back, and exactly one thread at a time waits in the I/O — the pilot — because the ring is behind a lock and a thread inside io_uring_enter is woken by the kernel and by nothing else; the others wait on the pool’s bell. main waits on the runtime’s bell rather than in the I/O while the pool carries tasks. A waker arriving during a poll is recorded, and the worker that finishes the poll re-queues.

    block_on panics rather than parks where a task is waiting and nothing can wake it: that state means a future returned Pending without arranging a wake — a defect in std, and a hang is the worst way to report one.

  2. D1 and D2 in the emitter. The .await goes before the ?; main is driven by rt::exec::block_on where it can pause and called directly where it cannot, so a program with no I/O pays for no executor. A method call’s answer comes from the checker, as its ? does (ADR-288). A call that closes a cycle of pausing functions puts its future behind a pointer — the boxing is a property of the call, not of the callee, and a method call draws an edge to every pausing name it could be.
  3. std’s own pausing entries are async. The executor is the one place a program parks: a file operation is a slot on the ring, or a worker’s reply, behind one rt::io::InFlight, and polling it never blocks. No I/O future stores a waker — the executor re-arms everything it holds when the I/O moves — and the park reads a generation before it polls, so a completion between the poll and the park is not lost. A ring slot is owned by its handle until its answer is taken, and the handle’s Drop gives it back, because a future is started at one poll and answered at a later one.
  4. spawn and TaskHandle (D5). spawn fn { … } is TaskHandle::start(async move { … }) — an async block, because a task’s body may pause and Rust has no stable async closure — and .join() is an .await read off the ledger. main’s value is held until the queue empties, so a task nobody joins runs. NK2101 refuses a use after the move into a task only where the type is known and a move takes the value away.
  5. ADR-292 D2’s overlap, through std’s overlap2 .. overlap8, which take futures; a branch is an async block that borrows, since an overlap is not a task, and the branches are started in D6’s order, read off the sync column D1 reads.
This site is open source. Improve this page.