Skip to content

Rust Async in Depth: select!, Timeouts, Streams and Pin

On the Async/Await page you learned the basics: an async fn returns a lazy future, .await runs it, #[tokio::main] starts the runtime, and tokio::join! and tokio::spawn run things at the same time. If any of that feels new, read that page first. This page builds on it.

Real async programs need a few more tools. Think of a busy kitchen again. Sometimes you wait for every dish (join!). Sometimes you only care about the first thing that happens: the food is ready, or the customer leaves (select!). Sometimes you give up after five minutes (timeout). And sometimes orders keep coming in, one by one, all evening (streams). This page covers all of these, plus Pin, the strange word you’ll eventually see in error messages, and explains it in plain language.

All examples use tokio with features = ["full"], just like the basics page. The “Try it Yourself” buttons work, because the Rust Playground has tokio built in.

You already know tokio::join!: it waits for all its futures and returns their results as a tuple. Its sibling tokio::try_join! is for futures that return a Result. It gives you Ok with all the values, or the first Err, and it stops waiting for the others as soon as one fails:

use std::time::Duration;
use tokio::time::sleep;
async fn load_profile(id: u32) -> Result<String, String> {
sleep(Duration::from_millis(20)).await;
Ok(format!("profile #{id}"))
}
async fn load_settings(id: u32) -> Result<String, String> {
sleep(Duration::from_millis(10)).await;
if id == 2 {
return Err(format!("no settings for user {id}"));
}
Ok(String::from("dark mode"))
}
#[tokio::main]
async fn main() {
for id in [1, 2] {
match tokio::try_join!(load_profile(id), load_settings(id)) {
Ok((profile, settings)) => println!("user {id}: {profile}, {settings}"),
Err(e) => println!("user {id} failed: {e}"),
}
}
}

Output:

user 1: profile #1, dark mode
user 2 failed: no settings for user 2
▶ Try it Yourself

join! and try_join! run the futures inside the current task, taking turns. That’s perfect for a handful of futures you know in advance. When you have a list of jobs whose size you only know at runtime, spawn them as separate tasks. You could push each JoinHandle into a Vec, like on the basics page. But tokio has a nicer tool for this: JoinSet, a bag of tasks that hands you each result as soon as it finishes:

use std::time::Duration;
use tokio::task::JoinSet;
use tokio::time::sleep;
async fn check_site(site: &'static str, ms: u64) -> (&'static str, u64) {
sleep(Duration::from_millis(ms)).await; // pretend this is a network call
(site, ms)
}
#[tokio::main]
async fn main() {
let mut set = JoinSet::new();
for (site, ms) in [("rust-lang.org", 30), ("docs.rs", 10), ("crates.io", 20)] {
set.spawn(check_site(site, ms));
}
let mut results = Vec::new();
while let Some(res) = set.join_next().await {
results.push(res.unwrap()); // Err only if the task panicked
}
results.sort(); // finish order can change from run to run
for (site, ms) in results {
println!("{site} answered in {ms}ms");
}
}

Output:

crates.io answered in 20ms
docs.rs answered in 10ms
rust-lang.org answered in 30ms
▶ Try it Yourself

join_next() returns None when the set is empty, so the while let loop ends by itself. Results arrive in finish order, not spawn order. When tasks take a similar time, that order can change between runs, so we sort before printing. One more nice thing: if you drop a JoinSet, every task still inside it is cancelled. Nothing is left running by accident.

join! waits for everything. tokio::select! waits for the first one. Each branch has a pattern, a future and a block of code: pattern = future => code. Whichever future finishes first, its code runs, and the other branches are cancelled.

What does cancelled mean in Rust? The losing future is simply dropped, like any other value going out of scope. It never runs again. Its local variables are dropped too, so their cleanup code runs. This example uses a small Drop type to make that visible:

