Skip to content

Proposal: First-Class Stacks #1360

Open
@RossTate

Description

@RossTate

There are a number of control-flow patterns that go beyond the classic "last-in first-out" form. Coroutining, iterators based on yield, asynchronous I/O and, more exotically, delimited continuations, are all examples of non-linear control flow. These patterns are becoming increasingly important in applications that are intended to be dynamically responsive.

While it is possible to emulate many of these control-flow patterns using standard core WASM features, there is significant cost in doing so — in terms of run-time efficiency, code size, and composability.

At the same time, the space of control-flow patterns is sufficiently diverse that it may not be appropriate to design special mechanisms for each of them. Even focusing on coroutining, for example, there are at least two common patterns of coroutines—so-called symmetric and asymmetric coroutines—with significantly different implementation requirements. Furthermore, the details of how data is exchanged between coroutines also varies greatly; reflecting choices made at the language-design level as well as application design.

This proposal focuses on a suite of low-level mechanisms that would allow language implementers to build different variations on cooperative multi-tasking. Specifically, we focus on enabling the execution of WebAssembly programs on multiple stacks that a WebAssembly application can switch between.

Although important, we do not directly provide the mechanisms necessary to exchange messages between coroutines, nor do we "take a stand" on issues such as whether to support asymmetric vs. symmetric coroutines. Instead we provide the primitives that—in conjunction with other functionality WebAssembly already provides—enables the language implementer to develop their own mechanisms. We do, however, establish the framework for communicating the status of a coroutine when switching to another stack. That status must encode whether the coroutine is terminating and may encode values as part of that event.

A detailed presentation of this proposal can be found here, including detailed examples of how it can be used to support cooperative lightweight threads, asynchronous I/O, and (coming soon) multi-shot tagged delimited continuations. Here we (@fgmccabe and I) present a summary of the key ideas.

Stacks

The proposal introduces a single type: stackref. A stackref is a reference to a complete stack. In particular, the stack never returns and is equipped with a "grounding" frame generated by the host that determines what to do if ever the stack completes execution through other means (e.g. traps). Depending on how the stack was created, that grounding frame might terminate the thread worker the stack is running on or might cause the next event in the event loop to be processed. Details aside, the point is the stack is self contained and can be executed on its own.

In terms of implementation, a stackref references the leaf or active frame of the stack, rather than its root. This is represented by an instruction pointer paired with a stack pointer.

Stack Switching

The key instruction in the proposal is stack.switch:

  • stack.switch $event : [t* stackref] -> unreachable
    • where event $event : [t* stackref]

This instruction transfers control to the designated stack. The event is repurposed from the exception-handling proposal. It is used to convey a message to the target stack. The t* values are the payload of that message plus a stackref. The stackref in the message is the reference to the stack that was just switched from, which is now in a suspended state.

