Skip to content

Commit fd418ee

Browse files
committed
CABI: improve and add cooperative thread built-ins
1 parent a0a9398 commit fd418ee

6 files changed

Lines changed: 724 additions & 300 deletions

File tree

design/mvp/CanonicalABI.md

Lines changed: 189 additions & 156 deletions
Large diffs are not rendered by default.

design/mvp/Concurrency.md

Lines changed: 102 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -388,32 +388,44 @@ learn its own index by calling the [`thread.index`] built-in.
388388

389389
A suspended thread (identified by thread-table index) can be resumed at some
390390
nondeterministic point in future via the [`thread.resume-later`] built-in. In
391-
contrast, the [`thread.yield-to`] built-in switches execution to the given
392-
thread immediately, leaving the *calling* thread to be resumed at some
393-
nondeterministic point in the future. Lastly, the [`thread.switch-to`]
394-
built-in switches execution to the given thread immediately, like `yield-to`,
395-
but leaves the calling thread in the "suspended" state. These three functions
396-
can be used to resume both newly-created threads as well as threads that
397-
executed and then suspended.
398-
399-
In addition to threads entering the suspended state via `thread.new-indirect`
400-
and `thread.switch-to`, threads can also explicitly suspend themselves via the
401-
[`thread.suspend`] built-in. Thus, there are three ways a thread *enters* the
402-
suspended state and three ways a thread *exits* the suspended state (with
403-
`thread.switch-to` serving in both categories). Together, these 5 thread
404-
built-ins support both the "green thread" [use cases](#goals) where Core
405-
WebAssembly code running inside the component wants to fully control thread
406-
scheduling (via `thread.switch-to` and `thread.suspend`) as well as the "host
407-
thread" use cases where the Core WebAssembly code wants to let the containing
408-
runtime nondeterministically schedule threads (via `thread.resume-later` or
409-
`thread.yield-to`).
410-
411-
Lastly, since threads are cooperative, there is a [`thread.yield`] built-in
412-
that can be called in the middle of long-running computations to allow the
413-
runtime to nondeterministically switch execution to another thread.
414-
`thread.yield` is equivalent to (but obviously more efficient than) creating a
415-
new thread with a no-op function (via `thread.new-indirect`) and then yielding
416-
to it (via `thread.yield-to`).
391+
contrast, the [`thread.yield-then-resume`] built-in switches execution to the
392+
given thread immediately, leaving the *calling* thread to be resumed at some
393+
nondeterministic point in the future. Lastly, the [`thread.suspend-then-resume`]
394+
built-in switches execution to the given thread immediately, like
395+
`thread.yield-then-resume`, but leaves the calling thread in the "suspended"
396+
state. These three functions can be used to resume both newly-created threads as
397+
well as threads that executed and then suspended.
398+
399+
Threads can also explicitly put themselves in the "suspended" state without
400+
specifying the other thread to run by calling the [`thread.suspend`] built-in.
401+
This is useful if a thread needs to wait on some condition that will be met by
402+
some unknown thread in the future (which will resume the suspended thread).
403+
Similarly, threads can explicitly put themselves in the "ready to run" state
404+
without specifying the other thread to run by calling [`thread.yield`]. This is
405+
useful if a thread has a long-running computation without I/O but still needs to
406+
allow other cooperative threads to make progress concurrently.
407+
408+
Lastly, in addition to being able to switch to "suspended" threads, threads can
409+
also switch to threads that are in a "ready to run" state by calling the
410+
[`thread.suspend-then-promote`] and [`thread.yield-then-promote`] built-ins
411+
which, like the `thread.{suspend,yield}-then-resume` built-ins, leave the
412+
calling thread in a "suspended" or "ready to run" state, resp. The calling
413+
thread *may* know that the target thread is ready to run (e.g., because the
414+
target thread is known to have yielded or to be waiting on a future/stream
415+
operation that the calling thread just completed). However, in general,
416+
readiness may depend on nondeterministic external I/O and the calling thread may
417+
just want to yield its timeslice to the target thread *if* it's ready as a
418+
scheduling optimization. Thus, if the target thread is *not* "ready to run",
419+
these built-ins are defined to gracefully fall back to the behavior of the
420+
`thread.{suspend,yield}` built-ins.
421+
422+
Together, these thread built-ins support both the "green thread" [use
423+
cases](#goals), where Core WebAssembly code running inside the component wants
424+
to fully control thread scheduling (via suspending and resuming built-ins),
425+
and the "host thread" use cases, where the Core WebAssembly code wants to let
426+
the containing runtime nondeterministically schedule threads (via yielding
427+
built-ins) with hints (via the promoting built-ins) — or a mixture of both.
428+
417429

418430
### Thread-Local Storage
419431

@@ -467,47 +479,70 @@ For more information, see [`context.get`] in the AST explainer.
467479
### Blocking
468480

469481
When a thread calls an import using the async ABI, the Component Model
470-
guarantees that if the callee **blocks**, control flow is immediately returned
471-
back to the caller. When the callee is implemented by the *host*, what counts as
472-
"blocking" is up to the host; e.g., the host can arbitrarily determine whether
473-
file I/O "blocks" or not depending on whether the host is implemented using
474-
traditional synchronous OS syscalls or an asynchronous `io_uring`. However,
475-
when the callee is implemented by another component, the Component Model
476-
defines exactly what counts as "blocking".
477-
478-
At a high level, there are six ways for a call to a component export to block,
479-
all of which are described above or below in more detail:
480-
* calling an `async`-typed function import using the sync ABI
482+
guarantees that if the callee [task](#threads-and-tasks) **blocks**, control
483+
flow is immediately returned back to the caller's thread. When the callee is
484+
implemented by the *host*, what counts as "blocking" is up to the host; e.g.,
485+
the host can arbitrarily determine whether file I/O "blocks" or not depending on
486+
whether the host is implemented using traditional synchronous OS syscalls or an
487+
asynchronous `io_uring`. However, when the callee is implemented by another
488+
component, the Component Model defines what counts as "blocking".
489+
490+
There are several ways for a task to potentially "block":
491+
* synchronously calling an `async` function that transitively blocks
492+
or hits [backpressure](#backpressure)
481493
* suspending the current thread via the
482-
[`thread.suspend`](#thread-built-ins) built-in
494+
[`thread.suspend{,-then-promote}`](#thread-built-ins) built-ins
483495
* cooperatively yielding (e.g., during a long-running computation) via the
484-
[`thread.yield`](#thread-built-ins) built-in
496+
[`thread.yield{,-then-promote}`](#thread-built-ins) built-ins or, when
497+
using the stackless `callback` ABI, returning with the `YIELD` code
485498
* waiting for one of a set of concurrent operations to complete via the
486-
[`waitable-set.wait`](#waitables-and-waitable-sets) built-in
487-
* waiting for a stream or future operation to complete via the
499+
[`waitable-set.wait`](#waitables-and-waitable-sets) built-in or, when
500+
using the stackless `callback` ABI, returning with the `WAIT` code
501+
* synchronously waiting for a stream or future operation to complete via the
488502
[`{stream,future}.{,cancel-}{read,write}`](#streams-and-futures) built-ins
489-
* waiting for a subtask to cooperatively cancel itself via the
503+
* synchronously waiting for a subtask to cooperatively cancel itself via the
490504
[`subtask.cancel`](#cancellation) built-in
491505

492-
At each of these points, the [current thread](#current-thread-and-task) will be
493-
suspended. Execution transfers to a caller's thread if there is one, or
494-
otherwise back to the runtime, which may invoke new component exports or
495-
nondeterministically resume a cooperative thread that is ready to run. Thus,
496-
each of these represents **cooperative yield points**.
497-
498-
Additionally, each of these potentially-blocking operations will trap if the
499-
[current task's function type](#current-thread-and-task) does not declare the
500-
`async` effect, since only `async`-typed functions are allowed to block. As an
501-
exception, to allow it to be called arbitrarily from anywhere, `thread.yield`
502-
does not trap but instead behaves as a no-op if the current task's function
503-
type does not contain `async`.
506+
Since Component Model concurrency is [specified in terms of] the Core WebAssembly
507+
[stack-switching] proposal, each of the above represents a point where the
508+
[current thread](#current-thread-and-task) may suspend with the `$block` effect.
509+
Each of these points also serves as a **cooperative yield point** where
510+
[Component Invariant] #2 allows reentrance. However, just because the current
511+
thread *suspends* doesn't mean that the *task* has officially "blocked": what
512+
happens next depends on the state of the task and the declared function type:
513+
514+
If the task has already [returned](#returning) a value to the caller, then
515+
control flow returns to the caller and, from the caller's perspective, the call
516+
returns normally without blocking. The callee's threads can continue executing,
517+
but what happens with these threads no longer matters to the caller.
518+
519+
If instead the task has *not* yet returned a value and the callee's function
520+
type declares the `async` effect, control flow returns directly to the
521+
caller. If the caller used the *async* ABI, then control flow returns to Core
522+
WebAssembly, indicating that the call "blocked" by returning the non-zero index
523+
of a new [subtask](#subtasks-and-supertasks). If the caller used the *sync* ABI,
524+
then the caller immediately suspends with the `$block` effect and this process
525+
repeats recursively up the stack.
526+
527+
Lastly, if the task has not yet returned a value and the callee's function type
528+
does *not* declare the `async` effect, then the task is not allowed to "block"
529+
and will trap if it ends up blocking. However, just because the *current thread*
530+
has suspended doesn't mean that the overall *task* is blocked: if there are any
531+
other threads in the callee's component instance that are in the ["ready to
532+
run"](#thread-built-ins) state, progressing them may unblock returning a value
533+
(e.g., by releasing a lock or computing a dependency) and so the Component Model
534+
repeatedly resumes threads that are ready to run until either the task returns a
535+
value or there are no more eligible ready threads (and the call traps).
536+
See the end of [`canon_lift`] in the Canonical ABI Explainer for more details.
537+
538+
These rules achieve expressive parity with what would otherwise be possible
539+
using a CPS transform like [Asyncify] to implement a synchronous function. For
540+
example, they allow synchronous functions to be implemented by pthreads that
541+
switch and make progress at cooperative yield points. And if there is only a
542+
single pthread, since `thread.yield` always leaves the calling thread in a
543+
"ready to run" state, `thread.yield` effectively becomes a no-op (until a value
544+
is returned).
504545

505-
"Blocking" is [specified in terms of] stack-switching, with a `block` effect
506-
that suspends the current thread to produce a continuation that can be resumed
507-
once the reason for blocking is addressed.
508-
509-
The [Canonical ABI explainer] defines the above behavior more precisely; search
510-
for `may_block` to see all the relevant points.
511546

512547
### Waitables and Waitable Sets
513548

@@ -713,9 +748,7 @@ the readable end passed for `in`) and `stream.write`s (of the writable end it
713748
`stream.new`ed) before exiting the task.
714749

715750
Once `task.return` is called, the task is in the "returned" state. Calling
716-
`task.return` when not in the "started" state traps. Once in a "returned" state,
717-
non-`async` functions may block using cooperative threads that were created
718-
before the synchronous task's implicit thread returned.
751+
`task.return` when not in the "started" state traps.
719752

720753
### Borrows
721754

@@ -1538,13 +1571,15 @@ comes after:
15381571
[`waitable-set.wait`]: Explainer.md#-waitable-setwait
15391572
[`waitable-set.poll`]: Explainer.md#-waitable-setpoll
15401573
[`waitable.join`]: Explainer.md#-waitablejoin
1541-
[`thread.new-indirect`]: Explainer.md#-threadnew-indirect
15421574
[`thread.index`]: Explainer.md#-threadindex
1543-
[`thread.suspend`]: Explainer.md#-threadsuspend
1544-
[`thread.switch-to`]: Explainer.md#-threadswitch-to
1575+
[`thread.new-indirect`]: Explainer.md#-threadnew-indirect
15451576
[`thread.resume-later`]: Explainer.md#-threadresume-later
1546-
[`thread.yield-to`]: Explainer.md#-threadyield-to
1577+
[`thread.suspend`]: Explainer.md#-threadsuspend
15471578
[`thread.yield`]: Explainer.md#-threadyield
1579+
[`thread.suspend-then-resume`]: Explainer.md#-threadsuspend-then-resume
1580+
[`thread.yield-then-resume`]: Explainer.md#-threadyield-then-resume
1581+
[`thread.suspend-then-promote`]: Explainer.md#-threadsuspend-then-promote
1582+
[`thread.yield-then-promote`]: Explainer.md#-threadyield-then-promote
15481583
[`{stream,future}.new`]: Explainer.md#-streamnew-and-futurenew
15491584
[`{stream,future}.{read,write}`]: Explainer.md#-streamread-and-streamwrite
15501585
[`stream.cancel-write`]: Explainer.md#-streamcancel-read-streamcancel-write-futurecancel-read-and-futurecancel-write

0 commit comments

Comments
 (0)