Skip to content

Commit 8cf4217

Browse files
committed
co_await support for Embind
This adds support for `co_await`-ing Promises represented by `emscripten::val`. The surrounding coroutine should also return `emscripten::val`, which will be a promise representing the whole coroutine's return value. Note that this feature uses LLVM coroutines and so, doesn't depend on either Asyncify or JSPI. It doesn't pause the entire program, but only the coroutine itself, so it serves somewhat different usecases even though all those features operate on promises. Nevertheless, if you are not implementing a syscall that must behave as-if it was synchronous, but instead simply want to await on some async operations and return a new promise to the user, this feature will be much more efficient. Here's a simple benchmark measuring runtime overhead from awaiting on a no-op Promise repeatedly in a deep call stack: ```cpp using namespace emscripten; // clang-format off EM_JS(EM_VAL, wait_impl, (), { return Emval.toHandle(Promise.resolve()); }); // clang-format on val wait() { return val::take_ownership(wait_impl()); } val coro_co_await(int depth) { co_await wait(); if (depth > 0) { co_await coro_co_await(depth - 1); } co_return val(); } val asyncify_val_await(int depth) { wait().await(); if (depth > 0) { asyncify_val_await(depth - 1); } return val(); } EMSCRIPTEN_BINDINGS(bench) { function("coro_co_await", coro_co_await); function("asyncify_val_await", asyncify_val_await, async()); } ``` And the JS runner also comparing with pure-JS implementation: ```js import Benchmark from 'benchmark'; import initModule from './async-bench.mjs'; let Module = await initModule(); let suite = new Benchmark.Suite(); function addAsyncBench(name, func) { suite.add(name, { defer: true, fn: (deferred) => func(1000).then(() => deferred.resolve()), }); } for (const name of ['coro_co_await', 'asyncify_val_await']) { addAsyncBench(name, Module[name]); } addAsyncBench('pure_js', async function pure_js(depth) { await Promise.resolve(); if (depth > 0) { await pure_js(depth - 1); } }); suite .on('cycle', function (event) { console.log(String(event.target)); }) .run({async: true}); ``` Results with regular Asyncify (I had to bump up `ASYNCIFY_STACK_SIZE` to accomodate said deep stack): ```bash > ./emcc async-bench.cpp -std=c++20 -O3 -o async-bench.mjs --bind -s ASYNCIFY -s ASYNCIFY_STACK_SIZE=1000000 > node --no-liftoff --no-wasm-tier-up --no-wasm-lazy-compilation --no-sparkplug async-bench-runner.mjs coro_co_await x 727 ops/sec ±10.59% (47 runs sampled) asyncify_val_await x 58.05 ops/sec ±6.91% (53 runs sampled) pure_js x 3,022 ops/sec ±8.06% (52 runs sampled) ``` Results with JSPI (I had to disable `DYNAMIC_EXECUTION` because I was getting "RuntimeError: table index is out of bounds" in random places depending on optimisation mode - JSPI miscompilation?): ```bash > ./emcc async-bench.cpp -std=c++20 -O3 -o async-bench.mjs --bind -s ASYNCIFY=2 -s DYNAMIC_EXECUTION=0 > node --no-liftoff --no-wasm-tier-up --no-wasm-lazy-compilation --no-sparkplug --experimental-wasm-stack-switching async-bench-runner.mjs coro_co_await x 955 ops/sec ±9.25% (62 runs sampled) asyncify_val_await x 924 ops/sec ±8.27% (62 runs sampled) pure_js x 3,258 ops/sec ±8.98% (53 runs sampled) ``` So the performance is much faster than regular Asyncify, and on par with JSPI. Fixes emscripten-core#20413.
1 parent c1475fa commit 8cf4217

File tree

9 files changed

+224
-12
lines changed

9 files changed

+224
-12
lines changed

ChangeLog.md

+2
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ See docs/process.md for more on how version tagging works.
4141
sidestep some of the issues with legacy cmd.exe, but developers must
4242
explicitly opt-in to running PowerShell scripts in system settings or
4343
via the `Set-ExecutionPolicy` command. (#20416)
44+
- `emscripten::val` now supports C++20 `co_await` operator for JavaScript
45+
`Promise`s. (#20420)
4446

4547
3.1.47 - 10/09/23
4648
-----------------

emscripten.py

+1
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,7 @@ def create_pointer_conversion_wrappers(metadata):
944944
'stbi_load_from_memory': 'pp_ppp_',
945945
'emscripten_proxy_finish': '_p',
946946
'emscripten_proxy_execute_queue': '_p',
947+
'_emval_coro_resume': '_pp',
947948
}
948949

949950
for function in settings.SIGNATURE_CONVERSIONS:

site/source/docs/api_reference/val.h.rst

+47-11
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ Guide material for this class can be found in :ref:`embind-val-guide`.
4646
}
4747
4848
See :ref:`embind-val-guide` for other examples.
49-
49+
5050

5151
.. warning:: JavaScript values can't be shared across threads, so neither can ``val`` instances that bind them.
52-
52+
5353
For example, if you want to cache some JavaScript global as a ``val``, you need to retrieve and bind separate instances of that global by its name in each thread.
5454
The easiest way to do this is with a ``thread_local`` declaration:
5555

@@ -108,11 +108,11 @@ Guide material for this class can be found in :ref:`embind-val-guide`.
108108

