component-model
component-model copied to clipboard
Rationale asynchrony model
This week @aturon gave a presentation on asynchrony in the component model.
I was surprised by the amount of machinery introduced in https://hackmd.io/NM8WrCMWRPOwq--K1jRQrg?view. Would someone mind elaborating why this direction was chosen?
As a reference point, what are the benefits compared to for example a naive CPS approach where:
async fetch(url: string) -> string
translates to:
struct Continuation<T> {
funcref: (state: i32, result: T) -> void,
state: i32,
}
fetch(url: string, cont: Continuation<string>) -> void
Roughly Continuation
in your example amounts to a first-class function / closure. The immediate problem with closures in a cross-language context that we ran into when we got started a while back is that closures very quickly create cycles that require full-on garbage/cycle-collectors (as, e.g., browsers have) to collect and are nightmarish to debug. This is why the component model very explicitly includes a goal of acyclic ownership in its high-level goals. The stream
and future
types avoid these immediate cycles while achieving the concurrency use cases of callbacks.
There is also the goal of integration with existing async language support (e.g., async
, Promises and Futures): callbacks are low-level and would have to be manually mapped into the async runtimes by many developers whereas the future
and stream
types have enough high-level semantic structure to automatically, at the language-binding layer, integrate with async runtimes in much the same way that these async runtimes already integrate with OSes and OS abstractions (like libuv). Languages without async runtimes can just use the raw function pointers in the callback ABI but at least have a clear and regular callback protocol that they can depend upon (as it's enforced by the runtime).
There are also more nuanced problems with callbacks in Interface Types when you dig into the details of trying to balance both the goals of (1) virtualizability (I want to be able to implement a WASI API in either the host or wasm) and (2) performance (in particular, when streaming data, I want efficient bulk copies into and out of linear memory, ideally using io_uring on Linux and BYOB streams on the Web). It's hard to do both with just raw callbacks, especially in the concrete examples we're looking at today where a single component is dealing with multiple incoming and outgoing streams and programs/services are composed of multiple of these components.
While there are a lot of canonical ABI built-ins in the proposal, these roughly correspond to the low-level plumbing that you would expect from a high-performance concurrency library (like libuv) that a language async runtime builds on top of. But at a developer-facing level, I think this proposal is actually very simple: there are two high-level type constructors, future
and stream
, and they map to your regular language async abstractions and things should mostly Just Work (and efficiently).
I'm not sure there's much we could remove that wouldn't break these use cases, but of course, we'll want to dig into this with everyone to see if there are better ideas in the forthcoming WASI presentations and component-model PR (derived from this hackmd) that I mentioned in the last meeting and feedback will be most welcome.
Thanks for the detailed response! I'm not sure I understand everything, but I think it's more productive to hold off my follow-up questions until there is something more fleshed out on the table.
There is also the goal of integration with existing async language support (e.g., async, Promises and Futures): callbacks are low-level and would have to be manually mapped into the async runtimes by many developers whereas the future and stream types have enough high-level semantic structure to automatically, at the language-binding layer.
To clarify: I envisioned the extra continuation parameter to be an implementation detail at the ABI layer, not something to be exposed at the interface-level.
Other question;
I suspect the answer is "efficiency", but here it goes anyway:
Why are streams
special as opposed to being "just" a set of WASI API's built atop of lower level async/future
primitives. Example: async read(stream: handle, ...)
etc.
And more concretely; IIUC streams
can be subscribed to and then data will get to you when it gets to you. How do read options like "peek" fit in?
To clarify: I envisioned the extra continuation parameter to be an implementation detail at the ABI layer, not something to be exposed at the interface-level.
Sure, there is no problem with callbacks+closure-parameters in the canonical ABI and indeed the concurrency sketch you linked to above uses callbacks+closures in the Canonical ABI. But that still leaves the question of what are the types in the public interface that map down to these callbacks and determine how and when they are called. Futures/streams are one such high-level answer.
Yes, one performance is the main motivation for having streams (particularly in scenarios where the producer and consumer of a stream are optionally both wasm). A general design goal for streams is to not implicitly rely on buffering or multiple threads, which effectively means that stream reads/writes end up needing to participate in a control-flow transfer scheme. Trying to do this as a library would I believe end up requiring some lower-level, less-composable control flow primitive be exposed across component interfaces.
The async proposal slides presented over the last ~5 WASI meetings hopefully spoke more to these high-level questions. The next steps are to convert the proposal in those slides into a PR to this repo (e.g., adding the types to Explainer.md
and the canon
builtins to CanonicalABI.md
) which will also be a good point to discuss. (Note, that PR would come after a PR to add resource types/handles (catching up with WIT.md
), so this may take some time.) So I'll tentatively close this issue now, but feel free to reopen with new questions.