Make 'async' part of the function type, not a hint
This PR moves async from being a hint that is textually mangled into function names to an optional effect type that is part of a function type. WIT is not changed; just how it's encoded as a component import/export definition. For example,
foo: async func(s: string) -> string;
goes from being encoded (when imported) as:
(import "[async]foo" (func (param "s" string) (result string)))
to
(import "foo" (func async (param "s" string) (result string)))
Along with (and motivating) this change is a runtime behavioral change: if core wasm "blocks" (viz.: calls waitable-set.{wait,poll}, thread.suspend, or calls something that might block synchronously) and the current task has not returned and the current task's function type does not contain async, there is a trap. Thus, async moves from being a "hint" that can be violated to an invariant that a client (host or component) can depend on.
The motivation for this change is working through the browser/JS embedding (both in a near-term jco transpile context but also considering a longer-term native browser/JS embedding). If the lack of async doesn't guarantee the lack of blocking, then a browser/JS embedding would never be able to call a non-async component export in a synchronous context (with important use cases being: an addEventListener callback, constructors, getters and setters). This is certainly the case for jco transpile implemented in terms of JSPI (where "blocking" manifests as the JS glue code calling into wasm that returns a Promise that cannot be awaited in a synchronous JS context). But even in a native browser implementation, blocking in a synchronous context basically requires spinning a nested event loop which browsers are generally trying to kill off and prevent any new occurrences of. This change would also be beneficial for other, non-browser embeddings which have the same underlying implementation constraints as browsers.
This change also reduces the cognitive overhead for bindings generators and power users of the hint. As a hint, since a non-async import might block, the bindings generator had to ask: "do I, and how do I, expose a power-user option for overriding the hint to handle the blocking case". (This is especially tricky when considering native binding support, as we'd eventually like for all embeddings of components into language runtimes, where there's not the option of "build flags"). Conversely, if I'm a very careful programmer who wants to achieve maximum concurrency: if async is just a hint and callees "might" always block: should I override the hint and when? Do I just determine when to override experimentally or conservatively always use async? Having a checked effect avoids developers wasting their time with these obscure-but-otherwise-necessary questions.
Although this change would seem to give functions a "color", for the reasons described in this PR in Concurrency.md#summary, async at the WIT/Component level does not have the same infective quality as source-language async. This change is also backwards compatible with existing Preview 2 components (which are all non-async and have no way to block) and existing Preview 3 WASI worlds (which already mark all the exports as async and can thus always block, as you'd expect).
@dicej Yep! Is there already a dev release with these traps implemented that I can use to write the tests?
Some time ago you promised not to go down the road of function coloring, yet here we are. Async is the goto keyword of our generation. The concept of asynchronicity belongs on the caller side, not on the callee side. There are alternatives; investing the time and handling it properly now will prevent decades of headaches and pain.
@dicej Yep! Is there already a dev release with these traps implemented that I can use to write the tests?
If you can build this PR branch of Wasmtime, you should be set, e.g. cargo install --git https://github.com/dicej/wasmtime --branch trap-blocking-in-sync-tasks --features component-model-async --locked wasmtime-cli. I don't think we'll have a dev release until that PR is merged, but let me know if you have trouble and we'll figure something out.
exports impose little to no requirements on the guest language's style of concurrency
OK sorry for commenting before reading the full PR. No requirements would be fine but what does "little requirements" mean
and if the caller may or may not call the function in a concurrent fashion (as it should be !) then why add async keyword anyways?
OK sorry for commenting before reading the full PR. No requirements would be fine but what does "little requirements" mean
If we're just talking about source code compiled to run in a component with async exports using the sync ABI, I don't think there are any requirements; async just buys you more optionality for what you can do at runtime. The "little or" in "little or no requirements" in Concurrency.md#summary refers to cases where you need to call a component export in a synchronous context, so you might opt for non-async exports.
and if the caller may or may not call the function in a concurrent fashion (as it should be !) then why add async keyword anyways?
It's really just about capturing in the calling contract (= function type), as understood by both the caller and the callee (unlike lifting/lowering ABI options, which are encapsulated impl details of their respective component) the (runtime checked) fact that the callee will not block.
Ok, added a nice beefy test/async/trap-if-block-and-sync.wast.
Two additional tweaks to this change:
- Added a clarification to Concurrency.md that
startfunctions (component and core) are to be interpreted as sync functions. (Later we could add anasynceffect on the component-type to allow components to declare that instantiation could block.) - Re-allowed
waitable-set.pollto be called form sync functions by changing its semantics to not yield but instead do the more primitive operation of just returning whether any waitable in the set has a pending event, since this appears to be actually quite useful to do in a synchronous context in some virtualization scenarios.
@badeend Oops, yes, good catch. I grepped "poll" and found a bunch of places that needed updating.
WASI's stdio is fully async. There has been discussion about this before.
The nearest counterpart in browsers (console.log) never blocks and can be called in getters/setters/constructors/etc despite technically doing I/O.
I think the WASI decision hinged on the ability to always block. With that now gone, do you think the WASI interface needs to be revised after this PR?
@badeend That's a great catch! Based on the earlier discussions, I think there's (still) significant value in having stdin/stdout expose streams (for forwarding/splicing purposes and efficient completion-based bulk byte transfer). But obviously we also want to be able to printf() to console.log at any point in time. I think perhaps the way to square this circle is to add synchronous read/write functions to the stdin/stdout/stderr interfaces, alongside the existing read-via-stream/write-via-stream functions. This would be somewhat analogous to how, in C, stdout has both a FILE* that you can pass to fwrite() and a file descriptor that you can pass to write().
Alternatively, we could say "as a part of the behavioral contract of std{in,out,err}, the streams returned by {read,write}-via-stream never return "blocked" when the stream.{read,write} built-ins are called asynchronously (which you are always allowed to call), but this seems somewhat fragile and hazardous, e.g., if you want to virtualize these interfaces. But if we were in a time pinch to release 0.3.0, this might be a quick fix while we add the synchronous functions mentioned above in a 0.3.1.
this seems somewhat fragile and hazardous
I agree ;)
perhaps the way to square this circle is to add synchronous read/write functions to the stdin/stdout/stderr interfaces, alongside the existing read-via-stream/write-via-stream functions.
Sounds fine to me. Let's continue in the pre-existing issue on that topic.