Skip to content

Repository files navigation

some_executor

logo

Rust made the terrible mistake of not having an async executor in std. Worse, there is no trait for executors to implement, and no API for async code to expect. So everyone writes their code against one specific executor, and it's always tokio. But tokio has too many drawbacks to be the universal choice, and the other executors are too cumbersome to be practical. Async rust is stuck in limbo.

There are many proposals to fix this. This one's mine.

some_executor is a small crate that sits between the code that has futures and the code that runs them:

  • If you spawn futures, you get one obvious spawn that works on "some" executor: a generic argument, a stored trait object, the executor your caller is already running on, or a program-wide global. Whichever you pick, you get back an observer you can await, poll, detach, or drop to cancel.
  • If you write an executor, you implement one trait, and cancellation, task-locals, observers, priorities and hints are done for you. You get to focus on scheduling.
  • If you write async code, you get the featureset that (in my opinion) is table stakes for async rust: cancellation, task-locals, priorities, execution hints, task IDs and labels. They work the same on every executor, because the executor doesn't implement them; this crate does.

There is also a built-in fallback executor, so all of this works out of the box. It is not good, but it is always there; when you want a real one, see the reference executors below.

Quick start

use some_executor::SomeExecutor;
use some_executor::current_executor::current_executor;
use some_executor::observer::FinishedObservation;
use some_executor::task::{Configuration, Task};

# // Runs on the event loop on wasm32 and blocks natively.  It cannot be a plain
# // block_on: on the browser main thread that would starve the event loop the
# // fallback executor needs to start its worker.  See "Crossing from sync into async".
# wasm_lite_std::async_doctest!(async {
// A Task is a future plus a label and some scheduling metadata.
let task = Task::without_notifications(
    "add".to_string(),
    Configuration::default(),
    async { 2 + 2 },
);

// Spawn it on whatever executor is current: the one this task is running on,
// else the thread's, else the global one, else the built-in fallback.
let mut executor = current_executor();
let observer = executor.spawn(task);

// The observer is a Future.  Dropping it instead would cancel the task.
match observer.await {
    FinishedObservation::Ready(value) => assert_eq!(value, 4),
    FinishedObservation::Cancelled => unreachable!(),
}
# });

The rest of this page is organized by what you are trying to do.

Spawning tasks

Every path to spawning starts with a Task: your future, a String label, and a Configuration. Then pick how you want to get hold of an executor.

You want to... Use
Take an executor as a generic argument and monomorphize SomeExecutorExt (or StaticExecutorExt, LocalExecutorExt)
Store an executor in a struct, erasing its type DynExecutor, DynStaticExecutor, SomeLocalExecutor
Borrow the executor your caller is already running on current_executor, or Task::spawn_current for fire-and-forget
Use the executor pinned to this thread thread_executor, thread_static_executor, thread_local_executor
Spawn from nowhere in particular (a signal handler, say) global_executor

Whichever you use, spawn returns an Observer (usually a TypedObserver):

  • .await it to get a FinishedObservation: the task's output, or Cancelled.
  • Call observe to peek without waiting.
  • Call detach to let the task run to completion unobserved.
  • Drop it to request cancellation. Cancellation is cooperative: the task sees it through IS_CANCELLED, and executors may stop polling.

Which executor is "current"?

current_executor walks a fixed hierarchy and always returns something:

  1. The executor the current task was spawned on (TASK_EXECUTOR).
  2. The executor set for this thread with set_thread_executor.
  3. The program-wide executor set with set_global_executor.
  4. The built-in fallback executor.

The fallback executor exists so that libraries built on this crate work with zero configuration. It prints a warning when used, because it is not production quality; install a real one (see reference executors) with set_global_executor or set_thread_executor. Set SOME_EXECUTOR_BUILTIN_SHOULD_PANIC=1 to make the fallback panic instead, which is a good way to find places you forgot to do that.

Three flavors of executor

Executors differ in what futures they can accept. This crate models the three cases that come up in practice, each with a generic (*Ext) trait for static dispatch and an object-safe base trait for dynamic dispatch:

Futures are... Object-safe trait Generic trait Typical executor
Send + 'static SomeExecutor (see DynExecutor) SomeExecutorExt Thread pools; anything that moves work
'static, not Send SomeStaticExecutor (see DynStaticExecutor) StaticExecutorExt Main-thread and single-threaded executors; wasm
Neither ('a and !Send) SomeLocalExecutor LocalExecutorExt Executors scoped to a stack frame

The Send and static flavors are cloneable, like a channel sender, and can be discovered through current_executor and friends. Local executors are borrowed and cannot be cloned; the SomeLocalExecutor docs explain the lifetime parameter and why. If you don't know which you need, start with SomeExecutor.

Configuring a task

Configuration (build one with ConfigurationBuilder) carries three things an executor may use, and none of them changes what your future does:

  • A Hint: is this task expected to be I/O-bound, CPU-bound, or unknown?
  • A Priority, for executors that schedule by priority.
  • A poll_after Instant, before which the executor must not poll the task.

Every task also has a label and a TaskID, both visible from inside the task and from its observer, which makes tracing and logging across executors practical.

Reference executors

These crates implement the traits above and are the ones I actually use:

  • some_global_executor is a thread-pool executor for Send tasks. It runs on OS threads natively and on web workers on wasm32, and can install itself as the thread or global executor. If you want one executor to replace the built-in fallback, this is it.
  • some_local_executor is a local executor that runs its tasks on the current thread and can also receive tasks from other threads.
  • test_executors provides toy executors good enough for unit tests.

Crossing from sync into async

Every program crosses from sync into async exactly once: in fn main, in a test, in a CLI tool, at an FFI callback. Which API you want depends on whether you are choosing an executor or using one.

Choosing is ExecutorMain. It is the trait spelling of "construct the chosen backend, install it, run this future", so that an #[some_executor::main(SomeBackend)] attribute can expand to something that compiles against a backend the macro has never heard of. It returns () rather than the future's output, which is what makes it implementable on the wasm32 main thread as well as natively.

Using is SomeExecutor::block_on. Given an executor -- including the one current_executor hands you -- it drives a future to completion and returns its output, blocking the calling thread:

use some_executor::SomeExecutor;
use some_executor::current_executor::current_executor;

# // A doctest runs on the browser main thread, which cannot block; on wasm32 this
# // runs the body on a worker, where it can.  Natively it just calls the closure.
# wasm_lite_std::worker_doctest!(|| {
let mut executor = current_executor();
assert_eq!(executor.block_on(async { 2 + 2 }), 4);
# });

Underneath both is the free block_on function, which polls a future in place on the calling thread with no executor involved. Because the future never leaves the thread, it needs neither Send nor 'static and may borrow from the stack -- unlike spawning:

# wasm_lite_std::worker_doctest!(|| {
let name = String::from("world");
let greeting = some_executor::block_on(async { format!("hello {}", name.as_str()) });
assert_eq!(greeting, "hello world");
# });

Blocking is not universally available, which is why the entry point and the primitive are separate. It works whenever the blocking thread can keep driving every scheduler the future depends on: another thread is doing the work, or the executor owns its own loop and runs it here. It cannot work when the wakeups come from a scheduler this thread can only run by unwinding -- the browser main thread, where the JavaScript event loop delivers every timer, promise and worker message. block_on panics there with an explanation rather than hanging the tab; a wasm32 worker has no such problem, and ExecutorMain is the portable choice for an entry point.

Writing async code

Mostly, write the code you want to write. Nothing here requires you to know which executor you are running on. What you get on top:

  • task_local! declares task-local storage, comparable to thread_local! or tokio's task_local!, with scope, get, set and immutable (static const) variants.
  • Built-in task-locals describe the current task: TASK_ID, TASK_LABEL, TASK_PRIORITY, and IS_CANCELLED.
  • IS_CANCELLED lets long-running work notice a cancellation request and return early.
  • TASK_EXECUTOR and TASK_STATIC_EXECUTOR hold the executor a task was spawned on, which is how current_executor and Task::spawn_current find it.
use some_executor::task_local;
use some_executor::task::{TASK_LABEL, IS_CANCELLED};

task_local! {
    static REQUEST_ID: u64;
}

async fn handle() {
    let label = TASK_LABEL.with(|l| l.cloned());
    let request = REQUEST_ID.get();
    // Do a unit of work, then check for cancellation before the next one.
    if IS_CANCELLED.with(|c| c.map(|c| c.is_cancelled()).unwrap_or(false)) {
        return;
    }
    let _ = (label, request);
}

Implementing an executor

An executor is anything that accepts a Task and polls it. In outline:

  1. Implement SomeExecutor (for Send futures), SomeStaticExecutor (for 'static, !Send futures) and/or SomeLocalExecutor (for borrowed futures). Add the matching *Ext marker trait if your executor is Clone.
  2. In your spawn, call Task::spawn (or spawn_static / spawn_local) with &mut self. You get back a SpawnedTask to schedule and an observer to hand to the caller.
  3. Poll the spawned task. Its poll takes an executor context so that current_executor works inside the task; the spawned task itself installs task-locals, reports completion to the observer, and stops early on cancellation.
  4. Respect poll_after: do not poll before that instant. Sleep, defer, re-queue, whatever fits your design. This is the main gotcha.
  5. Optionally implement ExecutorNotified to be told when a task's observer requests cancellation, so you can drop it early instead of discovering that on the next poll.
  6. Optionally register yourself with set_thread_executor / set_global_executor (or the static and local equivalents) so that current_executor finds you.
  7. If your executor runs tasks on the calling thread, override SomeExecutor::block_on_objsafe. The default parks the caller, which is correct for a thread pool and a deadlock for a current-thread executor; run your own polling loop until the future resolves instead. Implement ExecutorMain too, so #[some_executor::main] can name you.

For static executors, static_support provides OwnedSomeStaticExecutorErasingNotifier to erase your notifier type into the common DynStaticExecutor shape. For the object-safe methods (spawn_objsafe and friends), the ObjSafe* and Boxed* type aliases at the crate root spell out the erased types so you don't have to.

Compared with executor-trait

One way to understand this crate is as an alternative to executor-trait. I like it a lot; here is why I made this instead:

  1. To support futures whose output isn't ().
  2. To avoid boxing futures where it isn't necessary.
  3. To carry hints and priorities to the executor.
  4. To provide task-locals and the other features async code actually needs.
  5. To support cancellation much more robustly.

Philosophically, executor-trait ships the lowest common denominator that every executor can support. This crate ships the highest common denominator that all async code can use, together with polyfills and fallbacks so every executor can offer it, even ones that don't support a feature natively. It is straightforward to implement either crate's API in terms of the other, so the two can be used together.

wasm32

wasm32-unknown-unknown is a first-class target, with and without atomics. Timing uses Instant, which is std::time::Instant natively and a web-clock on wasm, and the fallback executor schedules through the browser event loop.

Status

This interface is unstable and may change.

About

A trait for libraries that abstract over any executor

Topics

Resources

Stars

2 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages