CPP: async guest bindings sketch
While we could go with exactly the choices made for async c bindgen, I'd like to sketch a C++ native solution.
C++20 introduces coroutines which are a much closer fit to the co-operative threads design in the component model.
The perennial challenge with C++ is which minimum version should we support? I see where we lean into C++ 17 features today, but could/should we raise the bar to C++20? I started sketching what it might look like assuming we could depend on C++20 and also assuming we wouldn't go to C++23.
cc @cpetig
Some basic design decisions:
- Should we use exceptions for async failures? These appear to be uncommon in modern C++ for perf reasons and we should use
std::expected<T, AsyncError>instead. - Move semantics for all async operations, and have them only use owned values (to prevent use-after-move)
- No references/borrows in async contexts (to prevent dangling)
- All operations are cancellable
- RAII for automatic handle cleanup
Just a heads up that I'm knocking some rust off (pun intended) after having not written C++ professionally in several years. So please don't take my proposal here as authoritative as there's a solid chance I've missed some features we may want to take advantage of.
Usage example
interface example {
get-value: async func() -> u32;
}
// Bindings
namespace example {
// Async import
Task<uint32_t> GetValue();
// Implementation (export)
namespace exports {
uint32_t GetValue() {
// User implementation
return 42;
}
}
}
// Usage
Task<uint32_t> example_task = example::GetValue();
uint32_t value = co_await example_task;
Sketches on implementation details
// Cancellation support
auto cancel() -> std::optional<T> {
uint32_t status = writer_->vtable_->cancel_write(writer_->handle_);
if (status == (uint32_t)AsyncStatus::Cancelled) {
// Successfully cancelled, return original value
return std::move(value_);
}
return std::nullopt; // Already completed
}
Sketch for reading values from a stream:
// Read one value (returns nullopt on EOF)
auto next() -> Task<std::optional<T>> {
T value;
auto result = co_await read(std::span<T>(&value, 1));
if (result.count == 0) {
co_return std::nullopt;
}
co_return std::move(value);
}
// Collect all remaining values
auto collect() -> Task<std::vector<T>> {
std::vector<T> results;
while (auto val = co_await next()) {
results.push_back(std::move(*val));
}
co_return results;
}
MISRA only covers up to C++17, so within automotive this version is a reasonable default. πΈοΈ I have never seen anyone using neither modules nor concepts in the industry, C++14 is still ubiquitous.
For the existing bindings @TartanLlama and me decided to default to C++20 (well supported by the wasi-sdk at that time) with std::expected from C++23 using Sy's backport. Setting the limit to whatever wasi-sdk supports well, makes perfect sense to me.
Also I already have a working async, streams and futures implementation for the symmetric ABI variant in my fork: https://github.com/cpetig/wit-bindgen/blob/work-in-progress/tests/runtime-async/async/stream-string/test.cpp (I now see from my ifdefs that the writing side is much less implemented in C++ than Rust, I guess the inactive first branch would be the preferred way to go)
These days I will gladly write an asynchronous executor in Rust and use it via WIT from C++ (see https://github.com/cpetig/wit-bindgen/blob/work-in-progress/crates/symmetric_executor/cpp-client/stream_support.h and https://github.com/cpetig/wit-bindgen/blob/work-in-progress/crates/symmetric_executor/symmetric_stream/src/lib.rs ) - but I have bad history with getting co_await to do anything non-trivial.
That said I am happy for anyone teaching me how to use modern C++ (Sy told me that passing xvalues to rvalue references guarantees optimization), I just no longer use enough modern C++ to faithfully design a helpful API.
Trying an answer to your questions:
-
Should we use exceptions for async failures? These appear to be uncommon in modern C++ for perf reasons and we should use
std::expected<T, AsyncError>instead. I guess that we should have a plan for https://github.com/WebAssembly/wasi-sdk/issues/565 before even thinking about this. π» Functional safety guidelines usually forbid exceptions, so AUTOSAR (as many other companies) created its ownstd::expectedequivalent around 2018. π€¦ -
Move semantics for all async operations, and have them only use owned values (to prevent use-after-move) Yes, please π¦. Implicit copying always came back to hurt me.
-
No references/borrows in async contexts (to prevent dangling) I was expecting a closure in your example, but I guess this is my Rust brain talking.
-
All operations are cancellable β€οΈ
-
RAII for automatic handle cleanup π
I am most influenced by the design in https://www.autosar.org/fileadmin/standards/R24-11/AP/AUTOSAR_AP_EXP_ARAComAPI.pdf - basically a function returning std::future<std::expected<T, E>> and registering a callback for incoming events. The server derives from a base class and overrides the methods provided (Skeleton in AUTOSAR speak). Supporting both push (SetReceiveHandler) as well as pull (GetNewSamples) was a major design goal, see chapter 5.3.5.5).
For the Rust AUTOSAR API we went full async with Stream and similar modern types.
OK these resources are extremely helpful. I'm still catching up here. My C++ days come from when C++11 was cool and we wanted to upgrade to C++14 "one day". But now I'm working on a very large C++ codebase that is exposed as a component, so I'm back in the game.
"A very large C++ codebase" to me whispers "needs multi-threading". With Sy's cooperative multi-threading in wasmtime the only missing part is wasi-libc support for p3+coop threads. (I am currently investigating this problem space for one of our C++ code bases)
Yes definitely threads will be handy to have internal to the component, but I think from a boundary perspective we wouldn't want to expose at the bindgen layer if we don't have to. Now we may have to if we can't set the baseline to C++20.
I'm working on wasi-libc support right now FWIW!