Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API design #1

Open
jkarneges opened this issue Jan 28, 2024 · 0 comments
Open

API design #1

jkarneges opened this issue Jan 28, 2024 · 0 comments

Comments

@jkarneges
Copy link
Owner

jkarneges commented Jan 28, 2024

The goal of the waker-waiter crate is to implement context reactor hook to enable async executor/reactor interoperability, for later inclusion in std. However, ideally we could reach community consensus on the API before beginning such an inclusion process. Please weigh in here!

Goals

  • Provide a way for an executor to run special waiting/parking logic for when its managed futures have returned Poll::Pending, where such logic is dynamically supplied by the futures to the executor via Context.
  • Enable async runtimes to separate their executor and reactor parts, to allow developers to mix them. For example, using the executor from smol to run tasks containing network objects from tokio.
  • Enable the possibility of a universal block_on function able to run any future. Such a function could, for example, execute tasks containing network objects from tokio, without being aware of tokio and without any prior configuration.
  • Enable the possibility of a universal async fn main, which could simply wrap block_on.

TopLevelPoller

I propose introducing a TopLevelPoller trait, for which some kind of waitability mechanism (here a WakerWaiter, defined later) can be applied:

pub trait TopLevelPoller {
    fn set_waiter(&mut self, waiter: &WakerWaiter) -> Result<(), SetWaiterError>;
}

A value implementing this trait would then be exposed to futures via Context, with an API like:

impl<'a> Context<'a> {
    // add a poller to the context
    pub fn with_top_level_poller(self, poller: &mut dyn TopLevelPoller) -> Self {
        // ...
    }

    // get the poller, if any
    pub fn top_level_poller(&mut self) -> Option<&mut dyn TopLevelPoller> {
        // ...
    }
}

Outside of std, this could be simulated via an extension:

pub trait ContextExt<'a> {
    fn with_top_level_poller(self, poller: &mut dyn TopLevelPoller) -> Self;
    fn top_level_poller(&mut self) -> Option<&mut dyn TopLevelPoller>;
}

impl<'a> ContextExt<'a> for Context<'a> {
    fn with_top_level_poller(self, poller: &mut dyn TopLevelPoller) -> Self {
        // ...
    }

    fn top_level_poller(&mut self) -> Option<&mut dyn TopLevelPoller> {
        // ...
    }
}

This way, users could write cx.top_level_poller(), for example, as if the method existed on Context (under the hood, this is achievable using the nightly waker_getters feature).

Specifically, I propose the extension contain the following methods:

pub trait ContextExt<'a> {
    fn with_top_level_poller<'b: 'a>(
        self,
        poller: &'b mut dyn TopLevelPoller,
        scratch: &'a mut MaybeUninit<Waker>,
    ) -> Self;

    fn top_level_poller(&mut self) -> Option<&mut dyn TopLevelPoller>;

    fn with_waker<'b>(&'b mut self, waker: &'b Waker) -> Context<'b>;
}

The with_top_level_poller method definition is slightly different than how we'd want it in std, due to the need for the scratch argument. This is needed because the method internally creates a special Waker (capable of holding the Context's original Waker, plus the TopLevelPoller) to be borrowed by the returned Context. Usage looks like this:

let mut scratch = MaybeUninit::uninit();
let mut cx = Context::from_waker(&waker).with_top_level_poller(&mut poller, &mut scratch);

The top_level_poller method checks the waker vtable to see if it's our special waker, in order to find the poller and return it.

The with_waker method provides a way to create a new context that inherits the poller of an existing context, in case a new context needs to be made with a different waker. For example, a "select" implementation may want to provide individual wakers to each future it manages, and it would need a way to do this without the poller getting lost.

Note: The LocalWaker proposal suggests introducing ContextBuilder, for constructing a context out of parts. If that lands, we may want to change how the inheritance works, for example by doing it through the builder with some method like ContextBuilder::from(cx: &mut Context).

A caveat of wrapping the original waker with our special waker is Waker::will_wake won't return true when the inner waker would match. Technically this is legal, as the method is best-effort. However, ideally this kind of check would be possible, which could be done with separate method:

pub trait WakerExt {
    fn will_wake2(&self, other: &Waker) -> bool;
}

impl WakerExt for Waker {
    fn will_wake2(&self, other: &Waker) -> bool {
        // ... if self is our special waker, then call inner.will_wake(other), else self.will_wake(other) ...
    }
}

WakerWaiter

