Async and Runtimes
What is an async fn, and what does the compiler turn it into?
Answer
An async fn is syntactic sugar for a function that returns impl Future. The compiler turns its body into a state machine: each .await becomes a point where execution can pause, and local variables that live across an .await become fields of the state machine.
Calling an async fn doesn't do any work. It just returns a future. The work starts only when the future is polled, usually because you .await it or hand it to the runtime.
async fn add(a: u32, b: u32) -> u32 {
a + b
}
#[tokio::main]
async fn main() {
let f = add(1, 2); // nothing has been computed yet
let r = f.await; // the future is polled and produces the result here
println!("{}", r);
}This laziness is what sets Rust apart from languages like JavaScript or C#, where calling an async function starts running it right away.
What is a Future, and how does poll work?
Answer
Future is a trait with a single method, poll. The caller passes in a Context, which contains a Waker. The implementation does as much work as it can, then returns Poll::Ready(value) if it's finished, or Poll::Pending if it has to wait.
If a future returns Pending, it must make sure its waker gets called once it can make progress (for example, by registering it with the I/O driver). Otherwise, the future will never be polled again and will hang forever. The runtime doesn't poll futures in a busy loop; it only polls a task after it has been woken.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct Yield(bool);
impl Future for Yield {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.0 {
Poll::Ready(())
} else {
self.0 = true;
cx.waker().wake_by_ref();
Poll::Pending
}
}
}Yield gives up control once and immediately wakes itself up. It's a simple way to let the runtime run other tasks.
Tokio vs. async-std: what's the difference, and which should you pick?
Answer
Tokio is the de facto standard. It has a large set of primitives, a solid work-stealing scheduler, and the biggest ecosystem: hyper, axum, tonic, reqwest, and many other libraries are built on it.
async-std aimed to mirror the standard library's API in async form, but it never caught up with Tokio's adoption. In 2025 it was officially discontinued, and its authors recommend smol instead.
The choice is simple: unless you have a specific reason not to, use Tokio. If you want a small, minimal runtime, look at smol. For embedded and no_std, use embassy.
What are the executor and the reactor?
Answer
The executor polls tasks when they're ready to make progress. The reactor (Tokio calls it the I/O driver) registers interest in I/O events with the OS (via epoll, kqueue, or IOCP) and wakes the right waker when an event arrives.
In Tokio, the I/O driver is built on top of the mio crate. When a socket becomes readable, the driver wakes the waker for the task waiting on it, the executor puts that task back in its queue, and the future gets polled again.
It's worth understanding this split, because it explains a lot of confusing async behavior. If a future isn't making progress, either nobody woke it, or the executor hasn't gotten around to its queue yet (for example, because another task is blocking the thread).
What is cooperative multitasking, and why shouldn't you block a thread in async code?
Answer
In async code, a single thread runs many tasks. Until a task gives up control by hitting an .await that returns Pending, no other task can run on that thread. The runtime can't interrupt it.
If you call std::thread::sleep or do heavy synchronous I/O inside an async fn, every other task on that worker thread stops. Tokio's rule of thumb is that async code shouldn't go more than 10–100 microseconds without reaching an .await.
For blocking work, Tokio has spawn_blocking, which runs a closure on a separate pool of threads meant for blocking. It's the right tool for synchronous APIs like file I/O or database drivers. For heavy CPU-bound computation, Rayon is often a better fit.
#[tokio::main]
async fn main() {
let r = tokio::task::spawn_blocking(heavy).await.unwrap();
println!("{}", r);
}
fn heavy() -> u64 {
(0..1_000_000u64).sum()
}What is tokio::select!, and what are its pitfalls?
Answer
select! waits on several futures at once and runs the branch of whichever finishes first. All the other futures are dropped, which means they're cancelled.
That's the main pitfall. If a cancelled future had already done part of its work, like reading some bytes from a socket, that work is lost. When using select!, make sure every future in it is cancel safe.
Another common mistake is creating a new future on every iteration of a loop. If you need a future to keep its progress across iterations, create it once before the loop, pin it (tokio::pin! or std::pin::pin!), and pass &mut fut to select!.
By default, select! checks branches in random order, so no branch always wins. Add biased; if you want them checked from top to bottom.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
tokio::select! {
_ = sleep(Duration::from_millis(50)) => println!("timeout"),
_ = sleep(Duration::from_millis(100)) => println!("slow operation"),
}
}The first branch finishes first and prints "timeout". The second future is dropped.
What is cancel safety, and why does it matter?
Answer
A future is cancel safe if dropping it at any .await point doesn't lose data or break invariants. You'll run into this with select!, timeout, and anywhere else a future can be dropped before it finishes.
Many Tokio operations are cancel safe. For example, mpsc::Receiver::recv: if it's cancelled, no message is lost. Others aren't. For example, AsyncReadExt::read_exact: if it's cancelled midway, the bytes it already read are lost, and the stream is left in an unknown position.
If you use an API inside select!, check whether it's cancel safe. Tokio's documentation states this explicitly for each method.
What do tokio::spawn and JoinHandle do?
Answer
tokio::spawn hands a future to the runtime to run as a separate task and returns a JoinHandle. The task starts running in the background right away; you don't need to await the handle for it to make progress.
JoinHandle is itself a future. Awaiting it waits for the task to finish and returns Result<T, JoinError>: Err means the task panicked or was cancelled. If you drop the handle, the task keeps running in the background. To stop it, call abort().
#[tokio::main]
async fn main() {
let h = tokio::spawn(async { 42 });
println!("{}", h.await.unwrap());
}A future passed to tokio::spawn must always be Send + 'static, even on a single-threaded runtime. To run !Send futures, use tokio::task::spawn_local inside a LocalSet.
What is a JoinSet, and why would you use it?
Answer
A JoinSet manages a group of spawned tasks. You can:
- wait for tasks one by one as they finish, with
join_next, - cancel all of them with
abort_allorshutdown, - rely on it to abort all remaining tasks when the
JoinSetis dropped.
JoinSet doesn't limit how many tasks run at once. If you need that, combine it with a tokio::sync::Semaphore.
Compared with FuturesUnordered from the futures crate: FuturesUnordered runs all its futures inside the current task, so they never run in parallel. JoinSet spawns real Tokio tasks, which can run on different worker threads.
use tokio::task::JoinSet;
#[tokio::main]
async fn main() {
let mut set = JoinSet::new();
for i in 0..5 {
set.spawn(async move { i * i });
}
while let Some(r) = set.join_next().await {
println!("{}", r.unwrap());
}
}This is the typical fan-out/fan-in pattern. Results arrive in the order the tasks finish, not the order they were spawned.
How do async functions in traits work, and why was this hard?
Answer
For a long time, you couldn't write async fn in traits, because each implementation returns its own future type, which has a different size. This was stabilized in Rust 1.75: async fn in traits now works as long as you use static dispatch (generics).
Such traits aren't dyn compatible, though, so you can't use them as dyn Trait directly. For that, people use the async_trait macro or write methods that return Pin<Box<dyn Future<Output = T> + Send + '_>> by hand. async_trait does exactly that under the hood, which means one heap allocation per call. That's usually fine, but it can add up on hot paths.
use async_trait::async_trait;
#[async_trait]
trait Store {
async fn get(&self, key: &str) -> Option<String>;
}
struct Mem;
#[async_trait]
impl Store for Mem {
async fn get(&self, _key: &str) -> Option<String> {
Some("v".into())
}
}If you don't need dynamic dispatch, use native async fn in the trait and skip the boxing.
What is backpressure, and how do you implement it in async code?
Answer
Backpressure is how a slow consumer tells a fast producer to slow down. Without it, a fast producer fills up memory with work nobody has processed yet.
In async pipelines, backpressure comes from bounded channels and from limiting concurrency (for example, with a Semaphore). Tokio's mpsc::channel(capacity) creates a bounded channel: when the buffer is full, send(...).await waits until there's room, which automatically slows down the producer.
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<i32>(8);
tokio::spawn(async move {
for i in 0..100 {
tx.send(i).await.unwrap();
}
});
while let Some(v) = rx.recv().await {
println!("{}", v);
}
}If the receiver is slow, send in the producer task just waits, and memory usage stays under control.
What are streams, and where are they used?
Answer
A Stream is the async version of Iterator. Its poll_next method returns Poll<Option<Item>>. Streams are used for message queues, event feeds, chunks of data arriving over the network, lines read from a file, and so on.
The Stream trait isn't in the standard library yet, so it comes from the futures crate. The futures and tokio-stream crates also provide adapters like map, filter, take, chunks, and throttle.
use tokio_stream::{self as stream, StreamExt};
#[tokio::main]
async fn main() {
let mut s = stream::iter(vec![1, 2, 3, 4, 5]).filter(|x| x % 2 == 0);
while let Some(v) = s.next().await {
println!("{}", v);
}
}Like iterators, streams are lazy: nothing happens until someone polls for the next item. They're a natural fit for event-driven architectures.
What is pin-project, and why do you need it?
Answer
When you write your own future or stream that wraps other futures, you get self: Pin<&mut Self>, and you need to access its fields. Some fields (the inner futures) must stay pinned, while others (plain data) can be accessed normally. This is called pin projection.
Doing it by hand requires unsafe code like Pin::map_unchecked_mut, and it's easy to get wrong and cause undefined behavior. The pin-project crate generates a safe projection for you. There's also pin-project-lite, a lighter version without procedural macros.
use pin_project::pin_project;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
#[pin_project]
struct AddN<F> {
#[pin]
fut: F,
n: u32,
}
impl<F: Future<Output = u32>> Future for AddN<F> {
type Output = u32;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
let this = self.project();
match this.fut.poll(cx) {
Poll::Ready(v) => Poll::Ready(v + *this.n),
Poll::Pending => Poll::Pending,
}
}
}project() gives you Pin<&mut F> for the pinned field and a plain &mut u32 for the other one. No unsafe in your code.
What is Tokio's coop budget, and how does it affect your code?
Answer
Tokio gives each task a budget of operations (currently 128) every time it's polled. Each operation on a Tokio resource, like receiving from a channel or reading from a socket, uses up part of the budget. Once the budget runs out, Tokio's resources start returning Pending even if they're ready, which forces the task to yield back to the scheduler.
This protects against a task that would otherwise hog a worker thread, for example a loop reading from a channel that always has messages waiting.
But the budget only counts operations on Tokio's own resources. Pure CPU work between .awaits doesn't use it up at all, so a task doing heavy computation will still block its worker. The fix is to call tokio::task::yield_now().await from time to time, or to move the computation to spawn_blocking or Rayon.
What is a LocalSet, and when do you need one?
Answer
tokio::spawn requires futures to be Send, because tasks may move between worker threads. A LocalSet lets you run !Send futures by pinning all of them to a single thread.
You'll need it when working with types that can't cross threads, like Rc or RefCell, or with libraries that keep thread-bound state (some GUI and JavaScript engine bindings, for example).
use std::rc::Rc;
use tokio::task;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let local = task::LocalSet::new();
local
.run_until(async {
let r = Rc::new(1);
task::spawn_local(async move {
println!("{}", r);
})
.await
.unwrap();
})
.await;
}Here the Rc, which isn't Send, lives happily inside spawn_local.
How do you add a timeout in Tokio?
Answer
Use tokio::time::timeout. It wraps a future and returns a Result: Ok with the value if the future finished in time, or Err(Elapsed) if it didn't.
When the timeout fires, the inner future is dropped, so all the cancel safety rules apply. If the future was halfway through writing to a socket, for example, you may end up with a partially sent message.
use tokio::time::{sleep, timeout, Duration};
#[tokio::main]
async fn main() {
let r = timeout(Duration::from_millis(50), sleep(Duration::from_millis(100))).await;
println!("{:?}", r);
}This prints Err(Elapsed(())). Timeouts are a simple way to protect against external calls that hang.
What is structured concurrency, and how do you do it in Rust?
Answer
The idea of structured concurrency is that every child task finishes before the function that started it returns. This makes errors and data lifetimes much easier to reason about: no task outlives the code that spawned it.
With threads, the standard library supports this directly through std::thread::scope.
In async Rust, there's no equivalent in std yet, but there are ways to get close:
futures::join!andfutures::future::join_allrun futures inside the current task, so they can't outlive it.- A
JoinSetwhose tasks you wait for before returning. If theJoinSetis dropped early, it aborts the remaining tasks. - The
async-scopedcrate, or a manualselect!with graceful shutdown.
use tokio::task::JoinSet;
async fn run() {
let mut set = JoinSet::new();
for i in 0..4 {
set.spawn(async move { println!("{}", i); });
}
while set.join_next().await.is_some() {}
}
#[tokio::main]
async fn main() {
run().await;
}When run returns, all of its tasks have finished. One caveat: if run itself is cancelled, the JoinSet aborts its tasks, but aborting isn't instant, and a task may keep running until it reaches its next .await.