Skip to content

Commit 57b5b13

Browse files
committed
Merge remote-tracking branch 'origin/main' into clarify-options
2 parents c542ead + 8f9f881 commit 57b5b13

File tree

4 files changed

+144
-140
lines changed

4 files changed

+144
-140
lines changed

design/mvp/Async.md

Lines changed: 80 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ summary of the motivation and animated sketch of the design in action.
1616
* [Task](#task)
1717
* [Current task](#current-task)
1818
* [Context-Local Storage](#context-local-storage)
19-
* [Subtask and Supertask](#subtask-and-supertask)
2019
* [Structured concurrency](#structured-concurrency)
2120
* [Streams and Futures](#streams-and-futures)
2221
* [Waiting](#waiting)
2322
* [Backpressure](#backpressure)
2423
* [Returning](#returning)
24+
* [Borrows](#borrows)
2525
* [Async ABI](#async-abi)
2626
* [Async Import ABI](#async-import-abi)
2727
* [Async Export ABI](#async-export-abi)
@@ -251,70 +251,64 @@ reason why "context-local" storage is not called "task-local" storage (where a
251251
For details, see [`context.get`] in the AST explainer and [`canon_context_get`]
252252
in the Canonical ABI explainer.
253253

254-
### Subtask and Supertask
255-
256-
Each component-to-component call necessarily creates a new task in the callee.
257-
The callee task is a **subtask** of the calling task (and, conversely, the
258-
calling task is a **supertask** of the callee task. This sub/super relationship
259-
is immutable and doesn't change over time (until the callee task completes and
260-
is destroyed).
261-
262-
The Canonical ABI's Python code represents the subtask relationship between a
263-
caller `Task` and a callee `Task` via the Python [`Subtask`] class. Whereas a
264-
`Task` object is created by each call to [`canon_lift`], a `Subtask` object is
265-
created by each call to [`canon_lower`]. This allows `Subtask`s to store the
266-
state that enforces the caller side of the Canonical ABI rules.
267-
268254
### Structured concurrency
269255

270-
To realize the above goals of always having a well-defined cross-component
271-
async callstack, the Component Model's Canonical ABI enforces [Structured
272-
Concurrency] by dynamically requiring that a task waits for all its subtasks to
273-
[return](#returning) before the task itself is allowed to finish. This means
274-
that a subtask cannot be orphaned and there will always be an async callstack
275-
rooted at an invocation of an export by the host. Moreover, at any one point in
276-
time, the set of tasks active in a linked component graph form a forest of
277-
async call trees which e.g., can be visualized using a traditional flamegraph.
278-
279-
The Canonical ABI's Python code enforces Structured Concurrency by incrementing
280-
a per-task "`num_subtasks`" counter when a subtask is created, decrementing
281-
when the subtask [returns](#returning), and trapping if `num_subtasks > 0` when
282-
a task attempts to exit.
283-
284-
There is a subtle nuance to these Structured Concurrency rules deriving from
285-
the fact that subtasks may continue execution after [returning](#returning)
286-
their value to their caller. The ability to execute after returning value is
287-
necessary for being able to do work off the caller's critical path. A concrete
288-
example is an HTTP service that does some logging or billing operations after
289-
finishing an HTTP response, where the HTTP response is the return value of the
290-
[`wasi:http/handler.handle`] function. Since the `num_subtasks` counter is
291-
decremented when a subtask *returns* (as opposed to *exits*), this means that
292-
subtasks may continue execution even once their supertask has exited. To
293-
maintain Structured Concurrency (for purposes of checking [reentrance],
294-
scheduler prioritization and debugging/observability), we can consider
295-
the supertask to still be alive but in the process of "asynchronously
296-
tail-calling" its still-executing subtasks. (For scenarios where one
297-
component wants to non-cooperatively bound the execution of another
298-
component, a separate "[blast zone]" feature is necessary in any
299-
case.)
300-
301-
This async call tree provided by Structured Concurrency interacts naturally
302-
with the `borrow` handle type and its associated dynamic rules for preventing
303-
use-after-free. When a caller initially lends an `own`ed or `borrow`ed handle
304-
to a callee, a "`num_lends`" counter on the lent handle is incremented when the
305-
subtask starts and decremented when the caller is notified that the subtask has
306-
[returned](#returning). If the caller tries to drop a handle while the handle's
307-
`num_lends` is greater than zero, it traps. Symmetrically, each `borrow` handle
308-
passed to a callee task increments a "`num_borrows`" counter on the task that
309-
is decremented when the `borrow` handle is dropped. With async calls, there can
310-
of course be multiple overlapping async tasks and thus `borrow` handles must
311-
remember which particular task's `num_borrows` counter to drop. If a task
312-
attempts to return (which, for `async` tasks, means calling `task.return`) when
313-
its `num_borrows` is greater than zero, it traps. These interlocking rules for
314-
the `num_lends` and `num_borrows` fields inductively ensure that nested async
315-
call trees that transitively propagate `borrow`ed handles maintain the
316-
essential invariant that dropping an `own`ed handle never destroys a resource
317-
while there is any `borrow` handle anywhere pointing to that resource.
256+
Calling *into* a component creates a `Task` to track ABI state related to the
257+
*callee* (like "number of outstanding borrows"). Calling *out* of a component
258+
creates a `Subtask` to track ABI state related to the *caller* (like "which
259+
handles have been lent"). When one component calls another, there is thus a
260+
`Subtask`+`Task` pair that collectively maintains the overall state of the call
261+
and enforces that both components uphold their end of the ABI contract. But
262+
when the host calls into a component, there is only a `Task` and,
263+
symmetrically, when a component calls into the host, there is only a `Subtask`.
264+
265+
Based on this, the call stack at any point in time when a component calls a
266+
host-defined import will have a callstack of the general form:
267+
```
268+
[Host caller] <- [Task] <- [Subtask+Task]* <- [Subtask] <- [Host callee]
269+
```
270+
Here, the `<-` arrow represents the `supertask` relationship that is immutably
271+
established when first making the call. A paired `Subtask` and `Task` have the
272+
same `supertask` and can thus be visualized as a single node in the callstack.
273+
274+
(These concepts are represented in the Canonical ABI Python code via the
275+
[`Task`] and [`Subtask`] classes.)
276+
277+
One semantically-observable use of this async call stack is to distinguish
278+
between hazardous **recursive reentrance**, in which a component instance is
279+
reentered when one of its tasks is already on the callstack, from
280+
business-as-usual **sibling reentrance**, in which a component instance is
281+
freshly reentered when its other tasks are suspended waiting on I/O. Recursive
282+
reentrance currently always traps, but may be allowed (and indicated to core
283+
wasm) in an opt-in manner in the [future](#TODO).
284+
285+
The async call stack is also useful for non-semantic purposes such as providing
286+
backtraces when debugging, profiling and distributed tracing. While particular
287+
languages can and do maintain their own async call stacks in core wasm state,
288+
without the Component Model's async call stack, linkage *between* different
289+
languages would be lost at component boundaries, leading to a loss of overall
290+
context in multi-component applications.
291+
292+
There is an important nuance to the Component Model's minimal form of
293+
Structured Concurrency compared to Structured Concurrency support that appears
294+
in popular source language features/libraries. Often, "Structured Concurrency"
295+
refers to an invariant that all "child" tasks finish or are cancelled before a
296+
"parent" task completes. However, the Component Model doesn't force subtasks to
297+
[return](#returning) or be cancelled before the supertask returns (this is left
298+
as an option to particular source langauges to enforce or not). The reason for
299+
not enforcing a stricter form of Structured Concurrency at the Component
300+
Model level is that there are important use cases where forcing a supertask to
301+
stay resident simply to wait for a subtask to finish would waste resources
302+
without tangible benefit. Instead, we can say that once the core wasm
303+
implementing a supertask finishes execution, the supertask semantically "tail
304+
calls" any still-live subtasks, staying technically-alive until they complete,
305+
but not consuming real resources. Concretely, this means that a supertask that
306+
finishes executing stays on the callstack of any still-executing subtasks for
307+
the abovementioned purposes until all transitive subtasks finish.
308+
309+
For scenarios where one component wants to *non-cooperatively* put an upper
310+
bound on execution of a call into another component, a separate "[blast zone]"
311+
feature is necessary in any case (due to iloops and traps).
318312

319313
### Streams and Futures
320314

@@ -486,6 +480,28 @@ A task may not call `task.return` unless it is in the "started" state. Once
486480
finish once it is in the "returned" state. See the [`canon_task_return`]
487481
function in the Canonical ABI explainer for more details.
488482

483+
### Borrows
484+
485+
Component Model async support is careful to ensure that `borrow`ed handles work
486+
as expected in an asynchronous setting, extending the dynamic enforcement used
487+
for synchronous code:
488+
489+
When a caller initially lends an `own`ed or `borrow`ed handle to a callee, a
490+
`num_lends` counter on the lent handle is incremented when the subtask starts
491+
and decremented when the caller is notified that the subtask has
492+
[returned](#returning). If the caller tries to drop a handle while the handle's
493+
`num_lends` is greater than zero, the caller traps. Symmetrically, each
494+
`borrow` handle passed to a callee increments a `num_borrows` counter on the
495+
callee task that is decremented when the `borrow` handle is dropped. If a
496+
callee task attempts to return when its `num_borrows` is greater than zero, the
497+
callee traps.
498+
499+
In an asynchronous setting, the only generalization necessary is that, since
500+
there can be multiple overlapping async tasks executing in a component
501+
instance, a borrowed handle must track *which* task's `num_borrow`s was
502+
incremented so that the correct counter can be decremented.
503+
504+
489505
## Async ABI
490506

491507
At an ABI level, native async in the Component Model defines for every WIT
@@ -962,6 +978,7 @@ comes after:
962978
[`Task.enter`]: CanonicalABI.md#task-state
963979
[`Task.wait`]: CanonicalABI.md#task-state
964980
[`Waitable`]: CanonicalABI.md#waitable-state
981+
[`Task`]: CanonicalABI.md#task-state
965982
[`Subtask`]: CanonicalABI.md#subtask-state
966983
[Stream State]: CanonicalABI.md#stream-state
967984
[Future State]: CanonicalABI.md#future-state

design/mvp/CanonicalABI.md

Lines changed: 46 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -129,35 +129,36 @@ known to not contain `borrow`. The `CanonicalOptions`, `ComponentInstance`,
129129

130130
### Canonical ABI Options
131131

132-
The following two classes list the various Canonical ABI options ([`canonopt`])
133-
that can be set on various Canonical ABI definitions. The default values of the
134-
Python fields are the default values when the associated `canonopt` is not
135-
present in the binary or text format definition.
132+
The following classes list the various Canonical ABI options ([`canonopt`])
133+
that can be set on various Canonical ABI definitions. The default values of
134+
the Python fields are the default values when the associated `canonopt` is
135+
not present in the binary or text format definition.
136136

137-
The `LiftLowerContext` class contains the subset of [`canonopt`] which are
138-
used to lift and lower the individual parameters and results of function
139-
calls:
137+
The `LiftOptions` class contains the subset of [`canonopt`] which are needed
138+
when lifting individual parameters and results:
140139
```python
141140
@dataclass
142-
class LiftLowerOptions:
141+
class LiftOptions:
143142
string_encoding: str = 'utf8'
144143
memory: Optional[bytearray] = None
145-
realloc: Optional[Callable] = None
146144

147-
def __eq__(self, other):
148-
return self.string_encoding == other.string_encoding and \
149-
self.memory is other.memory and \
150-
self.realloc is other.realloc
145+
def equal(lhs, rhs):
146+
return lhs.string_encoding == rhs.string_encoding and \
147+
lhs.memory is rhs.memory
148+
```
149+
The `equal` static method is used by `task.return` below to dynamically
150+
compare equality of just this subset of `canonopt`.
151151

152-
def copy(opts):
153-
return LiftLowerOptions(opts.string_encoding, opts.memory, opts.realloc)
152+
The `LiftLowerOptions` class contains the subset of [`canonopt`] which are
153+
needed when lifting *or* lowering individual parameters and results:
154+
```python
155+
@dataclass
156+
class LiftLowerOptions(LiftOptions):
157+
realloc: Optional[Callable] = None
154158
```
155-
The `__eq__` override specifies that equality of `LiftLowerOptions` (as used
156-
by, e.g., `canon_task_return` below) is defined in terms of the identity of
157-
the memory and `realloc`-function instances.
158159

159-
The `CanonicalOptions` class contains the rest of the [`canonopt`] options
160-
that affect how an overall function is lifted/lowered:
160+
The `CanonicalOptions` class contains the rest of the [`canonopt`]
161+
options that affect how an overall function is lifted/lowered:
161162
```python
162163
@dataclass
163164
class CanonicalOptions(LiftLowerOptions):
@@ -470,21 +471,19 @@ class Task:
470471
opts: CanonicalOptions
471472
inst: ComponentInstance
472473
ft: FuncType
473-
caller: Optional[Task]
474+
supertask: Optional[Task]
474475
on_return: Optional[Callable]
475476
on_block: Callable[[Awaitable], Awaitable]
476-
num_subtasks: int
477477
num_borrows: int
478478
context: ContextLocalStorage
479479

480-
def __init__(self, opts, inst, ft, caller, on_return, on_block):
480+
def __init__(self, opts, inst, ft, supertask, on_return, on_block):
481481
self.opts = opts
482482
self.inst = inst
483483
self.ft = ft
484-
self.caller = caller
484+
self.supertask = supertask
485485
self.on_return = on_return
486486
self.on_block = on_block
487-
self.num_subtasks = 0
488487
self.num_borrows = 0
489488
self.context = ContextLocalStorage()
490489
```
@@ -559,7 +558,7 @@ the given arguments into the callee's memory (possibly executing `realloc`)
559558
returning the final set of flat arguments to pass into the core wasm callee.
560559

561560
The `Task.trap_if_on_the_stack` method called by `enter` prevents reentrance
562-
using the `caller` field of `Task` which points to the task's supertask in the
561+
using the `supertask` field of `Task` which points to the task's supertask in the
563562
async call tree defined by [structured concurrency]. Structured concurrency
564563
is necessary to distinguish between the deadlock-hazardous kind of reentrance
565564
(where the new task is a transitive subtask of a task already running in the
@@ -570,10 +569,10 @@ function to opt in (via function type attribute) to the hazardous kind of
570569
reentrance, which will nuance this test.
571570
```python
572571
def trap_if_on_the_stack(self, inst):
573-
c = self.caller
572+
c = self.supertask
574573
while c is not None:
575574
trap_if(c.inst is inst)
576-
c = c.caller
575+
c = c.supertask
577576
```
578577
An optimizing implementation can avoid the O(n) loop in `trap_if_on_the_stack`
579578
in several ways:
@@ -792,7 +791,6 @@ may be a synchronous task unblocked by the clearing of `calling_sync_export`.
792791
```python
793792
def exit(self):
794793
assert(Task.current.locked())
795-
trap_if(self.num_subtasks > 0)
796794
trap_if(self.on_return)
797795
assert(self.num_borrows == 0)
798796
if self.opts.sync:
@@ -806,7 +804,7 @@ may be a synchronous task unblocked by the clearing of `calling_sync_export`.
806804

807805
A "waitable" is anything that can be stored in the component instance's
808806
`waitables` table. Currently, there are 5 different kinds of waitables:
809-
[subtasks](Async.md#subtask-and-supertask) and the 4 combinations of the
807+
[subtasks](Async.md#structured-concurrency) and the 4 combinations of the
810808
[readable and writable ends of futures and streams](Async.md#streams-and-futures).
811809

812810
Waitables deliver "events" which are values of the following `EventTuple` type.
@@ -964,18 +962,10 @@ delivery.
964962
#### Subtask State
965963

966964
While `canon_lift` creates `Task` objects when called, `canon_lower` creates
967-
`Subtask` objects when called. If the callee (being `canon_lower`ed) is another
968-
component's (`canon_lift`ed) function, there will thus be a `Subtask`+`Task`
969-
pair created. However, if the callee is a host-defined function, the `Subtask`
970-
will stand alone. Thus, in general, the call stack at any point in time when
971-
wasm calls a host-defined import will have the form:
972-
```
973-
[Host caller] -> [Task] -> [Subtask+Task]* -> [Subtask] -> [Host callee]
974-
```
975-
976-
The `Subtask` class is simpler than `Task` and only manages a few fields of
977-
state that are relevant to the caller. As with `Task`, this section will
978-
introduce `Subtask` incrementally, starting with its fields and initialization:
965+
`Subtask` objects when called. The `Subtask` class is simpler than `Task` and
966+
only manages a few fields of state that are relevant to the caller. As with
967+
`Task`, this section will introduce `Subtask` incrementally, starting with its
968+
fields and initialization:
979969
```python
980970
class Subtask(Waitable):
981971
state: CallState
@@ -1003,14 +993,9 @@ turn only happens if the call is `async` *and* blocks. In this case, the
1003993
def add_to_waitables(self, task):
1004994
assert(not self.supertask)
1005995
self.supertask = task
1006-
self.supertask.num_subtasks += 1
1007996
Waitable.__init__(self)
1008997
return task.inst.waitables.add(self)
1009998
```
1010-
The `num_subtasks` increment ensures that the parent `Task` cannot `exit`
1011-
without having waited for all its subtasks to return (or, in the
1012-
[future](Async.md#TODO) be cancelled), thereby preserving [structured
1013-
concurrency].
1014999

10151000
The `Subtask.add_lender` method is called by `lift_borrow` (below). This method
10161001
increments the `num_lends` counter on the handle being lifted, which is guarded
@@ -1041,7 +1026,6 @@ its value to the caller.
10411026
def drop(self):
10421027
trap_if(not self.finished)
10431028
assert(self.state == CallState.RETURNED)
1044-
self.supertask.num_subtasks -= 1
10451029
Waitable.drop(self)
10461030
```
10471031

@@ -3257,7 +3241,7 @@ async def canon_task_return(task, result_type, opts: LiftLowerOptions, flat_args
32573241
trap_if(not task.inst.may_leave)
32583242
trap_if(task.opts.sync and not task.opts.always_task_return)
32593243
trap_if(result_type != task.ft.results)
3260-
trap_if(opts != LiftLowerOptions.copy(task.opts))
3244+
trap_if(not LiftOptions.equal(opts, task.opts))
32613245
task.return_(flat_args)
32623246
return []
32633247
```
@@ -3266,13 +3250,18 @@ component with multiple exported functions of different types, `task.return` is
32663250
not called with a mismatched result type (which, due to indirect control flow,
32673251
can in general only be caught dynamically).
32683252

3269-
The `trap_if(opts != LiftLowerOptions.copy(task.opts))` guard ensures that
3270-
the return value is lifted the same way as the `canon lift` from which this
3253+
The `trap_if(not LiftOptions.equal(opts, task.opts))` guard ensures that the
3254+
return value is lifted the same way as the `canon lift` from which this
32713255
`task.return` is returning. This ensures that AOT fusion of `canon lift` and
32723256
`canon lower` can generate a thunk that is indirectly called by `task.return`
3273-
after these guards. The `LiftLowerOptions.copy` method is used to select just
3274-
the `LiftLowerOptions` subset of `CanonicalOptions` (since fields like
3275-
`async` and `callback` aren't relevant to `task.return`).
3257+
after these guards. Inside `LiftOptions.equal`, `opts.memory` is compared with
3258+
`task.opts.memory` via object identity of the mutable memory instance. Since
3259+
`memory` refers to a mutable *instance* of memory, this comparison is not
3260+
concerned with the static memory indices (in `canon lift` and `canon
3261+
task.return`), only the identity of the memories created
3262+
at instantiation-/ run-time. In Core WebAssembly spec terms, the test is on the
3263+
equality of the [`memaddr`] values stored in the instance's [`memaddrs` table]
3264+
which is indexed by the static [`memidx`].
32763265

32773266

32783267
### 🔀 `canon yield`
@@ -3972,6 +3961,9 @@ def canon_thread_available_parallelism():
39723961
[WASI]: https://github.com/webassembly/wasi
39733962
[Deterministic Profile]: https://github.com/WebAssembly/profiles/blob/main/proposals/profiles/Overview.md
39743963
[stack-switching]: https://github.com/WebAssembly/stack-switching
3964+
[`memaddr`]: https://webassembly.github.io/spec/core/exec/runtime.html#syntax-memaddr
3965+
[`memaddrs` table]: https://webassembly.github.io/spec/core/exec/runtime.html#syntax-moduleinst
3966+
[`memidx`]: https://webassembly.github.io/spec/core/syntax/modules.html#syntax-memidx
39753967

39763968
[Alignment]: https://en.wikipedia.org/wiki/Data_structure_alignment
39773969
[UTF-8]: https://en.wikipedia.org/wiki/UTF-8

0 commit comments

Comments
 (0)