async Where a Function Can PauseStatus: 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)
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:
spawn, which is a legal schedule of yes but
deadlocks the program where yes would not, and makes Part II 11.2’s sentence
false;.join(), which silently drops a task nobody joins — and
Part I 8.2’s own example keeps no handle;spawn at no, which makes a library that uses one un-compilable
where it is consumed, the thing Part II 11.2 exists to prevent.Each of the three costs something the specification already promised. So the lowering is what has to move.
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.
.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.
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.
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.
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.
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:
async fn that calls itself needs its future boxed, or
rustc refuses with a recursive type. A recursive Nikaia function that can
pause is an ordinary program, so this has to be handled rather than met..await is a case the view analysis has to answer.Send on a spawned future. At yes the executor moves a task between
threads, so the future must be Send — which is the structural check
ADR-005 §1 Group B already runs on what a task captures, now
also asked of everything the body holds across a pause.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.
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.
std’s surface keeps its blocking shape anywhere. A function that
cannot pause stays a plain fn by D1, and some of std genuinely cannot —
text::digit_value does arithmetic. Which entries are which is a reading of
std.contracts, not a decision.async fn is harder
to call than a fn.
ADR-303 D7’s foreign-runtime rules are about the other direction.Box::pin
goes, and whether the emitter writes it always or only where recursion is
detected. D6 names the problem; the answer is an implementation reading.spawn, at no. The cheapest answer, and §1 says what
it costs: Part II 11.2’s sentence becomes false, and a program deadlocks at one
setting that runs at the other.tokio. It brings an executor and an I/O layer, and
ADR-303 provides the second one. Two event loops in one
process is the state D7 treats as a hazard when a foreign crate causes it.async. Simpler than reading sync — and it throws
away the one thing sync is for, makes text::digit_value a future, and
removes the reason to write the word.Five steps, each testable on its own.
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.
.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.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.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.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.