Spawn and await
nuclei::spawn takes a future that is Send + 'static and returns a Task<T>. A task is a future itself: awaiting it gives the task's output. If the task panics, awaiting it resumes the panic.
#[nuclei::main]
async fn main() {
// Each task is placed on a shard, and there it stays.
let tasks: Vec<_> = (0..8u64)
.map(|n| nuclei::spawn(async move { n * n }))
.collect();
let mut total = 0;
for task in tasks {
total += task.await;
}
assert_eq!(total, 140);
}Dropping, detaching, cancelling
A Task owns its task: dropping it cancels the task. To let a task run on with nobody waiting for it, call detach.
use std::time::Duration;
use nuclei::time::sleep;
#[nuclei::main]
async fn main() {
// Dropping a Task cancels it: this one never prints.
let dropped = nuclei::spawn(async {
sleep(Duration::from_millis(50)).await;
println!("never printed");
});
drop(dropped);
// detach lets a task run to its end with no handle.
nuclei::spawn(async { println!("detached, and it runs") }).detach();
// cancel stops a task, and gives its output back if it had finished.
let slow = nuclei::spawn(async {
sleep(Duration::from_secs(10)).await;
1
});
assert_eq!(slow.cancel().await, None);
// abort cancels without waiting; an AbortHandle does it from anywhere.
let stuck = nuclei::spawn(std::future::pending::<()>());
let abort = stuck.abort_handle();
abort.abort();
assert!(stuck.join().await.unwrap_err().is_cancelled());
sleep(Duration::from_millis(100)).await;
}| Call | What it does |
|---|---|
detach() | The task runs to its end; its output is dropped. |
cancel().await | Cancels and waits until the future is dropped; gives the output back if the task had finished. |
abort() | Cancels without waiting. abort_handle() gives an AbortHandle that does it from anywhere and outlives the task. |
is_finished() | Whether the task completed or was cancelled. |
The most common surprise
A server loop that spawns a task per connection and drops each handle cancels every connection at once. Write nuclei::spawn(handler).detach(), or keep the tasks in a JoinSet.
Why a task gave no output
Awaiting a task panics where it has no output to give. join() gives a Result instead: the output, or a JoinError that says the task was cancelled or panicked (with the panic's payload). fallible() gives an Option.
#[nuclei::main]
async fn main() {
// join gives the output, or a JoinError that says why there is none.
let ok = nuclei::spawn(async { 7 }).join().await;
assert_eq!(ok.unwrap(), 7);
let failed = nuclei::spawn(async {
if true {
panic!("the task gave up");
}
})
.join()
.await;
let error = failed.unwrap_err();
assert!(error.is_panic());
let message = error.into_panic();
assert_eq!(message.downcast_ref::<&str>(), Some(&"the task gave up"));
// fallible: None where awaiting the Task would panic.
let done = nuclei::spawn(async { 1 }).fallible();
assert_eq!(done.await, Some(1));
}Many tasks: JoinSet
A JoinSet holds tasks and gives their outputs in the order they finish. Dropping the set cancels whatever is still in it, so no task outlives the code that owns it.
use std::time::Duration;
use nuclei::task::JoinSet;
use nuclei::time::sleep;
#[nuclei::main]
async fn main() {
let mut set = JoinSet::new();
for n in [30u64, 10, 20] {
set.spawn(async move {
sleep(Duration::from_millis(n)).await;
n
});
}
// Joined in the order they finish, not the order they were spawned.
let mut order = Vec::new();
while let Some(done) = set.join_next().await {
order.push(done.unwrap());
}
assert_eq!(order, [10, 20, 30]);
// Dropping the set, or shutdown, cancels whatever is still in it.
set.spawn(std::future::pending());
set.shutdown().await;
assert!(set.is_empty());
}join_next_with_id says which task finished, try_join_next never waits, join_all collects every output, and abort_all, detach_all and shutdown end or release them all.
Ids and names
Every task has an Id, unique in the process and never reused: task.id() from the handle, task::id() from inside it, and the task hooks are given it too. task::Builder spawns with options and answers an io::Result: a name, which the hooks read with TaskMeta::name, and a worker to place the task on (Runtime).
use nuclei::task;
#[nuclei::main]
async fn main() -> std::io::Result<()> {
// A task spawned with options: a name.
let worker = task::Builder::new()
.name("checksum")
.spawn(async { task::id() })?;
let id = worker.id();
// Inside the task, task::id() is the same id the handle reports.
assert_eq!(worker.await, id);
// Outside any task there is none.
assert!(task::try_id().is_none());
Ok(())
}Tasks that are not Send
nuclei::spawn_local takes a future that need not be Send and keeps it on the calling thread's shard. There is no local set to create first.
use std::cell::RefCell;
use std::rc::Rc;
#[nuclei::main]
async fn main() {
// Rc is not Send: these tasks stay on the thread that spawned them.
let log = Rc::new(RefCell::new(Vec::new()));
let tasks: Vec<_> = (0..3)
.map(|n| {
let log = Rc::clone(&log);
nuclei::spawn_local(async move { log.borrow_mut().push(n) })
})
.collect();
for task in tasks {
task.await;
}
assert_eq!(log.borrow().len(), 3);
}Work that blocks
A blocking call (a synchronous library, a long computation) would hold its shard and every task on it. nuclei::spawn_blocking runs a closure on the runtime's blocking pool instead and returns a Task for its result. The pool grows on demand up to max_blocking_threads, and idle threads leave after thread_keep_alive.
#[nuclei::main]
async fn main() -> std::io::Result<()> {
// Blocking calls go to the blocking pool, so no shard waits on them.
let entries = nuclei::spawn_blocking(|| std::fs::read_dir(".").map(|dir| dir.count())).await?;
println!("{entries} entries here");
let digest =
nuclei::spawn_blocking(|| (0..1_000_000u64).fold(0u64, |h, n| h.rotate_left(5) ^ n)).await;
println!("digest {digest:x}");
Ok(())
}A poll that blocks anyway
Blocking code finds its way into tasks: a lock of a synchronous library, a slow system call, a loop longer than planned. When one poll of a task runs past handoff_after (10 ms by default), the task holds up only itself: its worker's shard, with every other task, timer and IO of it, goes on on another thread on the same core, and the blocked task carries on once its poll returns. A task placed on a worker stays on that worker through it.
- The blocked thread still shares the core, so a handoff is a rescue, not a place to run work: give long work to
spawn_blockingor break it up with yields. - Tasks that
spawn_localkeeps on the blocked thread, and a nestedblock_onthere, wait for it and finish on that thread. on_long_pollnames the task that blocked (Production), andstats()counts each worker'shandoffsand itslongest_poll.- Watching costs each poll two plain stores and a load, and each call a task makes into its worker (a wake, a spawn, a send or a receive) two loads and two stores: on io_uring a 64-byte TCP round trip runs 12% more instructions with it, a spawn and its join 25% more.
handoff_after(None)turns it off.
Task-local values
task_local! declares a key whose value belongs to one task: scope runs a future with it set, and any code that future runs reads it with get, with or try_with.
nuclei::task_local! {
static REQUEST: u64;
}
async fn handle() -> String {
// Any code the task runs reads the value its scope set.
format!("request {}", REQUEST.get())
}
#[nuclei::main]
async fn main() {
let first = nuclei::spawn(REQUEST.scope(1, handle()));
let second = nuclei::spawn(REQUEST.scope(2, handle()));
assert_eq!(first.await, "request 1");
assert_eq!(second.await, "request 2");
// Read where no scope set it, try_with answers an error instead of panicking.
assert!(REQUEST.try_with(|id| *id).is_err());
}Yielding and the budget
Each poll of a task may complete a budget of operations (coop::BUDGET) before it yields, so one busy task cannot hold its shard. IO that completes spends one, and so does a receive, an acquire or a lock of nuclei::sync that completes without waiting, so a loop over a channel that is never empty yields too. A loop that awaits nothing else can spend the budget itself with coop::consume_budget(); task::yield_now() gives the other tasks a turn at once, and coop::unconstrained runs a future with no budget.
use nuclei::task::{self, coop};
#[nuclei::main]
async fn main() {
let busy = nuclei::spawn(async {
let mut sum = 0u64;
for n in 0..100_000u64 {
sum += n;
// A long loop that awaits nothing else spends the budget, so the
// shard's other tasks get their turns.
coop::consume_budget().await;
}
sum
});
// yield_now: a turn for the other tasks of this shard, at once.
task::yield_now().await;
assert_eq!(busy.await, 4_999_950_000);
}