use std::time::Duration;
use tokio::time::sleep;
struct Download(&'static str);
impl Drop for Download {
fn drop(&mut self) {
println!("cleaning up the {} download", self.0);
}
}
async fn download(mirror: &'static str, ms: u64) -> &'static str {
let _download = Download(mirror);
sleep(Duration::from_millis(ms)).await;
println!("{mirror} download finished");
mirror
}
#[tokio::main]
async fn main() {
tokio::select! {
winner = download("Europe", 50) => println!("winner: {winner}"),
winner = download("Asia", 1000) => println!("winner: {winner}"),
}
println!("main goes on");
}

Output:

Europe download finished
cleaning up the Europe download
cleaning up the Asia download
winner: Europe
main goes on
▶ Try it Yourself

Look at what’s missing: “Asia download finished” is never printed. The Asia future was stopped at its .await and dropped, and its Download value was cleaned up right away. This is how cancellation works everywhere in async Rust: stop polling a future and drop it. No special “cancel” call is needed.

A classic use of select! is a timeout: race your work against a timer.

tokio::select! {
report = build_report() => println!("{report}"),
_ = tokio::time::sleep(Duration::from_secs(5)) => println!("too slow, giving up"),
}

This works fine, but there’s a shortcut for it, tokio::time::timeout, covered in the next section.

select! really shines inside a loop. Here a worker takes jobs from a channel, but also listens for a “please stop” message on a second channel. A oneshot channel carries exactly one message, which makes it a perfect shutdown signal:

use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
use tokio::time::sleep;
#[tokio::main]
async fn main() {
let (job_tx, mut job_rx) = mpsc::channel::<String>(10);
let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
let worker = tokio::spawn(async move {
let mut done = 0;
loop {
tokio::select! {
Some(job) = job_rx.recv() => {
println!("working on: {job}");
done += 1;
}
_ = &mut stop_rx => {
println!("shutdown signal received");
break;
}
}
}
done
});
for job in ["resize photo", "send email", "update search index"] {
job_tx.send(job.to_string()).await.unwrap();
}
sleep(Duration::from_millis(100)).await; // give the worker time to finish
stop_tx.send(()).unwrap();
let done = worker.await.unwrap();
println!("worker finished {done} jobs");
}

Output:

working on: resize photo
working on: send email
working on: update search index
shutdown signal received
worker finished 3 jobs
▶ Try it Yourself

A few details worth knowing:

  • We write &mut stop_rx, not stop_rx. Each time around the loop, select! needs the same receiver again, so we lend it instead of moving it into the first round.
  • Every time around the loop, job_rx.recv() creates a new future, and the losing one is dropped. That’s fine, because recv() is cancel-safe: dropping it never loses a message.
  • If the pattern doesn’t match (here, recv() returns None because all senders are gone), select! turns that branch off and keeps waiting on the others.
  • If several branches are ready at the same moment, select! picks one at random, so no branch can hog the loop. Write biased; as the first line inside select! to check the branches from top to bottom instead.

Cancellation safety, in one paragraph. Because the losing branch is dropped, any work it had half done is thrown away. For recv() on a channel or sleep() that’s harmless: nothing was taken yet. But imagine a future that reads 100 bytes from a network socket and has already read 60 when it gets dropped. Those 60 bytes are gone. Such a future is not cancel-safe. Tokio’s docs say for each method whether it’s cancel-safe. When you use select! in a loop, check the methods you call, or create the future once outside the loop and keep polling that same future (you’ll see how in the Pin section).

tokio::time::timeout(duration, future) wraps any future. If the future finishes in time you get Ok(value). If the time runs out first, the future is dropped (cancelled, just like a losing select! branch) and you get Err(Elapsed):

use std::time::Duration;
use tokio::time::{sleep, timeout};
async fn fetch_price(server_delay_ms: u64) -> u32 {
sleep(Duration::from_millis(server_delay_ms)).await;
42
}
#[tokio::main]
async fn main() {
let limit = Duration::from_millis(200);
for (server, delay) in [("fast server", 20), ("slow server", 2000)] {
match timeout(limit, fetch_price(delay)).await {
Ok(price) => println!("{server}: price is {price}"),
Err(e) => println!("{server}: {e}"),
}
}
}

Output:

fast server: price is 42
slow server: deadline has elapsed
▶ Try it Yourself

Put a timeout on anything that talks to the outside world: HTTP calls, database queries, reading from a socket. A server that never answers should not freeze your program forever.

sleep vs interval. sleep waits once. interval gives you a steady rhythm, like a metronome. You call .tick().await in a loop, and each tick arrives on schedule. The first tick fires right away:

use std::time::Duration;
use tokio::time::interval;
#[tokio::main]
async fn main() {
let mut heartbeat = interval(Duration::from_millis(50));
for beat in 1..=3 {
heartbeat.tick().await; // the first tick completes immediately
println!("heartbeat {beat}");
}
}

Output:

heartbeat 1
heartbeat 2
heartbeat 3
▶ Try it Yourself

Why not just sleep 50ms in a loop? Because then each round takes “your work + 50ms”, and the schedule slowly drifts. An interval measures from the planned start of each tick, so if your work takes 10ms, it only waits the remaining 40ms. Use interval for heartbeats, polling and “save every minute” jobs. Use sleep for a single pause.

An iterator gives you values one by one with next(). A stream is the async version: next().await gives you the next value when it’s ready, and None when there are no more. Think of a ticker of stock prices, rows arriving from a database, or messages from a chat server.

You’ve actually used a stream-like thing already: a tokio channel receiver. rx.recv().await waits for the next message and returns None when every sender is gone:

use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(8);
tokio::spawn(async move {
for temp in [21, 22, 24, 23] {
tx.send(temp).await.unwrap();
sleep(Duration::from_millis(10)).await; // a reading every 10ms
}
// tx is dropped here, which closes the channel
});
while let Some(temp) = rx.recv().await {
println!("temperature: {temp}°C");
}
println!("sensor stopped");
}

Output:

temperature: 21°C
temperature: 22°C
temperature: 24°C
temperature: 23°C
sensor stopped
▶ Try it Yourself

The standard library doesn’t have a Stream trait yet (it’s still being designed). The ecosystem uses the one from the futures crate, and tokio’s helper crate tokio-stream adds a StreamExt trait with the methods you know from iterators: next(), map(), filter(), take() and more. Add it to your project:

[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
use tokio_stream::{self as stream, StreamExt};
#[tokio::main]
async fn main() {
let prices = stream::iter(vec![100, 102, 98, 105, 110]);
let mut doubled = prices.map(|p| p * 2).take(3);
while let Some(p) = doubled.next().await {
println!("{p}");
}
}

This prints 200, 204 and 196. It reads just like iterator code, except for the .await on next(). To turn a tokio channel receiver into a real stream, wrap it: tokio_stream::wrappers::ReceiverStream::new(rx). (The futures crate has its own StreamExt with the same idea. Pick one per file, because both traits have a next() method and they clash.)

Sooner or later you’ll see Pin in a type or an error message. Here is the idea without the deep theory.

When Rust compiles an async fn, it turns it into a hidden struct, a state machine. Every local variable that’s still needed after an .await becomes a field of that struct. Now imagine one local variable borrows another, like let name = String::from("Ana"); let r = &name; with an .await in between. Then the struct holds a pointer to its own field. If you moved the struct to another place in memory, that pointer would point at the old, empty spot.

Pin is the fix. A pinned value is a promise: “this value will not move in memory again.” Once a future is pinned, it’s safe to poll it. Most of the time you never notice, because .await pins the future for you.

You’ll meet Pin in three situations:

  1. Polling the same future many times, for example a deadline in a select! loop.
  2. Storing futures in a Vec or a struct field, usually as Pin<Box<dyn Future<Output = T>>>.
  3. Recursion in an async fn.

For the first one, here’s what happens if you try to reuse a sleep in a loop without pinning it:

let deadline = sleep(Duration::from_millis(300));
loop {
tokio::select! {
_ = &mut deadline => break,
Some(n) = rx.recv() => println!("{n}"),
}
}
error[E0277]: `PhantomPinned` cannot be unpinned
|
= note: consider using the `pin!` macro
consider using `Box::pin` if you need to access the pinned value outside of the current scope
= note: required for `Sleep` to implement `Unpin`

The compiler even tells you the fix. std::pin::pin! (or tokio::pin!) pins the future in place on the stack. Now the same deadline is shared by every round of the loop:

use std::pin::pin;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(8);
tokio::spawn(async move {
for order in 1..=3 {
tx.send(order).await.unwrap();
sleep(Duration::from_millis(10)).await;
}
sleep(Duration::from_secs(5)).await; // then the shop goes quiet
});
let mut deadline = pin!(sleep(Duration::from_millis(300)));
loop {
tokio::select! {
_ = &mut deadline => {
println!("closing time");
break;
}
Some(order) = rx.recv() => println!("got order #{order}"),
}
}
}

Output:

got order #1
got order #2
got order #3
closing time
▶ Try it Yourself

If we wrote sleep(...) inside the select!, every new order would start a fresh 300ms timer, and a busy shop would never close. Pinning one deadline outside the loop fixes that.

For recursion, the compiler needs to know how big the future is, and a future that contains itself would be infinitely big. Calling countdown(n - 1).await inside async fn countdown gives:

error[E0733]: recursion in an async fn requires boxing
|
= note: a recursive `async fn` call must introduce indirection such as `Box::pin` to avoid an infinitely sized future

Box::pin moves the inner future to the heap and pins it there. The outer future now only holds a pointer, which has a fixed size:

async fn countdown(n: u32) {
if n == 0 {
println!("liftoff!");
return;
}
println!("{n}...");
Box::pin(countdown(n - 1)).await;
}
#[tokio::main]
async fn main() {
countdown(3).await;
}

Output:

3...
2...
1...
liftoff!
▶ Try it Yourself

That’s honestly all most people need: pin! for a future you poll many times, Box::pin for storing futures or recursion. If you want the full story, the Rust Book chapter in the Read more list explains it step by step.

Since Rust 1.75 you can write async fn directly in a trait. Since Rust 1.85 you also have async closures, written async |x| ..., with matching traits AsyncFn, AsyncFnMut and AsyncFnOnce (async versions of the closure traits). Here are both in one example:

use std::time::Duration;
use tokio::time::sleep;
trait Notifier {
async fn notify(&self, user: &str) -> String;
}
struct Email;
impl Notifier for Email {
async fn notify(&self, user: &str) -> String {
sleep(Duration::from_millis(10)).await; // pretend to talk to a mail server
format!("email sent to {user}")
}
}
async fn for_each_user(users: &[&str], job: impl AsyncFn(&str) -> String) {
for user in users {
println!("{}", job(user).await);
}
}
#[tokio::main]
async fn main() {
let email = Email;
let users = ["ana", "ben"];
for_each_user(&users, async |user: &str| email.notify(user).await).await;
}

Output:

email sent to ana
email sent to ben
▶ Try it Yourself

There’s one catch with async fn in traits. The trait doesn’t promise that the returned future is Send, and tokio::spawn needs Send futures (they may move between threads). So spawning a call through a generic N: Notifier fails:

fn send_in_background<N: Notifier + Send + Sync + 'static>(notifier: N) {
tokio::spawn(async move {
let msg = notifier.notify("ana").await;
println!("{msg}");
});
}
error: future cannot be sent between threads safely
|
7 | let msg = notifier.notify("ana").await;
| ^^^^^^^^^^^^^^^^^^^^^^ await occurs here on type `impl Future<Output = String>`, which is not `Send`
|
help: `Send` can be made part of the associated future's guarantees for all implementations of `Notifier::notify`
|
2 - async fn notify(&self, user: &str) -> String;
2 + fn notify(&self, user: &str) -> impl std::future::Future<Output = String> + Send;

The compiler’s suggestion is the usual fix: write the trait method as fn ... -> impl Future<Output = String> + Send. Implementers can still write a plain async fn. Also note that traits with async fn can’t be used as dyn Notifier trait objects yet. If you need that, the popular async-trait crate boxes the futures for you.

Blocking the runtime. std::thread::sleep, heavy calculations, or big std::fs reads inside an async task freeze the whole worker thread, and every other task on it waits. The basics page showed the sleep version. For CPU-heavy or blocking work, hand it to tokio::task::spawn_blocking, which runs it on a separate pool of threads made for that:

fn count_primes(limit: u64) -> usize {
(2..limit)
.filter(|&n| (2..).take_while(|d| d * d <= n).all(|d| n % d != 0))
.count()
}
#[tokio::main]
async fn main() {
let handle = tokio::task::spawn_blocking(|| count_primes(50_000));
println!("still answering requests while we count...");
let primes = handle.await.unwrap();
println!("there are {primes} primes below 50000");
}

Output:

still answering requests while we count...
there are 5133 primes below 50000
▶ Try it Yourself

Holding a std::sync::Mutex guard across .await. This looks innocent:

let handle = tokio::spawn(async move {
let mut count = visits.lock().unwrap();
*count += 1;
save_to_disk(*count).await;
});
error: future cannot be sent between threads safely
|
= help: within `{async block@src/main.rs:13:31: 13:41}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, u32>`
note: future is not `Send` as this value is used across an await
|
14 | let mut count = visits.lock().unwrap();
| --------- has type `std::sync::MutexGuard<'_, u32>` which is not `Send`
15 | *count += 1;
16 | save_to_disk(*count).await;
| ^^^^^ await occurs here, with `mut count` maybe used later

The compiler is protecting you. While this task sleeps at .await, it would still hold the lock, and any other task that wants the lock would block its whole thread. The fix is to drop the guard before the .await, by putting the locking in its own block:

let count = {
let mut count = visits.lock().unwrap();
*count += 1;
*count
}; // the guard is dropped here
save_to_disk(count).await;

If you truly need to keep a lock while awaiting, use tokio::sync::Mutex, whose lock().await waits without blocking the thread. (For more on locks, see Shared State.)

Forgetting .await. Futures are lazy, so save_file(); without .await does nothing at all. The compiler warns you with “futures do nothing unless you .await or poll them”. See Async/Await for the full story, and don’t ignore that warning.

Spawning a task and never waiting for it. When main returns, the runtime shuts down and every unfinished task is dropped:

use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
println!("report saved"); // never printed!
});
println!("main is done");
}

Output:

main is done
▶ Try it Yourself

The report is never saved. If the work matters, keep the JoinHandle and .await it (or put the tasks in a JoinSet and drain it) before main ends.

Four shops answer a price request after 20, 40, 300 and 60 milliseconds. Ask all four at the same time with a JoinSet, but give each shop only 150ms. Print the prices that arrived (sorted by shop name), then how many shops timed out. Hint: spawn timeout(limit, ask_shop(name, delay)) into the set. Each finished task then gives you an Ok or an Err(Elapsed), so return the shop name together with that result.

Show solution
use std::time::Duration;
use tokio::task::JoinSet;
use tokio::time::{sleep, timeout};
async fn ask_shop(delay_ms: u64) -> u32 {
sleep(Duration::from_millis(delay_ms)).await;
100 + delay_ms as u32 // a made-up price
}
#[tokio::main]
async fn main() {
let shops = [("Corner Shop", 20), ("MegaMart", 40), ("SlowCo", 300), ("Bits & Bobs", 60)];
let limit = Duration::from_millis(150);
let mut set = JoinSet::new();
for (name, delay) in shops {
set.spawn(async move { (name, timeout(limit, ask_shop(delay)).await) });
}
let mut prices = Vec::new();
let mut timed_out = 0;
while let Some(res) = set.join_next().await {
match res.unwrap() {
(name, Ok(price)) => prices.push((name, price)),
(_, Err(_)) => timed_out += 1,
}
}
prices.sort();
for (name, price) in prices {
println!("{name}: {price}");
}
println!("{timed_out} shop(s) timed out");
}

Output:

Bits & Bobs: 160
Corner Shop: 120
MegaMart: 140
1 shop(s) timed out
▶ Try it Yourself

Every shop waits at the same time, so the whole check takes about 150ms, no matter how slow SlowCo is. When its timeout fires, its future is dropped, so it can’t keep running in the background.

A Folder has a name, a list of file sizes in KB, and a list of sub-folders. Write async fn total_size(folder: &Folder) -> u64 that adds up its own files plus the size of every sub-folder, calling itself for each one. Pretend each folder needs a short async “disk read” first, with tokio::task::yield_now().await. Hint: the recursive call needs Box::pin(...) around it, or you’ll get error E0733.

Show solution
struct Folder {
name: &'static str,
files_kb: Vec<u64>,
subfolders: Vec<Folder>,
}
async fn total_size(folder: &Folder) -> u64 {
tokio::task::yield_now().await; // pretend to read the disk
let mut total: u64 = folder.files_kb.iter().sum();
for sub in &folder.subfolders {
total += Box::pin(total_size(sub)).await;
}
println!("{}: {} KB", folder.name, total);
total
}
#[tokio::main]
async fn main() {
let home = Folder {
name: "home",
files_kb: vec![4],
subfolders: vec![
Folder { name: "photos", files_kb: vec![2048, 1024], subfolders: vec![] },
Folder {
name: "code",
files_kb: vec![12, 8],
subfolders: vec![Folder { name: "target", files_kb: vec![500], subfolders: vec![] }],
},
],
};
let total = total_size(&home).await;
println!("total: {total} KB");
}

Output:

photos: 3072 KB
target: 500 KB
code: 520 KB
home: 3596 KB
total: 3596 KB
▶ Try it Yourself

Each folder prints after all its sub-folders, because it has to wait for their sizes first. Box::pin puts each nested call’s future on the heap, so the outer future stays a fixed size.

Change the shutdown example so it doesn’t need the sleep(100ms) before sending the stop signal. Send the three jobs and the stop signal right away, but make sure the worker still finishes all queued jobs before stopping. Hint: without help, select! picks a random branch when both are ready. Add biased; and put the jobs branch first.

Show solution
use tokio::sync::{mpsc, oneshot};
#[tokio::main]
async fn main() {
let (job_tx, mut job_rx) = mpsc::channel::<String>(10);
let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
for job in ["resize photo", "send email", "update search index"] {
job_tx.send(job.to_string()).await.unwrap();
}
stop_tx.send(()).unwrap();
let worker = tokio::spawn(async move {
let mut done = 0;
loop {
tokio::select! {
biased;
Some(job) = job_rx.recv() => {
println!("working on: {job}");
done += 1;
}
_ = &mut stop_rx => {
println!("queue is empty, stopping");
break;
}
}
}
done
});
let done = worker.await.unwrap();
println!("worker finished {done} jobs");
}

Output:

working on: resize photo
working on: send email
working on: update search index
queue is empty, stopping
worker finished 3 jobs
▶ Try it Yourself

With biased;, select! always tries the jobs branch first, so it only looks at the stop signal once recv() has nothing ready. Without it, the worker might stop after one or two jobs, a bug that would only show up now and then.

  • join! waits for all futures, try_join! stops at the first Err, and JoinSet runs many spawned tasks and hands back results as they finish (sort them if order matters).
  • select! waits for the first future to finish. The others are dropped, which is how cancellation works in async Rust. Inside a loop, use cancel-safe futures and biased; when order matters.
  • timeout gives up on a slow future. interval ticks on a steady schedule, sleep waits once.
  • A stream is an async iterator. A channel receiver works like one, and tokio-stream adds map, take and friends.
  • Pin promises a future won’t move in memory. Use pin! to poll one future many times and Box::pin for stored futures and async recursion.
  • Don’t block the runtime (spawn_blocking), don’t hold a std lock across .await, and await the tasks you spawn.

Quick check

1. In tokio::select!, what happens to the branches that don't finish first?

2. What does tokio::time::timeout return when the time runs out?

3. Why do you need Box::pin when an async fn calls itself?

4. You must do 2 seconds of CPU-heavy work inside a tokio program. What's the best tool?

Score: 0 / 4