tokioCrate
The async runtime the ecosystem settled on: an executor for tasks, non-blocking I/O, timers, and the channels and locks that work across .await points.
Overview
Rust has async and .await in the language, and no runtime to execute them.
A Future does nothing until something polls it, and the standard library
provides nothing that does. tokio is that something, plus everything a real
program needs alongside it:
use std::time::Duration;
#[tokio::main]
async fn main() {
// Tasks run concurrently on the runtime's threads.
let a = tokio::spawn(async { 21 * 2 });
let b = tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(5)).await;
"done"
});
assert_eq!(a.await.unwrap(), 42);
assert_eq!(b.await.unwrap(), "done");
}
It is a runtime, an async reimplementation of the parts of std that block
(tokio::fs, tokio::net, tokio::io), timers, and synchronisation
primitives that can be held across an .await. Under it sits
mio for the OS readiness API.
The rule that matters most: never block in an async task. A task that calls
std::fs::read, std::thread::sleep, or spends a long time computing, stops
the whole worker thread — and with it every other task scheduled there. The
symptom is a server that becomes unresponsive under load for no visible reason.
The fixes are tokio::fs and friends for I/O, and spawn_blocking for
CPU-bound or unavoidably blocking work, which moves it to a separate pool.
The second thing to internalise: dropping a future cancels it. There is no
Task::cancel because dropping the JoinHandle's future — or losing a select!
branch — simply stops polling, and the work stops wherever it was. That is
powerful and sharp: a select! that loses a branch mid-write can leave a
half-written message, so code that must not be interrupted needs to say so,
usually by moving it into its own spawn.
Async is not automatically faster. It wins when a program waits on many
things at once — thousands of connections, many concurrent requests. For
CPU-bound work it adds machinery and no speed, and for a handful of
simultaneous operations, threads are simpler and perform fine. Async also
colours your API: an async fn can only be called from async code, so adopting
it is a decision about the whole program rather than one function.
The alternatives are real but narrow. smol is much smaller and does the same
core job; glommio is thread-per-core for io_uring workloads. In practice the
ecosystem standardised on tokio — axum, hyper, tonic, reqwest and most
database drivers expect it — so choosing another runtime means checking that
everything you depend on still works.
Features are opt-in and full is the usual starting point: rt-multi-thread
for the threaded scheduler, macros for #[tokio::main], net, fs, time,
sync. Trimming them matters for build time on a small service. It requires
Rust 1.71.
When to use it
Doing several slow things at once
The case async exists for. Three requests that each take 100 ms take 100 ms together, not 300.
use std::time::Duration;
async fn fetch(name: &str, delay_ms: u64) -> String {
tokio::time::sleep(Duration::from_millis(delay_ms)).await; // <- stands in for I/O
format!("{name} ok")
}
#[tokio::main]
async fn main() {
let started = std::time::Instant::now();
// join! polls all three on this task, concurrently.
let (a, b, c) = tokio::join!(
fetch("users", 30),
fetch("orders", 30),
fetch("stock", 30),
);
assert_eq!((a.as_str(), b.as_str()), ("users ok", "orders ok"));
assert_eq!(c, "stock ok");
// Concurrent, so well under the 90ms a sequential version would take.
assert!(started.elapsed() < Duration::from_millis(80));
}
Why it fits: join! runs the futures concurrently on one task, with no
spawning and no threads. Use spawn instead when the work should run in
parallel across threads or outlive the current scope; join! is the cheaper
answer when you simply need several awaits to overlap.
A worker fed by a channel
The standard shape for background work: producers send, one task consumes, and backpressure is built in.
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
// Capacity 8: send() waits once the queue is full, which is backpressure.
let (tx, mut rx) = mpsc::channel::<u32>(8);
let worker = tokio::spawn(async move {
let mut total = 0;
while let Some(job) = rx.recv().await {
total += job;
}
total // <- recv returns None once every sender is dropped
});
for n in 1..=4 {
tx.send(n).await.unwrap();
}
drop(tx); // <- closes the channel, ending the loop
assert_eq!(worker.await.unwrap(), 10);
}
Why it fits: the bounded channel is the point. An unbounded queue turns a slow consumer into unbounded memory growth; a bounded one makes the producer wait, which pushes the pressure back to where it can be handled. Dropping the sender is how the consumer learns to stop — not a sentinel message.
Giving an operation a deadline
Anything over a network needs a time limit, and the caller is where it belongs.
use std::time::Duration;
async fn slow_call() -> &'static str {
tokio::time::sleep(Duration::from_secs(30)).await;
"eventually"
}
#[tokio::main]
async fn main() {
// Err on timeout; the inner future is dropped, which cancels it.
let result = tokio::time::timeout(Duration::from_millis(20), slow_call()).await;
assert!(result.is_err());
// Inside the limit, the value comes through.
let quick = tokio::time::timeout(Duration::from_millis(50), async { 7 }).await;
assert_eq!(quick.unwrap(), 7);
}
Why it fits: timeout wraps any future, so it works on a request, a lock
acquisition or a whole pipeline without those knowing about it. What it does on
expiry is drop the future — so anything half-done is abandoned, which is the
cancellation behaviour to keep in mind when the operation had side effects.
API map
tokio is large, so this is the part you reach for daily: running the runtime, spawning work, the synchronisation primitives, time, and the async I/O types.
Starting the runtime3
#[tokio::main]
Turns an async main into a sync one that builds a runtime and blocks on it.
#[tokio::main]
async fn main() {
let value = async { 1 + 1 }.await;
assert_eq!(value, 2);
}
When to use it: in binaries, once. It needs the macros and
rt-multi-thread features. #[tokio::main(flavor = "current_thread")] gives a
single-threaded runtime, which is lighter and the right choice for a CLI tool
that just happens to make a few async calls.
Runtime::new and block_on
Building a runtime by hand, for when an attribute will not do.
fn main() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let result = runtime.block_on(async {
tokio::spawn(async { "from a task" }).await.unwrap()
});
assert_eq!(result, "from a task");
}
When to use it: a library exposing a sync API over async internals, a test
harness, or a program with a non-async main it does not control. Builder
configures worker count and thread names. Never call block_on from inside a
runtime — it panics, and it is the usual cause of "cannot block the current
thread from within a runtime".
#[tokio::test]
An async test, with a fresh runtime per test.
// In a real crate this carries #[tokio::test]; here it is called directly.
async fn adds_up() {
let handle = tokio::spawn(async { 2 + 2 });
assert_eq!(handle.await.unwrap(), 4);
}
#[tokio::main]
async fn main() {
adds_up().await;
}
When to use it: every test touching async code. It defaults to a current-thread runtime, which is usually what you want — deterministic, and it surfaces a deadlock as a hang in one test rather than a flake.
Tasks3
spawn
Hands a future to the runtime to run independently.
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
(1..=10).sum::<u32>()
});
// The task is already running; await collects its result.
assert_eq!(handle.await.unwrap(), 55);
}
When to use it: work that should proceed whether or not you are waiting, or
should run in parallel on another thread. The future must be Send + 'static,
which is what forces owned data into it — usually via move and a clone of
whatever it needs.
JoinHandle
The handle to a spawned task: await it for the result, drop it to detach, abort it to cancel.
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async { 1 });
assert_eq!(handle.await.unwrap(), 1);
// A panicking task does not kill the runtime; it surfaces here.
let bad = tokio::spawn(async { panic!("boom") });
let err = bad.await.unwrap_err();
assert!(err.is_panic());
// abort stops a task; awaiting it afterwards reports cancellation.
let long = tokio::spawn(async { std::future::pending::<()>().await });
long.abort();
assert!(long.await.unwrap_err().is_cancelled());
}
When to use it: whenever you need the result or the outcome. The
unwrap_err().is_panic() case is the one to know — a panic in a task is
contained, so a supervisor that never awaits its handles will never learn a
task died.
spawn_blocking
Moves blocking work off the async threads.
#[tokio::main]
async fn main() {
// Pretend this is a CPU-heavy or blocking-IO call.
let total = tokio::task::spawn_blocking(|| {
(1..=1_000u64).sum::<u64>()
})
.await
.unwrap();
assert_eq!(total, 500_500);
}
When to use it: synchronous file APIs, CPU-bound computation, an FFI call that blocks, a database driver with no async version. This is the fix for the rule in the Overview — the closure runs on a separate pool sized for blocking, so stalling it costs nothing to the async tasks.
Synchronisation4
mpsc::channel
A bounded multi-producer, single-consumer queue.
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<&str>(2);
let tx2 = tx.clone(); // <- multi-producer
tokio::spawn(async move { tx2.send("a").await.unwrap() });
tokio::spawn(async move { tx.send("b").await.unwrap() });
let mut got = vec![rx.recv().await.unwrap(), rx.recv().await.unwrap()];
got.sort_unstable();
assert_eq!(got, ["a", "b"]);
// With every sender dropped, recv yields None.
assert_eq!(rx.recv().await, None);
}
When to use it: the default channel — job queues, event pipelines, fan-in
from many producers. Choose the capacity deliberately: it is the amount of work
allowed to pile up before producers are slowed down. unbounded_channel exists
but removes exactly that protection.
oneshot::channel
A single value, sent once, from one place to one place.
use tokio::sync::oneshot;
#[tokio::main]
async fn main() {
let (tx, rx) = oneshot::channel::<u32>();
tokio::spawn(async move {
tx.send(99).unwrap(); // <- not async: it never blocks
});
assert_eq!(rx.await.unwrap(), 99);
// A dropped sender is an error rather than a hang.
let (tx2, rx2) = oneshot::channel::<u32>();
drop(tx2);
assert!(rx2.await.is_err());
}
When to use it: request/response between tasks — send a job carrying a
oneshot::Sender and await the reply. The dropped-sender error is the important
property: if the worker dies, the caller gets an error rather than waiting
forever.
broadcast and watch
Two fan-out channels with different semantics.
use tokio::sync::{broadcast, watch};
#[tokio::main]
async fn main() {
// broadcast: every receiver sees every message.
let (tx, mut rx1) = broadcast::channel::<u8>(4);
let mut rx2 = tx.subscribe();
tx.send(1).unwrap();
assert_eq!(rx1.recv().await.unwrap(), 1);
assert_eq!(rx2.recv().await.unwrap(), 1);
// watch: receivers see only the latest value.
let (wtx, mut wrx) = watch::channel("starting");
wtx.send("ready").unwrap();
wrx.changed().await.unwrap();
assert_eq!(*wrx.borrow_and_update(), "ready");
}
When to use it: broadcast for events every subscriber must see, accepting
that a slow receiver can lag and miss messages. watch for current state —
configuration, a shutdown flag, a health status — where only the newest value
matters and missing intermediate ones is correct.
Mutex and RwLock
Locks that can be held across an .await.
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
let counter = Arc::new(Mutex::new(0u32));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(tokio::spawn(async move {
let mut guard = counter.lock().await; // <- await, not block
*guard += 1;
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(*counter.lock().await, 4);
}
When to use it: only when the guard must survive an .await. Otherwise
std::sync::Mutex is faster and perfectly correct in async code, provided you
lock, use, and drop the guard without awaiting in between. Reaching for the
tokio one by default is a common and needless slowdown.
Time2
sleep and interval
Waiting, without blocking a thread.
use std::time::Duration;
#[tokio::main]
async fn main() {
let start = std::time::Instant::now();
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(start.elapsed() >= Duration::from_millis(10));
// interval ticks repeatedly; the first tick is immediate.
let mut ticker = tokio::time::interval(Duration::from_millis(5));
ticker.tick().await;
ticker.tick().await;
assert!(start.elapsed() >= Duration::from_millis(15));
}
When to use it: retry backoff, polling loops, rate limiting. Never
std::thread::sleep in a task — it stops the worker thread and everything
scheduled on it. interval's immediate first tick surprises people; it is
documented behaviour and usually what a polling loop wants.
timeout
A deadline around any future.
use std::time::Duration;
#[tokio::main]
async fn main() {
let fast = tokio::time::timeout(Duration::from_millis(50), async { "ok" }).await;
assert_eq!(fast.unwrap(), "ok");
let slow = tokio::time::timeout(
Duration::from_millis(10),
tokio::time::sleep(Duration::from_secs(5)),
)
.await;
assert!(slow.is_err());
}
When to use it: every network call, and anything that could hang. It returns
Result<T, Elapsed>, so the timeout is a value you handle rather than a
surprise. Remember the inner future is dropped on expiry — cancelled, not
allowed to finish in the background.
Racing futures2
select!
Waits on several futures and takes the first to finish.
use std::time::Duration;
#[tokio::main]
async fn main() {
let slow = async {
tokio::time::sleep(Duration::from_millis(50)).await;
"slow"
};
let fast = async { "fast" };
let winner = tokio::select! {
v = slow => v,
v = fast => v,
};
assert_eq!(winner, "fast");
}
When to use it: a work loop that must also watch for shutdown, or a
first-response-wins race. The crucial caveat: the losing branches are
dropped, so a branch that was midway through a write is cancelled there. If a
branch must complete once started, spawn it and select on its JoinHandle
instead.
join! and try_join!
Waits for all of several futures.
#[tokio::main]
async fn main() {
let (a, b) = tokio::join!(async { 1 }, async { 2 });
assert_eq!((a, b), (1, 2));
// try_join! short-circuits on the first Err.
let ok: Result<(u8, u8), &str> = tokio::try_join!(async { Ok(1) }, async { Ok(2) });
assert_eq!(ok.unwrap(), (1, 2));
let failed: Result<(u8, u8), &str> =
tokio::try_join!(async { Ok(1) }, async { Err("nope") });
assert_eq!(failed.unwrap_err(), "nope");
}
When to use it: several independent awaits whose results you all need.
try_join! for the fallible version, where one failure should abandon the rest —
and note it does exactly that, dropping the others rather than waiting.
Async I/O3
tokio::fs
The async mirror of std::fs.
#[tokio::main]
async fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("tokio-page-demo.txt");
tokio::fs::write(&path, b"contents").await?;
let read_back = tokio::fs::read_to_string(&path).await?;
assert_eq!(read_back, "contents");
tokio::fs::remove_file(&path).await?;
Ok(())
}
When to use it: file work inside a task. Be aware of what it actually is —
most platforms have no async file I/O, so these call the blocking syscalls on
the spawn_blocking pool. The benefit is not speed; it is that the async
threads keep running.
AsyncReadExt and AsyncWriteExt
The async counterparts of Read and Write.
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() -> std::io::Result<()> {
// A Vec<u8> is an AsyncWrite, and a &[u8] an AsyncRead.
let mut sink: Vec<u8> = Vec::new();
sink.write_all(b"hello ").await?;
sink.write_all(b"world").await?;
assert_eq!(sink, b"hello world");
let mut source = &b"abc"[..];
let mut buf = String::new();
source.read_to_string(&mut buf).await?;
assert_eq!(buf, "abc");
Ok(())
}
When to use it: reading and writing sockets, files and pipes. The extension
traits carry the convenience methods, so both imports are needed even when the
type already implements the base trait — a "no method named write_all" error
is almost always a missing AsyncWriteExt.
tokio::net::TcpListener
Accepting connections, one task per connection.
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 16];
let n = socket.read(&mut buf).await.unwrap();
socket.write_all(&buf[..n]).await.unwrap(); // <- echo it back
});
let mut client = TcpStream::connect(addr).await?;
client.write_all(b"ping").await?;
let mut reply = [0u8; 4];
client.read_exact(&mut reply).await?;
assert_eq!(&reply, b"ping");
server.await.unwrap();
Ok(())
}
When to use it: any TCP server. The shape is the whole point — accept in a
loop, spawn per connection, and the runtime multiplexes thousands of them onto
a few threads. Compare mio, which is what this is built on and shows
the bookkeeping tokio is doing for you.