So if a lightweight thread (implemented as a stackref) wanted to yield control to another lightweight thread, it would do so with the following (where we use catch $yielding $yielded as shorthand for "catch only $resuming exceptions and branch to $resumed):

(event $resuming (param stackref))
(func $add_thread_to_schedule (param $thread stackref) ...)
(func $yield_to (param $thread stackref)
  (block $resumed
    (try
      stack.switch $resuming (local.get $thread)
    catch $resuming $resumed
    )
  ) ;; $resumed : [stackref]
  (call $add_thread_to_schedule)
)

The output type of stack.switch is unreachable. That is because the instruction leaves the current stack in a suspended state, and when control is transferred back, the conveyed message is conceptually thrown as an exception from that point. In practice, we expect engines to optimize for this pattern and only throw an exception (i.e. start an unwinding stack walk) if there isn't a statically known catcher for the event.

Notice that this design provides extremely fast switches between stacks. One essentially just swaps the instruction-pointer and stack-pointer registers with the registers storing the instruction pointer and stack pointer of the target stackref.

Stack Construction

Because the host needs contextual information to create a grounding frame (e.g. is this module instance running on a thread worker or on the event loop), we provide no instruction for creating stacks, instead expecting modules to import a function for creating stacks from the environment, e.g. (import "host" "new_stack" (func $new_stack (result stackref))).

Instead, we provide a process for extending stacks with additional stack frames. This is particularly useful for initializing a new stack, but can also be used to implement (at the application-level) multi-shot continuations.

For this, we need a way to describe stack frames, and in particular stack frames that are ready to be switched to receive events. The following is an example of a func one could use to describe the initial stack frame of a lightweight thread:

(event $completing : [i32 f64 stackref])
(func $do_work (param f64) (result f64) ...)
(func $get_thread_manager (result stackref) ...)

(rout $thread_root (param $id i32) (param $input f64) (local $output f64)
  (block $aborted
    (try
      (block $resumed
        (try
          stack.start
          unreachable
        catch $resuming $resumed
        )
      ) ;; $resumed : [stackref]
      (call $add_thread_to_schedule)
      (local.set $output (call $do_work (local.get $input)))
      (stack.switch $work_completed (local.get $id) (local.get $output) (call $get_thread_manager))
    catch $abort $aborted
    )
  ) ;; $aborted : [stackref]
  (stack.switch $resuming)
)

Notice the instruction stack.start. This is used only by stack.extend (below) to say where execution should start when the added (suspended) stack frame is resumed, and it can only be preceded by event-handling setup instructions (like block and try). So in this example, the thread is set up to so that the first time it is resumed it starts to $do_work. The implementation of which may in turn call $yield_to, but eventually all the work gets done, in which case the lightweight thread switches control to the thread manager, handing off the result of its work.

To extend a stack, say to add $thread_root to a newly created stack, one uses stack.extend:

  • stack.extend $func : [t* stackref] -> unreachable
    • where func $func (param t*)

Stack Destruction

This example also illustrates how one can implement their own thread-aborting mechanism. Given a stackref for a lightweight thread, you can send it an $aborting message. That message is not handled by $yield_to, and as such will be thrown and cause the stack to be unwound, until eventually $thread_rout is reached, in which case it will transfer control back to the stack that initiated the aborting (which in turn will drop the stackref in the payload).

Stack Redirection

Many applications "compose" stacks together. This composition, however, is an illusion. The stack components are of course distinct entities, but stack walks are redirected from one stack to another. In this manner, a WebAssembly application can run its entire body on its own stack, but have any exceptions thrown by host code called within that body be redirected to the host stack that initiated the application in the first place. Changing this redirection lets an application conceptually detach its stack from the current host stack, e.g. the current event handler, and attach it to another host stack, e.g. the handler for a promise. A full account of how to support asynchronous I/O with stack redirection can be found here.

Stack-walk redirection is supported by the following instruction:

  • stack.redirect $local instr* end : [ti*] -> [to*]
    • where local $local : stackref
    • and instr* : [ti*] -> [to*]

Whenever a stack walk (say due to an exception being thrown or due to a stack inspection) reaches stack.redirect, the walk is redirected to the stackref in the local variable $local at the time.

Summary

With a new stackref type, a new rout construct, and a few new instructions, this proposal enables applications to treat stacks as first-class values. Every instruction is constant time. The proposal is designed to make use of the existing exception-handling infrastructure, and is designed to compose well with stack inspection. The proposal is independent of garbage collection (e.g. introduces no cycles), and the only implicit memory management is that a stack must be implicitly freed when its stackref is implicitly dropped. But with the combination of these proposals, we have conducted case studies suggesting that this proposal can implement a wide variety of features in the same manner as many existing industry implementations. The only significant shortcoming we have found so far is that stack duplication, needed for multi-shot continuations, is substantially more complicated than would be necessary if the language had complete trusted control over the runtime.

Metadata

Metadata

Assignees

No one assigned

    Labels

    asyncstack switching, JSPI, async, green threads, coroutines

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions