Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
282 changes: 50 additions & 232 deletions design/mvp/CanonicalABI.md

Large diffs are not rendered by default.

180 changes: 74 additions & 106 deletions design/mvp/Concurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ gated by the 🔀 (async) and 🧵 (threading) emojis.
* [Borrows](#borrows)
* [Cancellation](#cancellation)
* [Nondeterminism](#nondeterminism)
* [Asynchronous Recursion](#asynchronous-recursion)
* [Reentrance](#reentrance)
* [Interaction with the start function](#interaction-with-the-start-function)
* [Async ABI](#async-abi)
* [Async Import ABI](#async-import-abi)
Expand Down Expand Up @@ -58,9 +58,8 @@ use cases are added, refining the Component Model's high-level
* Allow runtimes to maintain meaningful cross-language call stacks (for the
benefit of debugging, logging, tracing and profiling).
* Consider backpressure and cancellation as part of the design.
* Allow non-reentrant synchronous and event-loop-driven core wasm code that
assumes a single global linear memory stack to not have to worry about
additional reentrancy.
* Provide optional run-to-completion semantics for async code as described
by [Component Invariant] #2.


## Summary
Expand Down Expand Up @@ -202,11 +201,8 @@ Note that because functions without `async` in their type are not allowed to
block, non-`async` functions do not attempt to acquire the "exclusive" lock;
they just barge in. Components exporting a mix of `async` and non-`async`
functions (which again mostly only arises in the more advanced virtualization
scenarios) must therefore take care to handle the "barge-in" case gracefully.
Because this nested non-`async` call will complete synchronously without
blocking, this behavior does not break [Component Invariant] #3: a single
global shadow stack can still be (re)used in a LIFO manner, much like a
traditional signal handler.
scenarios) must therefore take care to handle the "barge-in" case gracefully
by, for example, allocating a fresh linear memory shadow stack.

Lastly, WIT is extended with two new type constructors—`future<T>` and
`stream<T>`—to allow new WIT interfaces to explicitly represent concurrency in
Expand Down Expand Up @@ -506,10 +502,11 @@ There are several ways for a task to potentially "block":
Since Component Model concurrency is [specified in terms of] the Core WebAssembly
[stack-switching] proposal, each of the above represents a point where the
[current thread](#current-thread-and-task) may suspend with the `$block` effect.
Each of these points also serves as a **cooperative yield point** where
[Component Invariant] #2 allows reentrance. However, just because the current
thread *suspends* doesn't mean that the *task* has officially "blocked": what
happens next depends on the state of the task and the declared function type:
Each of these points also serves as a **cooperative yield point** where the host
may nondeterministically switch to another thread. However, just because the
current thread *suspends* doesn't mean that the *task* has officially "blocked":
what happens next depends on the state of the task and the declared function
type:

If the task has already [returned](#returning) a value to the caller, then
control flow returns to the caller and, from the caller's perspective, the call
Expand Down Expand Up @@ -703,15 +700,14 @@ instead of a boolean flag, unrelated pieces of code can report backpressure for
distinct limited resources without prior coordination.

In addition to *explicit* backpressure set by wasm code, there is also an
*implicit* source of backpressure to ensure [Component Invariant] #3 and protect
non-reentrant core wasm code. In particular, when an `async`-typed export is
lifted with the sync ABI or the stackless async ABI, a component-instance-wide
lock is implicitly acquired every time core wasm is executed. By returning to
the event loop after every event (instead of once at the end of the task),
stackless async exports release the lock between every event, allowing a higher
degree of concurrency than synchronous exports. Stackful async exports ignore
the lock entirely and thus achieve the highest degree of (cooperative)
concurrency.
*implicit* source of backpressure to ensure [Component Invariant] #2. In
particular, when an `async`-typed export is lifted with the sync ABI or the
stackless async ABI, a component-instance-wide lock is implicitly acquired every
time core wasm is executed. By returning to the event loop after every event
(instead of once at the end of the task), stackless async exports release the
lock between every event, allowing a higher degree of concurrency than
synchronous exports. Stackful async exports ignore the lock entirely and thus
achieve the highest degree of (cooperative) concurrency.

Since non-`async` functions are not allowed to block (including due to
backpressure) and also don't pile up like `async` functions, non-`async`
Expand Down Expand Up @@ -891,104 +887,76 @@ Despite the above, the following scenarios do behave deterministically:
(modulo any nondeterministic execution that determines the ordering in which
the operations are performed).

### Asynchronous Recursion
### Reentrance

Even without concurrency support, it is possible to reenter a component instance
by recursively calling the component's export from a function called by the
component's import. For example, given a component importing `imp` and exporting
`exp`, using the [JS API], JS code could write:
In general, whenever a component calls an imported function, the component must
assume that before the import call returns, the callee may **reenter** the
caller's component instance by recursively calling an exported function. For
example, if `component.wasm` has type:
```wat
(component
(import "imp" (func))
(export "exp" (func))
)
```
using the [JS API], reentrance is possible via:
```js
import source component from './component.wasm';
var instance;
function imp() {
instance.exports.exp();
}
instance = WebAssembly.instantiate(component, { imp });
instance.exports.exp(); // exp ~~> imp ~~> exp
var { instance } = await WebAssembly.instantiateStreaming(fetch('component.wasm'), { imp });
instance.exports.exp();
```
Pure component-to-component reentrance is also possible via [donut wrapping],
using `call_indirect` and the parent's mutable function table to close the loop.

If `component.wasm` is changed to have type:
```wat
(component
(import "imp" (func async))
(export "exp" (func async))
)
```
To relieve generic bindings generators and component authors from having to
conservatively assume that *every* import call might reenter in this manner,
the Component Model has [Component Invariant] #2. This is enforced by the
[Canonical ABI](CanonicalABI.md#embedding) using strategically placed traps and
boolean flags on component instances.

With native concurrency support, what we'd naturally expect is that if our
component imports `imp` and exports `exp` as `async` functions, then the
following JS code could run the two `exp` calls concurrently, as if they were JS
`async` functions:
and the above JS code is modified to use `async`/`await`:
```js
import source component from './component.wasm';
async function imp() {
await ... some Web API I/O
await instance.exports.exp();
}
instance = WebAssembly.instantiate(component, { imp });
await Promise.all([
instance.exports.exp(),
instance.exports.exp()
]);
var { instance } = await WebAssembly.instantiateStreaming(fetch('component.wasm'), { imp });
instance.exports.exp();
```
In particular, if `exp` transitively awaits `imp`, then when `imp` blocks (via
`await`), control flow returns to the top-level JS script with `instance` in a
reenterable state, so that `exp` can be concurrently invoked a second time.

However, this also means that if we slightly change our original recursive
example to use `async` and then `await` before attempting to reenter `instance`,
there is no trap. The first `await` in `imp` returns to top-level, leaving
`instance` in a reenterable state, so when `imp` is later resumed from the event
loop, it is allowed to reenter `exp`.
this call will deadlock if `component.wasm` implements `exp` using the sync or
stackless async ABI and calls `imp` synchronously. This is due to the [automatic
backpressure](#backpressure) mentioned above that is necessary to preserve the
run-to-completion semantics of [Component Invariant] #2. However, if `imp` is
called asynchronously and `exp` doesn't wait for the result, there is no
deadlock (the outer task completes, backpressure is disabled, and the inner task
can start). Alternatively, if `exp` is implemented using the *stackful* async
ABI, there is no automatic backpressure and it's up to the guest to handle the
recursion without deadlock (as with the preceding synchronous case).

Setting aside these recursive cases, with concurrency support, a new kind of
*non-recursive* reentrance is also possible, which is that `exp` can
[block](#blocking), allowing the caller to then start a second `exp` call that
can run concurrently with the first. From JS code this looks like:
```js
import source component from './component.wasm';
var instance;
async function imp() {
await Promise.resolve();
await instance.exports.exp();
await ... some I/O ...
}
instance = WebAssembly.instantiate(component, { imp });
await instance.exports.exp(); // exp ~~> imp ~~> exp
var { instance } = await WebAssembly.instantiateStreaming(fetch('component.wasm'), { imp });
let results = await Promise.all([
instance.exports.exp(),
instance.exports.exp()
]);
```
The hazard with this example is that if the outer call to `exp` internally grabs
and holds a lock while awaiting the call to `imp`, and if the recursive call to
`exp` waits to acquire the same lock, there will be a deadlock. In the preceding
`async` example, since there is no circular dependency between the two calls to
`exp`, the second call can simply wait for the first to release any lock it
holds.

A concrete example of this hazard is the implicit per-component-instance lock
taken and released by [backpressure](#backpressure). E.g., if `component` lifts
`exp` synchronously (which triggers implicit backpressure while a call to `exp`
is running), the recursive call to `exp` will immediately deadlock.

Unfortunately, it's not possible to reliably discriminate the two cases so that
the second example traps (as it did in the synchronous case) while the first
example succeeds. Given the Component Model's well-defined [async call
stack](#subtasks-and-supertasks), it might seem possible to tell the cases apart
by checking whether `instance` is already *on the call stack* when attempting to
enter `exp`. However, this doesn't work for two reasons:

First, to properly detect asynchronous recursion, the host embedding would have
to maintain something analogous to the Component Model's async call stack, which
some hosts (including, currently, browsers) simply do not have a well-defined
way to do.

Second, the async call stack is neither necessary nor sufficient to catch these
kinds of asynchronous recursive deadlocks. The async call stack tracks the
*causality* leading up to a call, which is useful for debugging, tracing,
profiling, etc., but the async call stack doesn't imply that every call on the
stack is blocking on the result of the next call in the chain (unlike with a
synchronous call stack, which does imply this). Moreover, the async call stack
can arbitrarily reset through indirect forms of asynchronous calls (e.g., host
APIs with callbacks like, in a browser, `setTimeout`), so the absence of
recursion on the async call stack does not guarantee the absence of a circular
asynchronous dependency.

Thus, the Canonical ABI rules don't attempt to distinguish the different kinds
of asynchronous reentrance. It is thus the responsibility of component clients
to avoid async recursion. Fortunately, in component-to-component compositions,
this kind of recursion is only possible when doing advanced higher-order linking
(aka [donut wrapping]). And unlike [Component Invariant] #2, which directly
impacts bindings generators, async recursion only arises when there's
[blocking](#blocking) and so it's already necessary to support (non-recursive)
reentrance.
Unlike recursive reentrance, this non-recursive reentrance is expected to be
commonplace and enabling it is one of the primary goals of having native
concurrency support in the Component Model. Moreover, because the two `exp`
tasks above are non-recursive siblings, unless there is implicit synchronization
between them (e.g., through their shared linear memory), there shouldn't be any
deadlock hazards. Thus, while reentrance is possible both with and without
`async`, `async` "dials up" the expected degree of reentrance but also "dials
down" the hazards associated with reentrance.


## Interaction with the start function
Expand Down
24 changes: 9 additions & 15 deletions design/mvp/Explainer.md
Original file line number Diff line number Diff line change
Expand Up @@ -3009,21 +3009,15 @@ In particular, the Component Model maintains the following invariants:
after a trap, it's no longer possible to observe the internal state of a
component instance.

2. Components can only be reentered (via component export or thread resumption)
when they explicitly [block] or call a [donut wrapped] child component. Calls
to non-`async` functions do *not* count as "blocking" nor do non-blocking
(`async`-lowered) calls to `async` functions. Thus, bindings generators and
component authors do not need to always safely handle reentrance at all
import call sites. (In the [future](Concurrency.md#TODO), support for
first-class functions (as parameter and result values) would loosen this
restriction in an explicit opt-in manner.)

3. To ease adoption, unless a component opts in (via "stackful" lift 🚟 or
cooperative threads 🧵), all core wasm execution inside a component instance
is locally serialized (via automatic backpressure applied at export calls) so
that producer toolchains can continue to use a single global linear memory
shadow stack that is pushed and popped in LIFO order.

2. When components implement `async` functions using the 0.3.0 sync or
async-callback ABIs, core wasm execution is "run to completion" within the
scope of a single component instance: the runtime automatically exerts
backpressure to prevent there from being multiple core wasm stacks live at
the same time. Multiple stacks may, however, be live across *multiple*
component instances, or within a *single* component instance if the component
uses the "stackful" async ABI 🚟, cooperative thread built-ins 🧵, or exposes
synchronously-typed functions (since non-`async` functions cannot exert
backpressure when called).

## JavaScript Embedding

Expand Down
10 changes: 6 additions & 4 deletions design/mvp/Linking.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,12 @@ Once `M1` and `M2` share linear memory and table state, `M2` can import the
so that `M1` can call `C`'s exports via `call_indirect`. This provides `Q` the
flexibility to put *all* its core wasm code in `M1` (using `M2` to only do
`funcref`-plumbing), which is convenient. But this also allows `M1` to attempt
to reenter `C` while `C` is calling an import of `M1`, which would violate
[Component Invariant] #2. To prevent this, the Canonical ABI must place runtime
guards in `lift` that trap if `M1` tries to recursively reenter `C`.
to recursively reenter `C` while `C` is calling an import of `M1`. This kind of
[recursive reentrance](Concurrency.md#reentrance) is already possible to perform
from the host, so donut wrapping just gives parent components the same
expressive capability. In both cases, the host/parent must be careful not to
create circular dependencies between child component tasks that create
deadlocks.

Similarly, donut wrapping allows `Q` to both define resource types that are
imported by `C` and consume resource types that are defined by `C`. This allows
Expand Down Expand Up @@ -319,7 +322,6 @@ future features of WIT and the Component Model.)
[Binary Format]: Binary.md
[WIT]: WIT.md
[`external-id`]: Explainer.md#import-and-export-definitions
[Component Invariant]: Explainer.md#component-invariants
[ESM-integration]: Explainer.md#esm-integration

[WebAssembly/tool-conventions]: https://github.com/WebAssembly/tool-conventions
Expand Down
Loading
Loading