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
spawnthat 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.
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.
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):
.awaitit to get aFinishedObservation: the task's output, orCancelled.- Call
observeto peek without waiting. - Call
detachto 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.
current_executor walks a fixed hierarchy and always returns something:
- The executor the current task was spawned on (
TASK_EXECUTOR). - The executor set for this thread with
set_thread_executor. - The program-wide executor set with
set_global_executor. - 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.
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.
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_afterInstant, 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.
These crates implement the traits above and are the ones I actually use:
- some_global_executor is a
thread-pool executor for
Sendtasks. 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.
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.
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 tothread_local!or tokio'stask_local!, withscope,get,setand immutable (static const) variants.- Built-in task-locals describe the current task:
TASK_ID,TASK_LABEL,TASK_PRIORITY, andIS_CANCELLED. IS_CANCELLEDlets long-running work notice a cancellation request and return early.TASK_EXECUTORandTASK_STATIC_EXECUTORhold the executor a task was spawned on, which is howcurrent_executorandTask::spawn_currentfind 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);
}
An executor is anything that accepts a Task and polls it. In outline:
- Implement
SomeExecutor(forSendfutures),SomeStaticExecutor(for'static,!Sendfutures) and/orSomeLocalExecutor(for borrowed futures). Add the matching*Extmarker trait if your executor isClone. - In your
spawn, callTask::spawn(orspawn_static/spawn_local) with&mut self. You get back aSpawnedTaskto schedule and an observer to hand to the caller. - Poll the spawned task. Its
polltakes an executor context so thatcurrent_executorworks inside the task; the spawned task itself installs task-locals, reports completion to the observer, and stops early on cancellation. - Respect
poll_after: do not poll before that instant. Sleep, defer, re-queue, whatever fits your design. This is the main gotcha. - Optionally implement
ExecutorNotifiedto be told when a task's observer requests cancellation, so you can drop it early instead of discovering that on the next poll. - Optionally register yourself with
set_thread_executor/set_global_executor(or the static and local equivalents) so thatcurrent_executorfinds you. - 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. ImplementExecutorMaintoo, 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.
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:
- To support futures whose output isn't
(). - To avoid boxing futures where it isn't necessary.
- To carry hints and priorities to the executor.
- To provide task-locals and the other features async code actually needs.
- 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-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.
This interface is unstable and may change.
