Repository navigation
rt: add EventLoop and LocalEventLoop driven by a host event loop - #8479
guybedford wants to merge 4 commits into
Conversation
16947a6 to
5315798
Compare
…untime On wasm32-unknown-emscripten an async export marked `tokio` is scheduled as a root on Tokio's `EventLoopRuntime` (tokio-rs/tokio#8479) instead of the wasm-bindgen executor, bridging its outcome to the returned Promise, so `tokio::spawn`, timers and I/O work inside it without JSPI. The runtime lowers only its wait to the host event loop; `schedule` drives synchronously when called outside a runtime context and queues when re-entered from a task. By default exports share the thread's ambient runtime, built on first use or installed with `wasm_bindgen_futures::tokio::try_set_ambient`. `tokio = "isolated"` builds a fresh runtime per invocation, torn down with native drop semantics once the root settles, for multiplexed hosts where one invocation's event loop must not perform I/O on behalf of another's context. The support lives in wasm-bindgen-futures behind a `tokio` feature and requires `--cfg tokio_unstable`.
…untime On wasm32-unknown-emscripten an async export marked `tokio` is scheduled as a root on Tokio's `EventLoopRuntime` (tokio-rs/tokio#8479) instead of the wasm-bindgen executor, bridging its outcome to the returned Promise, so `tokio::spawn`, timers and I/O work inside it without JSPI. The runtime lowers only its wait to the host event loop; `schedule` drives synchronously when called outside a runtime context and queues when re-entered from a task. By default exports share the thread's ambient runtime, built on first use or installed with `wasm_bindgen_futures::tokio::try_set_ambient`. `tokio = "isolated"` builds a fresh runtime per invocation, torn down with native drop semantics once the root settles, for multiplexed hosts where one invocation's event loop must not perform I/O on behalf of another's context. The support lives in wasm-bindgen-futures behind a `tokio` feature and requires `--cfg tokio_unstable`.
…untime On wasm32-unknown-emscripten an async export marked `tokio` is scheduled as a root on Tokio's `EventLoopRuntime` (tokio-rs/tokio#8479) instead of the wasm-bindgen executor, bridging its outcome to the returned Promise, so `tokio::spawn`, timers and I/O work inside it without JSPI. The runtime lowers only its wait to the host event loop; `schedule` drives synchronously when called outside a runtime context and queues when re-entered from a task. By default exports share the thread's ambient runtime, built on first use or installed with `wasm_bindgen_futures::tokio::try_set_ambient`. `tokio = "isolated"` builds a fresh runtime per invocation, torn down with native drop semantics once the root settles, for multiplexed hosts where one invocation's event loop must not perform I/O on behalf of another's context. The support lives in wasm-bindgen-futures behind a `tokio` feature and requires `--cfg tokio_unstable`.
`--emscripten --tokio` schedules each handler on a Tokio event-loop runtime per invocation (tokio-rs/tokio#8479), whose wait is the host event loop, so plain async handlers use tokio::net and tokio::time with no stack switching. `--emscripten --tokio=jspi` is the previous behaviour: JSPI exports on their own fibers blocking on a runtime. The mode reaches the macros as cfg(worker_tokio = ...). The epoll listener backport now delivers readiness through a macrotask: workerd drains microtasks synchronously inside builtin module loads, so a microtask delivery ran one runtime's drive under another's connect().
Darksonn
left a comment
There was a problem hiding this comment.
Would it be possible to split this into two PRs so that there's just one PR that adds a generic EventLoop utility without any emscripten specific code?
5315798 to
e2dc44c
Compare
EventLoopRuntime for wasm32-unknown-emscriptenEventLoop and LocalEventLoop driven by a host event loop
e2dc44c to
2148e3e
Compare
I've gone ahead and updated this PR to just create the new Since this no longer depends on the Emscripten PRs, this is now directly on main and no longer a draft PR. I am posting the follow-on Emscripten implementation based to this now. |
73ff4d0 to
2d6f31c
Compare
…untime On wasm32-unknown-emscripten an async export marked `tokio` is scheduled as a root on Tokio's `EventLoopRuntime` (tokio-rs/tokio#8479) instead of the wasm-bindgen executor, bridging its outcome to the returned Promise, so `tokio::spawn`, timers and I/O work inside it without JSPI. The runtime lowers only its wait to the host event loop; `schedule` drives synchronously when called outside a runtime context and queues when re-entered from a task. By default exports share the thread's ambient runtime, built on first use or installed with `wasm_bindgen_futures::tokio::try_set_ambient`. `tokio = "isolated"` builds a fresh runtime per invocation, torn down with native drop semantics once the root settles, for multiplexed hosts where one invocation's event loop must not perform I/O on behalf of another's context. The support lives in wasm-bindgen-futures behind a `tokio` feature and requires `--cfg tokio_unstable`.
782b76f to
6e9537b
Compare
|
Thank you @mcollina for trying this native integration out in libuv for Node.js - this is really valuable implementation feedback to have at such an early stage. I was initially a bit concerned about Windows support for your suggestion, but was able to verify that this works under Windows IOCP with one change to Mio in tokio-rs/mio#2023. With this, I've refactored the approach to expose the handle shape you proposed:
This now provides all of the necessary primitives to integrate into a libuv event loop for Node.js addons - please do test it out and share any feedback further. In terms of performance, note you can tune the |
|
Thanks @guybedford, this works. I tested 6e9537b on Linux with a prototype inside our Node.js addon, and the new API covers everything we need:
I haven't tested macOS or Windows yet. Happy to keep testing as this evolves. |
|
Sorry for being so long to review this. Here is an API design I sketched a while ago, and I think it'd be useful to compare the designs for differences. impl Builder {
/// Current-thread only; errors otherwise.
pub fn build_event_loop(&mut self) -> io::Result<EventLoop>;
pub fn build_local_event_loop(&mut self) -> io::Result<LocalEventLoop>;
/// Invoked when the loop transitions from "nothing to do" to "has work".
///
/// Called for remote wakeups of the runtime, but not for IO events on
/// a runtime with IO enabled.
pub fn on_work_available<F>(&mut self, f: F) -> &mut Self
where
F: Fn() + Send + Sync + 'static;
/// Give the event loop an `event_fd` even when built without an I/O
/// driver, by allocating a standalone eventfd for wakeups.
#[cfg(unix)]
pub fn enable_event_fd(&mut self) -> &mut Self;
}
impl EventLoop {
pub fn handle(&self) -> &Handle;
pub fn enter(&self) -> EnterGuard<'_>;
/// Poll the I/O driver without blocking, then poll up to `max_tasks`
/// ready tasks. Returns the number of tasks polled.
///
/// `max_tasks == 0` drains the driver without running any tasks.
/// Panics if called from within a task owned by this loop.
pub fn tick(&mut self, max_tasks: usize) -> usize;
/// How long the caller may sleep before calling `tick` again.
///
/// * `Some(ZERO)` — work is ready now; call `tick` again.
/// * `Some(d)` — nothing until a timer fires in `d`, or the loop is woken.
/// * `None` — nothing scheduled; wait indefinitely for a wake.
pub fn next_sleep(&self) -> Option<Duration>;
/// A descriptor that becomes readable whenever the loop has work.
///
/// Might be the epoll fd, an io-uring fd, an eventfd or other similar fd.
///
/// The fd becomes ready any time there is runnable work, except for
/// timers, so sleeping on this fd should be done using `next_sleep` as
/// the timeout.
#[cfg(unix)]
pub fn event_fd(&self) -> Option<BorrowedFd<'_>>;
/// Block until there is work, a timer expires, `timeout` elapses, or
/// `Unparker::unpark` is called. May return spuriously.
///
/// Sleeps for `min(timeout, next_sleep())`. Does not tick.
/// Panics if called from within a task owned by this loop.
///
/// On wasm this suspends the current JSPI activation rather than blocking
/// the thread.
pub fn park(&mut self, timeout: Option<Duration>);
/// When the `unpark()` method on this `Unparker` is called, a call to `park`
/// will return.
pub fn unparker(&self) -> Unparker;
pub fn shutdown_background(self);
pub fn shutdown_timeout(self, duration: Duration);
}
impl LocalEventLoop {
// ... same, plus:
pub fn spawn_local<F>(&self, future: F) -> JoinHandle<F::Output>
where
F: Future + 'static;
} |
|
In particular, it looks like your design implies that the event loop always has an fd available for readiness, which causes complications on windows. I'm not sure we need to always provide an fd for runtimes without IO. |
|
@Darksonn wow this is a really nicely worked through design as well. Most things seem to adapt pretty straightforwardly in the above. A couple of the main differences AFAICT are:
For Windows support - for feature parity I think we would still want the IOCP handle exposed. The purist in me would prefer that mirroring, but that would require tokio-rs/mio#2023. If we did have that Windows support, then FWIW Emscripten has landed epoll and epoll callbacks now such that a full IO event_fd would be enough of a primitive allowing us to even skip #8484 entirely and move that out to userland runtime integration instead. So the question seems to me really one of scope, and including if tokio-rs/mio#2023 lands or not. |
|
I think it makes sense to avoid the dependency on tokio-rs/mio#2023 for this PR, and consider it as a follow-up. You can file a feature request issue to track it. I think having |
|
Note CI issues are fixed if you rebase on master. |
6e9537b to
7c52b7b
Compare
LocalEventLoop driven by a host event loopEventLoop and LocalEventLoop driven by a host event loop
c74a99f to
dae44b5
Compare
b753347 to
1d2a013
Compare
|
@Darksonn I've updated this PR now to your design now, which I've verified is compatible with the previous one. Ready for review. |
Master-Norna
left a comment
There was a problem hiding this comment.
Thanks for working through the host-loop API. I focused on the callback notification protocol and found the two independent issues below. The upstream rt_event_loop suite passes locally (23 tests on Windows, 35 on Linux), as do the Windows runtime/timer regression suites I ran (200 tests); those passing tests do not exercise these notification interleavings. These comments are not a review of the entire API or of the macOS reactor path.
| pub(crate) fn has_injected_work(&self) -> bool { | ||
| !self.shared.inject.is_empty() |
There was a problem hiding this comment.
The lost-wake proof in signal.rs relies on the host taking the inject queue mutex after Signal::end, but this path calls Inject::is_empty, which only loads Shared::len with Acquire. The existing Loom test instead reads an always-locked queue, so it supplies synchronization that next_sleep does not have.
I extended the model to use the production queue's Release length updates, Acquire empty checks, and no-I/O parker notification. Loom finds a wake neither consumed by the tick, observed by next_sleep, nor delivered to the callback. I also reproduced it in a crate-internal probe using the real Signal, Inject, and ParkThread, not just the simplified queue model. Taking the queue mutex for the host check eliminates this counterexample in the isolated model. This matters for a callback-only host sleeping on its own queue: the internal parker notification cannot wake that host.
Could we give the host check a real synchronization edge, or change the signal handshake so it supplies one, and make the Loom model exercise that production protocol?
There was a problem hiding this comment.
Yes the claim in the comment of signal.rs that the host takes the inject lock in next_sleep was incorrect here. Inject::is_empty as a lock-free Acquire resulted in no common ordering, and this was missed in Loom modelling the queue as a Mutex.
This is now managed through ordered pending.swap(false, AcqRel) in end() so that every write to pending is now an RMW with on modification order:
- if
end's swap reads a wakeup'strue, the host sees that wakeup's push andnext_sleepreports it - if
wake's swap readsend'sfalse, it seesbusy = falseand runs the callback.
begin() also has a pending swap where a plain store would be correct, since Loom reports a false positive here pending tokio-rs/loom#282.
New tests are included for these cases.
| pub(crate) fn end(&self) { | ||
| self.pending.store(false, Release); | ||
| self.busy.store(false, Release); |
There was a problem hiding this comment.
There is also a separate coalescing race, even with a locked host queue check:
- Remote wake A queues a task, then pauses before
signal.wake(). - The tick consumes A and reaches
end()'spending.store(false). - A resumes:
pending.swap(true)returns false, butbusyis still true, so no callback runs. end()clearsbusy; the host finds no remaining work and may sleep.- New wake B sees
pending == trueand returns without notifying the host.
The single-wake Loom assertion still passes because A was consumed; extending it across the next idle period catches B's missing notification. I confirmed this against the unchanged Signal, independently of the lock-free queue-check issue above. Could we make the busy-to-idle transition and pending reset one coherent protocol, and add a two-period regression model?
There was a problem hiding this comment.
This is now resolved with end() clearing busy first, then handling pending so that a wakeup landing between the two can no longer be left pending without having seen the host idle.
A new test, wake_after_tick_is_never_lost covers this "two-period" model, which fails before the fix.
A nearer timer registered from within the `current_thread` scheduler's context unparked the driver as if from another thread. That thread holds the driver, so it cannot be parked while registering, and when it parks the time driver reads the wheel's next expiration under the lock, so the unpark only ever produced a spurious wakeup: one waker syscall on registration and one empty driver turn after. The multi-thread scheduler is unchanged: the registering worker may not be the one holding the driver.
A `LocalRuntime` that never parks: it exposes the reactor's file descriptor and the time until its soonest timer, and the host calls `drive` when either fires, for one zero-timeout driver turn and one batch of tasks.
Adds `EventLoop` alongside `LocalEventLoop`, with `tick(max_tasks)`, `next_sleep`, `event_fd`, `park(timeout)`, `unparker`, `enter` and the shutdown methods, plus `Builder::on_work_available` and `Builder::enable_event_fd`. Drops the Windows completion port handle and with it the unreleased mio dependency; that is a follow-up.
The host's notification protocol now orders through swaps on `pending`, since `next_sleep` checks the inject queue without its lock. A nearer remote timer after a cancel is announced, as the host's wait is recorded as `next_wake`. An unpark is reported by `next_sleep` like any other wakeup. Loom models the production protocol end to end.
Motivation
This adds an event loop form of the
current_threadruntime that exposes what a host needs to integrate with its own event loop.This is needed for any programs embedded when an event loop already owns the thread, for example libuv in a Node.js addon, KJ, a GUI toolkit, a JavaScript host under WebAssembly. In these environments the runtime must never block so the host needs to wait on the runtime's behalf and call it back when it has work.
Solution
Under
tokio_unstable:EventLoopisSend + Syncover aRuntime,LocalEventLoopis!Sendover aLocalRuntime.Hosts can decide on the type of integration - a descriptor-only host is correct without ever reading
next_sleepfor readiness, since the descriptor stays level-readable from a wakeup until a tick drains it. Similarly, a callback host ticks untilnext_sleepis not zero before waiting, since wakeups during a tick are reported there.All remote wake paths - schedule, the block_on waker, timer re-registration, Unparker - go through the driver handle's unpark, which is where the host notification lives as a Signal on the driver handle. It only needs to notify once per idle period, so wakes are coalesced, and while the loop is being ticked or parked the work a wake carries will be found by the tick itself, so the callback is held back for that window. That holding-back is what creates a lost-wake race (a stale busy read on one side, an empty inject queue on the other). Both sides take the inject queue lock, so its order rules the race out, not the atomics - see signal.rs.
loom_current_thread::event_loopmodels it along with an unpark racing a tick (R-loom-current-thread).The first commit is independent of the event loop and affects every
current_threadruntime, so it is split out for separate consideration: While testing the event loop, every in-runtime sleep showed up as a spurious wakeup: registering a timer from within the scheduler's own context was unparking the driver. But that thread holds the driver, so it can't be parked while registering, and when it does park the time driver reads the wheel's next expiration under the lock - the unpark was never doing anything except costing a waker syscall and an empty driver turn. Multi-thread is unchanged since the registering worker there may not hold the driver.Windows IOCP support is left as a possible follow-on, if we get handle support from Mio via tokio-rs/mio#2023. Without that, and for hosts without IO that don't want to enable event_fd,
on_work_availableis supported perfectly fine for wakeups andparkfor I/O.Tests
rt_event_loopdrives both loops as a host would: overpoll(2)onevent_fdwithnext_sleepas the timeout, overpark, and over the callback. It pins when the host is and isn't notified (spawns, cross-thread and cross-loop wakes, I/O readiness, nearer timers, timers as timeouts only), what a tick runs (max_tasksbatches, leftovers vianext_sleep),park's return conditions, the standalone descriptor, the callback's once-per-idle-period rule, and the usual lifecycle cases (drop, shutdown, panics, nested ticks, auto traits).loom_current_thread::event_loopmodels the wakeup protocol.