Skip to content

Commit a721019

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

6 files changed

Lines changed: 733 additions & 304 deletions

File tree

design/mvp/CanonicalABI.md

Lines changed: 192 additions & 155 deletions
Large diffs are not rendered by default.

design/mvp/Concurrency.md

Lines changed: 110 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -388,32 +388,43 @@ 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. While the calling
413+
thread *may* know that the target thread is ready to run (e.g., because the
414+
target thread yielded or is waiting on a future/stream operation that the
415+
calling thread just completed), in general, readiness may depend on
416+
nondeterministic external I/O and the calling thread just wants to yield its
417+
timeslice to the target thread *if* it's ready. Thus, if the target thread
418+
is *not* "ready to run", these built-ins gracefully fall back to the behavior of
419+
the `thread.{suspend,yield}` built-ins.
420+
421+
Together, these thread built-ins support both the "green thread" [use
422+
cases](#goals), where Core WebAssembly code running inside the component wants
423+
to fully control thread scheduling (via the suspending and resuming built-ins),
424+
and the "host thread" use cases, where the Core WebAssembly code wants to let
425+
the containing runtime nondeterministically schedule threads (via the yielding
426+
and promoting built-ins).
427+
417428

418429
### Thread-Local Storage
419430

@@ -467,47 +478,73 @@ For more information, see [`context.get`] in the AST explainer.
467478
### Blocking
468479

469480
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
481+
guarantees that if the callee [task](#threads-and-tasks) **blocks**, control
482+
flow is immediately returned back to the caller's thread. When the callee is
483+
implemented by the *host*, what counts as "blocking" is up to the host; e.g.,
484+
the host can arbitrarily determine whether file I/O "blocks" or not depending on
485+
whether the host is implemented using traditional synchronous OS syscalls or an
486+
asynchronous `io_uring`. However, when the callee is implemented by another
487+
component, the Component Model defines what counts as "blocking".
488+
489+
There are several ways for a task to potentially "block":
490+
* synchronously calling an `async` function that transitively blocks
491+
or hits [backpressure](#backpressure)
481492
* suspending the current thread via the
482-
[`thread.suspend`](#thread-built-ins) built-in
493+
[`thread.suspend{,-then-promote}`](#thread-built-ins) built-ins
483494
* cooperatively yielding (e.g., during a long-running computation) via the
484-
[`thread.yield`](#thread-built-ins) built-in
495+
[`thread.yield{,-then-promote}`](#thread-built-ins) built-ins or, when
496+
using the stackless `callback` ABI, returning with the `YIELD` code
485497
* 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
498+
[`waitable-set.wait`](#waitables-and-waitable-sets) built-in or, when
499+
using the stackless `callback` ABI, returning with the `WAIT` code
500+
* synchronously waiting for a stream or future operation to complete via the
488501
[`{stream,future}.{,cancel-}{read,write}`](#streams-and-futures) built-ins
489-
* waiting for a subtask to cooperatively cancel itself via the
502+
* synchronously waiting for a subtask to cooperatively cancel itself via the
490503
[`subtask.cancel`](#cancellation) built-in
491504

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`.
505+
Since Component Model concurrency is [specified in terms of] [stack-switching],
506+
each of the above represents a point where the [current thread](#current-thread-and-task)
507+
may suspend with the `$block` effect. Each of these also serves as a
508+
**cooperative yield point** where [Component Invariant] #2 allows reentrance.
509+
However, just because the current thread *suspends* doesn't mean that the *task*
510+
has officially "blocked": what happens next depends on the state of the task and
511+
the declared function type:
512+
513+
If the task has already [returned](#returning) a value to the caller, then
514+
control flow returns to the caller and, from the caller's perspective, the call
515+
returns normally without blocking. The callee's threads can continue executing,
516+
but whether or not these threads suspend doesn't matter to the caller anymore.
517+
518+
If instead the task has *not* yet returned a value and the callee's function
519+
type declares the `async` effect, control flow transfers immediately to the
520+
caller when the callee suspends. If the caller used the *async* ABI, then
521+
control flow returns to Core WebAssembly, indicating that the call "blocked" by
522+
returning the non-zero index of a new [subtask](#subtasks-and-supertasks). If
523+
the caller used the *sync* ABI, then the caller itself suspends with the
524+
`$block` effect and this process repeats recursively up the stack.
525+
526+
Lastly, if the task has not yet returned a value and the callee's function type
527+
does *not* declare the `async` effect, then the task is not allowed to "block"
528+
and will trap if it ends up blocking. However, just because the *current thread*
529+
has suspended doesn't mean that the overall *task* is blocked: if there are any
530+
other threads in the callee's component instance that are in the ["ready to
531+
run"](#thread-built-ins) state, they may lead to returning a value (e.g., by
532+
releasing a lock or computing a dependency or returning the value themselves).
533+
Thus, the Component Model repeatedly resumes threads that are ready to run (only
534+
in the callee's component instance; picking nondeterministically if there are
535+
multiple) until either the task returns a value (in which case control flow
536+
returns to the caller who sees that the call did not block) or there are no more
537+
ready threads in the callee's component instance (in which case the call traps).
538+
539+
These rules achieve expressive parity with what would otherwise be possible
540+
using a CPS transform like [Asyncify] to implement a synchronous function. For
541+
example, they allow synchronous functions to be implemented by pthreads that
542+
switch at cooperative yield points. And if there is only a single pthread, since
543+
`thread.yield` always leaves the calling thread in a "ready to run" state,
544+
`thread.yield` effectively becomes a no-op (until a value is returned). The
545+
limitation of only switching to cooperative threads *in the same component
546+
instance* is necessary to preserve [Component Invariant] #2.
504547

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

512549
### Waitables and Waitable Sets
513550

@@ -678,12 +715,12 @@ degree of concurrency than synchronous exports. Stackful async exports ignore
678715
the lock entirely and thus achieve the highest degree of (cooperative)
679716
concurrency.
680717

681-
Since non-`async` functions are not allowed to block (including due to
682-
backpressure) and also don't pile up like `async` functions, non-`async`
683-
functions ignore backpressure (explicit and implicit) entirely. If a
684-
component exports a mix of `async` and non-`async` functions, code generation
685-
must therefore be prepared to handle non-`async` functions executing at
686-
any cooperative yield point, even in the middle of a `callback`.
718+
Since non-`async` functions are not allowed to [block](#blocking) (including due
719+
to backpressure) and also don't pile up like `async` functions, non-`async`
720+
functions ignore backpressure (explicit and implicit) entirely. If a component
721+
exports a mix of `async` and non-`async` functions, code generation must
722+
therefore be prepared to handle non-`async` functions executing at any
723+
cooperative yield point, even in the middle of a `callback`.
687724

688725
Once a task is allowed to start according to these backpressure rules, its
689726
arguments are lowered into the callee's linear memory and the task is in
@@ -713,9 +750,7 @@ the readable end passed for `in`) and `stream.write`s (of the writable end it
713750
`stream.new`ed) before exiting the task.
714751

715752
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.
753+
`task.return` when not in the "started" state traps.
719754

720755
### Borrows
721756

@@ -1538,13 +1573,15 @@ comes after:
15381573
[`waitable-set.wait`]: Explainer.md#-waitable-setwait
15391574
[`waitable-set.poll`]: Explainer.md#-waitable-setpoll
15401575
[`waitable.join`]: Explainer.md#-waitablejoin
1541-
[`thread.new-indirect`]: Explainer.md#-threadnew-indirect
15421576
[`thread.index`]: Explainer.md#-threadindex
1543-
[`thread.suspend`]: Explainer.md#-threadsuspend
1544-
[`thread.switch-to`]: Explainer.md#-threadswitch-to
1577+
[`thread.new-indirect`]: Explainer.md#-threadnew-indirect
15451578
[`thread.resume-later`]: Explainer.md#-threadresume-later
1546-
[`thread.yield-to`]: Explainer.md#-threadyield-to
1579+
[`thread.suspend`]: Explainer.md#-threadsuspend
15471580
[`thread.yield`]: Explainer.md#-threadyield
1581+
[`thread.suspend-then-resume`]: Explainer.md#-threadsuspend-then-resume
1582+
[`thread.yield-then-resume`]: Explainer.md#-threadyield-then-resume
1583+
[`thread.suspend-then-promote`]: Explainer.md#-threadsuspend-then-promote
1584+
[`thread.yield-then-promote`]: Explainer.md#-threadyield-then-promote
15481585
[`{stream,future}.new`]: Explainer.md#-streamnew-and-futurenew
15491586
[`{stream,future}.{read,write}`]: Explainer.md#-streamread-and-streamwrite
15501587
[`stream.cancel-write`]: Explainer.md#-streamcancel-read-streamcancel-write-futurecancel-read-and-futurecancel-write

0 commit comments

Comments
 (0)