109109
.. _val_as_handle:
110110
.. cpp:function:: EM_VAL as_handle() const
111-
111+
112112
Returns a raw handle representing this ``val``. This can be used for
113113
passing raw value handles to JavaScript and retrieving the values on the
114114
other side via ``Emval.toValue`` function. Example:
115-
115+
116116
.. code:: cpp
117117
118118
EM_JS(void, log_value, (EM_VAL val_handle), {
@@ -130,16 +130,16 @@ Guide material for this class can be found in :ref:`embind-val-guide`.
130130
from JavaScript, where the JavaScript side should wrap a value with
131131
``Emval.toHandle``, pass it to C++, and then C++ can use ``take_ownership``
132132
to convert it to a ``val`` instance. Example:
133-
133+
134134
.. code:: cpp
135-
135+
136136
EM_ASYNC_JS(EM_VAL, fetch_json_from_url, (const char *url_ptr), {
137137
var url = UTF8ToString(url);
138138
var response = await fetch(url);
139139
var json = await response.json();
140140
return Emval.toHandle(json);
141141
});
142-
142+
143143
val obj = val::take_ownership(fetch_json_from_url("https://httpbin.org/json"));
144144
std::string author = obj["slideshow"]["author"].as<std::string>();
145145
@@ -169,12 +169,12 @@ Guide material for this class can be found in :ref:`embind-val-guide`.
169169

170170

171171
.. cpp:function:: val(val&& v)
172-
172+
173173
Moves ownership of a value to a new ``val`` instance.
174174

175175

176176
.. cpp:function:: val(const val& v)
177-
177+
178178
Creates another reference to the same value behind the provided ``val`` instance.
179179

180180

@@ -184,7 +184,7 @@ Guide material for this class can be found in :ref:`embind-val-guide`.
184184

185185

186186
.. cpp:function:: val& operator=(val&& v)
187-
187+
188188
Removes a reference to the currently bound value and takes over the provided one.
189189

190190

@@ -217,7 +217,7 @@ Guide material for this class can be found in :ref:`embind-val-guide`.
217217

218218

219219
.. cpp:function:: val operator()(Args&&... args) const
220-
220+
221221
Assumes that current value is a function, and invokes it with provided arguments.
222222

223223

@@ -262,6 +262,42 @@ Guide material for this class can be found in :ref:`embind-val-guide`.
262262

263263
.. note:: This method requires :ref:`Asyncify` to be enabled.
264264

265+
.. cpp:function:: val operator co_await() const
266+
267+
The ``co_await`` operator allows awaiting JavaScript promises represented by ``val``.
268+
269+
It's compatible with any C++20 coroutines, but should be normally used inside
270+
a ``val``-returning coroutine which will also become a ``Promise``.
271+
272+
For example, it allows you to implement the equivalent of this JavaScript ``async``/``await`` function:
273+
274+
.. code:: javascript
275+
276+
async function foo() {
277+
const response = await fetch("http://url");
278+
const json = await response.json();
279+
return json;
280+
}
281+
282+
export { foo };
283+
284+
as a C++ coroutine:
285+
286+
.. code:: cpp
287+
288+
val foo() {
289+
val response = co_await val::global("fetch")(std::string("http://url"));
290+
val json = co_await response.call<val>("json");
291+
return json;
292+
}
293+
294+
EMSCRIPTEN_BINDINGS(module) {
295+
function("foo", &foo);
296+
}
297+
298+
Unlike the ``await()`` method, it doesn't need Asyncify as it uses native C++ coroutine transform.
299+
300+
:returns: A ``val`` representing the fulfilled value of this promise.
265301

266302
.. cpp:type: EMSCRIPTEN_SYMBOL(name)
267303

src/embind/emval.js

+29
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,35 @@ var LibraryEmVal = {
462462
var result = iterator.next();
463463
return result.done ? 0 : Emval.toHandle(result.value);
464464
},
465+
466+
_emval_coro_suspend__deps: ['$Emval', '_emval_coro_resume'],
467+
_emval_coro_suspend: (promiseHandle, awaiterPtr) => {
468+
Emval.toValue(promiseHandle).then(result => {
469+
__emval_coro_resume(awaiterPtr, Emval.toHandle(result));
470+
});
471+
},
472+
473+
_emval_coro_make_promise__deps: ['$Emval', '__cxa_rethrow'],
474+
_emval_coro_make_promise: (resolveHandlePtr, rejectHandlePtr) => {
475+
return Emval.toHandle(new Promise((resolve, reject) => {
476+
const rejectWithCurrentException = () => {
477+
try {
478+
// Use __cxa_rethrow which already has mechanism for generating
479+
// user-friendly error message and stacktrace from C++ exception
480+
// if EXCEPTION_STACK_TRACES is enabled and numeric exception
481+
// with metadata optimised out otherwise.
482+
___cxa_rethrow();
483+
} catch (e) {
484+
// But catch it so that it rejects the promise instead of throwing
485+
// in an unpredictable place during async execution.
486+
reject(e);
487+
}
488+
};
489+
490+
{{{ makeSetValue('resolveHandlePtr', '0', 'Emval.toHandle(resolve)', '*') }}};
491+
{{{ makeSetValue('rejectHandlePtr', '0', 'Emval.toHandle(rejectWithCurrentException)', '*') }}};
492+
}));
493+
},
465494
};
466495

467496
addToLibrary(LibraryEmVal);

src/library_sigs.js

+2
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,8 @@ sigs = {
339339
_emval_await__sig: 'pp',
340340
_emval_call__sig: 'dpppp',
341341
_emval_call_method__sig: 'dppppp',
342+
_emval_coro_make_promise__sig: 'ppp',
343+
_emval_coro_suspend__sig: 'vpp',
342344
_emval_decref__sig: 'vp',
343345
_emval_delete__sig: 'ipp',
344346
_emval_equals__sig: 'ipp',

system/include/emscripten/val.h

+90
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
#include <cstdint> // uintptr_t
1919
#include <vector>
2020
#include <type_traits>
21+
#if _LIBCPP_STD_VER >= 20
22+
#include <coroutine>
23+
#include <variant>
24+
#endif
2125

2226

2327
namespace emscripten {
@@ -110,6 +114,11 @@ EM_VAL _emval_await(EM_VAL promise);
110114
EM_VAL _emval_iter_begin(EM_VAL iterable);
111115
EM_VAL _emval_iter_next(EM_VAL iterator);
112116

117+
#if _LIBCPP_STD_VER >= 20
118+
void _emval_coro_suspend(EM_VAL promise, void* coro_ptr);
119+
EM_VAL _emval_coro_make_promise(EM_VAL *resolve, EM_VAL *reject);
120+
#endif
121+
113122
} // extern "C"
114123

115124
template<const char* address>
@@ -586,6 +595,10 @@ class val {
586595
// our iterators are sentinel-based range iterators; use nullptr as the end sentinel
587596
constexpr nullptr_t end() const { return nullptr; }
588597

598+
#if _LIBCPP_STD_VER >= 20
599+
struct promise_type;
600+
#endif
601+
589602
private:
590603
// takes ownership, assumes handle already incref'd and lives on the same thread
591604
explicit val(EM_VAL handle)
@@ -646,6 +659,83 @@ inline val::iterator val::begin() const {
646659
return iterator(*this);
647660
}
648661

662+
#if _LIBCPP_STD_VER >= 20
663+
namespace internal {
664+
struct val_awaiter {
665+
val_awaiter(val&& promise)
666+
: state(std::in_place_index<STATE_PROMISE>, std::move(promise)) {}
667+
668+
// just in case, ensure nobody moves / copies this type around
669+
val_awaiter(val_awaiter&&) = delete;
670+
671+
bool await_ready() { return false; }
672+
673+
void await_suspend(std::coroutine_handle<val::promise_type> handle) {
674+
internal::_emval_coro_suspend(std::get<STATE_PROMISE>(state).as_handle(), this);
675+
state.emplace<STATE_CORO>(handle);
676+
}
677+
678+
void resume_with(val&& result) {
679+
auto coro = std::move(std::get<STATE_CORO>(state));
680+
state.emplace<STATE_RESULT>(std::move(result));
681+
coro.resume();
682+
}
683+
684+
val await_resume() { return std::move(std::get<STATE_RESULT>(state)); }
685+
686+
private:
687+
// State machine holding awaiter's current state. One of:
688+
// - initially created with promise
689+
// - waiting with a given coroutine handle
690+
// - completed with a result
691+
std::variant<val, std::coroutine_handle<val::promise_type>, val> state;
692+
693+
constexpr static std::size_t STATE_PROMISE = 0;
694+
constexpr static std::size_t STATE_CORO = 1;
695+
constexpr static std::size_t STATE_RESULT = 2;
696+
};
697+
698+
extern "C" {
699+
void _emval_coro_resume(val_awaiter* awaiter, EM_VAL result) {
700+
awaiter->resume_with(val::take_ownership(result));
701+
}
702+
}
703+
}
704+
705+
// Note: this type can't be internal because coroutines look for the public `promise_type` member.
706+
struct val::promise_type {
707+
promise_type() {
708+
EM_VAL resolve_handle;
709+
EM_VAL reject_handle;
710+
promise = val(internal::_emval_coro_make_promise(&resolve_handle, &reject_handle));
711+
resolve = val(resolve_handle);
712+
reject_with_current_exception = val(reject_handle);
713+
}
714+
715+
val get_return_object() { return promise; }
716+
717+
auto initial_suspend() noexcept { return std::suspend_never{}; }
718+
719+
auto final_suspend() noexcept { return std::suspend_never{}; }
720+
721+
void unhandled_exception() {
722+
reject_with_current_exception();
723+
}
724+
725+
template<typename T>
726+
void return_value(T&& value) {
727+
resolve(std::forward<T>(value));
728+
}
729+
730+
internal::val_awaiter await_transform(val promise) {
731+
return {std::move(promise)};
732+
}
733+
734+
private:
735+
val promise, resolve, reject_with_current_exception;
736+
};
737+
#endif
738+
649739
// Declare a custom type that can be used in conjunction with
650740
// emscripten::register_type to emit custom TypeScript definitions for val
651741
// types.

test/embind/test_val_coro.cpp

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#include <emscripten.h>
2+
#include <emscripten/bind.h>
3+
#include <emscripten/val.h>
4+
#include <assert.h>
5+
#include <stdexcept>
6+
7+
using namespace emscripten;
8+
9+
EM_JS(EM_VAL, promise_sleep_impl, (int ms, int result), {
10+
let promise = new Promise(resolve => setTimeout(resolve, ms, result));
11+
let handle = Emval.toHandle(promise);
12+
// FIXME. See https://github.com/emscripten-core/emscripten/issues/16975.
13+
#if __wasm64__
14+
handle = BigInt(handle);
15+
#endif
16+
return handle;
17+
});
18+
19+
val promise_sleep(int ms, int result = 0) {
20+
return val::take_ownership(promise_sleep_impl(ms, result));
21+
}
22+
23+
val asyncCoro() {
24+
// check that just sleeping works
25+
co_await promise_sleep(1);
26+
// check that sleeping and receiving value works
27+
val v = co_await promise_sleep(1, 12);
28+
assert(v.as<int>() == 12);
29+
// check that returning value works (checked by JS in tests)
30+
co_return 34;
31+
}
32+
33+
val throwingCoro() {
34+
throw std::runtime_error("error in a coroutine");
35+
co_return 56;
36+
}
37+
38+
EMSCRIPTEN_BINDINGS(test_val_coro) {
39+
function("asyncCoro", asyncCoro);
40+
function("throwingCoro", throwingCoro);
41+
}

test/test_core.py

+11
Original file line numberDiff line numberDiff line change
@@ -7856,6 +7856,17 @@ def test_embind_val_cross_thread_deleted(self):
78567856
''')
78577857
self.do_runf('test_embind_val_cross_thread.cpp')
78587858

7859+
def test_embind_val_coro(self):
7860+
create_file('post.js', "Module.onRuntimeInitialized = () => Module.asyncCoro().then(console.log);")
7861+
self.emcc_args += ['-std=c++20', '--bind', '--post-js=post.js']
7862+
self.do_runf(test_file('embind/test_val_coro.cpp'), '34\n')
7863+
7864+
def test_embind_val_coro_caught(self):
7865+
self.set_setting('EXCEPTION_STACK_TRACES')
7866+
create_file('post.js', "Module.onRuntimeInitialized = () => Module.throwingCoro().then(console.log, err => console.error(`caught: ${err.stack}`));")
7867+
self.emcc_args += ['-std=c++20', '--bind', '--post-js=post.js', '-fexceptions']
7868+
self.do_runf(test_file('embind/test_val_coro.cpp'), 'caught: std::runtime_error: error in a coroutine\n')
7869+
78597870
def test_embind_dynamic_initialization(self):
78607871
self.emcc_args += ['-lembind']
78617872
self.do_run_in_out_file_test('embind/test_dynamic_initialization.cpp')

tools/maint/gen_sig_info.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ def main(args):
391391
'USE_SDL': 0,
392392
'MAX_WEBGL_VERSION': 0,
393393
'AUTO_JS_LIBRARIES': 0,
394-
'ASYNCIFY': 1}, cxx=True)
394+
'ASYNCIFY': 1}, cxx=True, extra_cflags=['-std=c++20'])
395395
extract_sig_info(sig_info, {'LEGACY_GL_EMULATION': 1}, ['-DGLES'])
396396
extract_sig_info(sig_info, {'USE_GLFW': 2, 'FULL_ES3': 1, 'MAX_WEBGL_VERSION': 2})
397397
extract_sig_info(sig_info, {'STANDALONE_WASM': 1})

0 commit comments

Comments
 (0)