To support no_std environments, the WakerWaiter API uses a raw vtable and safe wrapper struct, similar to Waker:

pub struct RawWaiterVTable {
    clone: unsafe fn(*const ()) -> RawWaiter,
    wait: unsafe fn(*const ()),
    canceler: unsafe fn(*const ()) -> Option<RawCanceler>,
    drop: unsafe fn(*const ()),
}

pub struct RawWaiter {
    data: *const (),
    vtable: &'static RawWaiterVTable,
}

pub struct WakerWaiter {
    inner: RawWaiter,
}

impl WakerWaiter {
    pub fn wait(&self) {
        unsafe { (self.inner.vtable.wait)(self.inner.data) }
    }

    pub fn canceler(&self) -> WakerWaiterCanceler {
        let raw = unsafe { (self.inner.vtable.canceler)(self.inner.data).unwrap() };

        WakerWaiterCanceler { inner: raw }
    }
}

impl Clone for WakerWaiter {
    fn clone(&self) -> Self {
        Self {
            inner: unsafe { (self.inner.vtable.clone)(self.inner.data) },
        }
    }
}

impl Drop for WakerWaiter {
    fn drop(&mut self) {
        unsafe { (self.inner.vtable.drop)(self.inner.data) }
    }
}

unsafe impl Send for WakerWaiter {}
unsafe impl Sync for WakerWaiter {}

As a convenience, we can provide an Arc-based trait, similar to std::task::Wake:

pub trait WakerWait {
    fn wait(self: &Arc<Self>);
    fn canceler(self: &Arc<Self>) -> WakerWaiterCanceler;
}

impl<W: WakerWait + Send + Sync + 'static> From<Arc<W>> for WakerWaiter {
    fn from(waiter: Arc<W>) -> Self {
        // ...
    }
}

This WakerWaiter API enables a future to provide the necessary functions to block and wait for wakers to be triggered, and to unblock such waiting via a "canceler" object.

Local waiters

To support single-threaded reactors, there should be an alternative to WakerWaiter that is !Send. Call it LocalWakerWaiter. Single-threaded executors would work with either WakerWaiter or LocalWakerWaiter, but multi-threaded executors would only work with WakerWaiter.

TopLevelPoller could then have two setters:

pub trait TopLevelPoller {
    fn set_waiter(&mut self, waiter: &WakerWaiter) -> Result<(), SetWaiterError>;
    fn set_local_waiter(&mut self, waiter: &LocalWakerWaiter) -> Result<(), SetLocalWaiterError>;
}

Only one waiter would be settable at a time though. It wouldn't make any sense to provide multiple waiters to the same poller.

Cancellation

To cancel a wait, the poller can obtain a WakerWaiterCanceler from the WakerWaiter in advance, and use it to cancel a wait.

The canceler would contain a single method:

impl WakerWaiterCanceler {
    pub fn cancel(&self) { ... }
}

The reason for having cancellation go through a separate object, as opposed to offering a cancel() method directly on WakerWaiter and LocalWakerWaiter, is to allow the objects to have different Send-ness. Notably, WakerWaiterCanceler must be Send (cancellation must always happen from a separate thread), and separating it out allows LocalWakerWaiter to be !Send.

That said, LocalWakerWaiter also shouldn't be required to offer a canceler, since implementing cancellation relies on thread synchronization. To allow for that, its API should deviate slightly from WakerWaiter by having its canceler() method return an Option instead.

Design notes

  • The waiting mechanism (here WakerWaiter) is not applied directly to the Context, and instead it is applied to a new entity (TopLevelPoller), in order to add clarity that the mechanism transcends the context. This is especially important considering there could be levels of contexts. In other words, the mechanism is not applied to a context, but rather the nearest context can be used to access an entity for which the mechanism can be applied. A related benefit of this separation isTopLevelPoller's API could be expanded later on without expanding Context. I might even go as far to suggest that other context extensions ought to work similarly (e.g. Context offering a Spawner object instead of directly offering a spawn method).
  • The names TopLevelPoller and WakerWaiter are given in the spirit of minimizing interface surface area. There may be alternative workable names, like RootPoller. However, the names Executor and Reactor are purposely avoided, as they imply a lot more than what is needed by this API.
  • Applying a waiter to the poller (or determining if one should be applied) should be very cheap, similar to what Waker enables with Waker::will_wake. This could be done by having WakerWaiter::eq compare the inner pointers/vtables.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant