Skip to content

Commit

Permalink
Add note on async+borrow rules; rename lend_count to num_lends
Browse files Browse the repository at this point in the history
  • Loading branch information
lukewagner committed Dec 20, 2024
1 parent cae44a8 commit 3a1c19a
Show file tree
Hide file tree
Showing 3 changed files with 41 additions and 22 deletions.
33 changes: 26 additions & 7 deletions design/mvp/Async.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,16 +197,17 @@ state that enforces the caller side of the Canonical ABI rules.
To realize the above goals of always having a well-defined cross-component
async callstack, the Component Model's Canonical ABI enforces [Structured
Concurrency] by dynamically requiring that a task waits for all its subtasks to
return before the task itself is allowed to finish. This means that a subtask
cannot be orphaned and there will always be an async callstack rooted at an
invocation of an export by the host. Moreover, at any one point in time, the
set of tasks active in a linked component graph form a forest of async call
trees which e.g., can be visualized using a traditional flamegraph.
[return](#returning) (by calling `task.return`) before the task itself is
allowed to finish. This means that a subtask cannot be orphaned and there will
always be an async callstack rooted at an invocation of an export by the host.
Moreover, at any one point in time, the set of tasks active in a linked
component graph form a forest of async call trees which e.g., can be visualized
using a traditional flamegraph.

The Canonical ABI's Python code enforces Structured Concurrency by incrementing
a per-task "`num_subtasks`" counter when a subtask is created, decrementing
when the subtask returns, and trapping if `num_subtasks > 0` when a task
attempts to exit.
when the subtask [returns](#returning), and trapping if `num_subtasks > 0` when
a task attempts to exit.

There is a subtle nuance to these Structured Concurrency rules deriving from
the fact that subtasks may continue execution after [returning](#returning)
Expand All @@ -225,6 +226,24 @@ component wants to non-cooperatively bound the execution of another
component, a separate "[blast zone]" feature is necessary in any
case.)

This async call tree provided by Structured Concurrency interacts naturally
with the `borrow` handle type and its associated dynamic rules for preventing
use-after-free. When a caller initially lends an `own`ed or `borrow`ed handle
to a callee, a "`num_lends`" counter on the lent handle is incremented when the
subtask starts and decremented when the caller is notified that the subtask has
[returned](#returning) (specifically, when `task.return` is called). If the
caller tries to drop a handle while the handle's `num_lends` is greater than
zero, it traps. Symmetrically, each `borrow` handle passed to a callee task
increments a "`num_borrows`" counter on the task that is decremented when the
`borrow` handle is dropped. With async calls, there can of course be multiple
overlapping async tasks and thus `borrow` handles must remember which
particular task's `num_borrows` counter to drop. If a task attempts to return
when its `num_borrows` is greater than zero, it traps. These interlocking rules
for the `num_lends` and `num_borrows` fields inductively ensure that nested
async call trees that transitively propagate `borrow`ed handles maintain the
essential invariant that dropping an `own`ed handle never destroys a resource
while there is any `borrow` handle anywhere pointing to that resource.

### Streams and Futures

Streams and Futures have two "ends": a *readable end* and *writable end*. When
Expand Down
18 changes: 9 additions & 9 deletions design/mvp/CanonicalABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,14 @@ class ResourceHandle:
rep: int
own: bool
borrow_scope: Optional[Task]
lend_count: int
num_lends: int

def __init__(self, rt, rep, own, borrow_scope = None):
self.rt = rt
self.rep = rep
self.own = own
self.borrow_scope = borrow_scope
self.lend_count = 0
self.num_lends = 0
```
The `rt` and `rep` fields of `ResourceHandle` store the `rt` and `rep`
parameters passed to the `resource.new` call that created this handle. The
Expand All @@ -264,7 +264,7 @@ reentrance, there is at most one `Task` alive in a component instance at any
time and thus an optimizing implementation doesn't need to store the `Task`
per `ResourceHandle`.

The `lend_count` field maintains a conservative approximation of the number of
The `num_lends` field maintains a conservative approximation of the number of
live handles that were lent from this `own` handle (by calls to `borrow`-taking
functions). This count is maintained by the `ImportCall` bookkeeping functions
(above) and is ensured to be zero when an `own` handle is dropped.
Expand Down Expand Up @@ -667,20 +667,20 @@ class Subtask:
The `lenders` field of `Subtask` maintains a list of all resource handles
that have been lent to a subtask and must therefor not be dropped until the
subtask returns. The `add_lender` method is called (below) when lifting a
resource handle and increments its `lend_count`, which is guarded to be zero by
resource handle and increments its `num_lends`, which is guarded to be zero by
`canon_resource_drop` (below). The `finish` method releases all the
`lend_count`s of all such handles lifted for the subtask and is called (below)
`num_lends`s of all such handles lifted for the subtask and is called (below)
when the subtask returns.
```python
def add_lender(self, lending_handle):
assert(not self.finished and self.state != CallState.RETURNED)
lending_handle.lend_count += 1
lending_handle.num_lends += 1
self.lenders.append(lending_handle)

def finish(self):
assert(not self.finished and self.state == CallState.RETURNED)
for h in self.lenders:
h.lend_count -= 1
h.num_lends -= 1
self.finished = True
```
Note, the `lenders` list usually has a fixed size (in all cases except when a
Expand Down Expand Up @@ -1465,7 +1465,7 @@ handle is currently being lent out as borrows.
def lift_own(cx, i, t):
h = cx.inst.resources.remove(i)
trap_if(h.rt is not t.rt)
trap_if(h.lend_count != 0)
trap_if(h.num_lends != 0)
trap_if(not h.own)
return h.rep
```
Expand Down Expand Up @@ -2755,7 +2755,7 @@ async def canon_resource_drop(rt, sync, task, i):
inst = task.inst
h = inst.resources.remove(i)
trap_if(h.rt is not rt)
trap_if(h.lend_count != 0)
trap_if(h.num_lends != 0)
flat_results = [] if sync else [0]
if h.own:
assert(h.borrow_scope is None)
Expand Down
12 changes: 6 additions & 6 deletions design/mvp/canonical-abi/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,14 +269,14 @@ class ResourceHandle:
rep: int
own: bool
borrow_scope: Optional[Task]
lend_count: int
num_lends: int

def __init__(self, rt, rep, own, borrow_scope = None):
self.rt = rt
self.rep = rep
self.own = own
self.borrow_scope = borrow_scope
self.lend_count = 0
self.num_lends = 0

class ResourceType(Type):
impl: ComponentInstance
Expand Down Expand Up @@ -494,13 +494,13 @@ def __init__(self, task):

def add_lender(self, lending_handle):
assert(not self.finished and self.state != CallState.RETURNED)
lending_handle.lend_count += 1
lending_handle.num_lends += 1
self.lenders.append(lending_handle)

def finish(self):
assert(not self.finished and self.state == CallState.RETURNED)
for h in self.lenders:
h.lend_count -= 1
h.num_lends -= 1
self.finished = True

def drop(self):
Expand Down Expand Up @@ -1018,7 +1018,7 @@ def unpack_flags_from_int(i, labels):
def lift_own(cx, i, t):
h = cx.inst.resources.remove(i)
trap_if(h.rt is not t.rt)
trap_if(h.lend_count != 0)
trap_if(h.num_lends != 0)
trap_if(not h.own)
return h.rep

Expand Down Expand Up @@ -1791,7 +1791,7 @@ async def canon_resource_drop(rt, sync, task, i):
inst = task.inst
h = inst.resources.remove(i)
trap_if(h.rt is not rt)
trap_if(h.lend_count != 0)
trap_if(h.num_lends != 0)
flat_results = [] if sync else [0]
if h.own:
assert(h.borrow_scope is None)
Expand Down

0 comments on commit 3a1c19a

Please sign in to comment.