Rust Async Programming with Tokio: Getting Started
Learn async Rust with Tokio from scratch. Covers async/await syntax, spawning tasks, channels, TCP servers, and common pitfalls with working examples.
What you'll learn
- ✓Set up a Tokio project and understand the runtime
- ✓Write async functions with async/await
- ✓Spawn concurrent tasks and communicate between them
- ✓Build a simple async TCP echo server
Prerequisites
- •Comfortable with Rust basics — see /blog/rust-variables-and-types
- •Understanding of ownership — see /blog/rust-ownership-basics
- •Familiarity with Result and error handling
Async programming lets your program do useful work while waiting for I/O operations like network requests, file reads, or database queries. Instead of blocking a thread, an async function yields control so other tasks can run on the same thread. Rust’s async model is zero-cost — you pay no runtime overhead beyond what you use.
Tokio is the most widely used async runtime for Rust. It provides the executor that drives your futures to completion, along with async versions of standard I/O primitives, timers, channels, and more.
Setting Up a Tokio Project
Create a new project and add Tokio:
// In your terminal:
// cargo new async-demo
// cd async-demo
// cargo add tokio --features full
The full feature flag enables all Tokio components. For production, you would pick only the features you need (like rt-multi-thread, macros, net, io-util).
Your Cargo.toml dependencies should look like:
[dependencies]
tokio = { version = "1", features = ["full"] }
Your First Async Program
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
println!("Starting...");
sleep(Duration::from_secs(1)).await;
println!("One second later!");
let result = fetch_data().await;
println!("Got: {}", result);
}
async fn fetch_data() -> String {
// Simulate a network request
sleep(Duration::from_millis(500)).await;
String::from("some data from the server")
}
The #[tokio::main] macro transforms your async fn main() into a regular fn main() that sets up the Tokio runtime and blocks on your future. Under the hood, it expands to:
fn main() {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async {
// your async main body here
});
}
Understanding Futures and .await
An async fn does not run immediately when called. It returns a Future — a value representing work that has not started yet. Nothing happens until you .await it.
async fn compute() -> u64 {
println!("Computing...");
42
}
#[tokio::main]
async fn main() {
let future = compute(); // Nothing printed yet
println!("Future created");
let value = future.await; // Now "Computing..." prints
println!("Value: {}", value);
}
This is different from Go’s goroutines or JavaScript’s promises, which start executing immediately. In Rust, futures are lazy by design.
Running Tasks Concurrently
Calling .await on futures one after another runs them sequentially. To run them concurrently, use tokio::join! or tokio::spawn.
Using join! for Structured Concurrency
use tokio::time::{sleep, Duration};
async fn download_file(name: &str) -> String {
println!("Starting download: {}", name);
sleep(Duration::from_secs(2)).await;
println!("Finished download: {}", name);
format!("{} contents", name)
}
#[tokio::main]
async fn main() {
// Sequential — takes 6 seconds
// let a = download_file("a.txt").await;
// let b = download_file("b.txt").await;
// let c = download_file("c.txt").await;
// Concurrent — takes 2 seconds
let (a, b, c) = tokio::join!(
download_file("a.txt"),
download_file("b.txt"),
download_file("c.txt"),
);
println!("Got: {}, {}, {}", a, b, c);
}
join! runs all three futures on the current task, interleaving their execution. All three downloads happen concurrently, so the total time is the duration of the longest one, not the sum.
Using spawn for Background Tasks
tokio::spawn creates a new task that runs independently on the runtime’s thread pool:
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
sleep(Duration::from_secs(1)).await;
println!("Task completed");
42
});
println!("Task is running in the background");
// Do other work here...
let result = handle.await.unwrap();
println!("Task returned: {}", result);
}
Spawned tasks are true concurrent units. They can run on different threads in a multi-threaded runtime. The JoinHandle lets you await the result or cancel the task by dropping it.
Important: spawned tasks must be 'static — they cannot borrow from the calling scope. If you need to share data, use Arc or clone the data into the task.
use std::sync::Arc;
#[tokio::main]
async fn main() {
let shared_data = Arc::new(vec![1, 2, 3, 4, 5]);
let data = Arc::clone(&shared_data);
let handle = tokio::spawn(async move {
let sum: i32 = data.iter().sum();
sum
});
println!("Sum: {}", handle.await.unwrap());
}
Async Channels
Tokio provides async channels for communication between tasks. The most common is mpsc (multi-producer, single-consumer):
use tokio::sync::mpsc;
#[derive(Debug)]
struct Job {
id: u32,
payload: String,
}
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<Job>(32); // buffer size of 32
// Spawn producers
for i in 0..5 {
let tx = tx.clone();
tokio::spawn(async move {
let job = Job {
id: i,
payload: format!("data-{}", i),
};
tx.send(job).await.unwrap();
});
}
// Drop the original sender so the receiver knows when all senders are done
drop(tx);
// Consume jobs
while let Some(job) = rx.recv().await {
println!("Processing job {}: {}", job.id, job.payload);
}
println!("All jobs processed");
}
Tokio also provides oneshot channels for single-value communication and broadcast channels for multi-consumer scenarios:
use tokio::sync::oneshot;
#[tokio::main]
async fn main() {
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
// Do some work
let result = expensive_computation().await;
tx.send(result).unwrap();
});
// Wait for the result
let value = rx.await.unwrap();
println!("Received: {}", value);
}
async fn expensive_computation() -> String {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
String::from("computed value")
}
Building an Async TCP Echo Server
Here is a practical example — a TCP server that echoes back whatever clients send:
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Server listening on 127.0.0.1:8080");
loop {
let (mut socket, addr) = listener.accept().await?;
println!("New connection from: {}", addr);
tokio::spawn(async move {
let mut buf = vec![0u8; 1024];
loop {
let n = match socket.read(&mut buf).await {
Ok(0) => {
println!("Client {} disconnected", addr);
return;
}
Ok(n) => n,
Err(e) => {
eprintln!("Error reading from {}: {}", addr, e);
return;
}
};
if let Err(e) = socket.write_all(&buf[..n]).await {
eprintln!("Error writing to {}: {}", addr, e);
return;
}
}
});
}
}
Each client connection runs in its own spawned task, so the server handles thousands of concurrent connections efficiently. You can test this with nc localhost 8080 or telnet localhost 8080.
Shared State with Mutex
When multiple tasks need to read and write shared state, use tokio::sync::Mutex:
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
type Db = Arc<Mutex<HashMap<String, String>>>;
#[tokio::main]
async fn main() {
let db: Db = Arc::new(Mutex::new(HashMap::new()));
let db1 = Arc::clone(&db);
let writer = tokio::spawn(async move {
for i in 0..5 {
let mut lock = db1.lock().await;
lock.insert(format!("key-{}", i), format!("value-{}", i));
// Lock is dropped here at end of scope
}
});
let db2 = Arc::clone(&db);
let reader = tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let lock = db2.lock().await;
for (k, v) in lock.iter() {
println!("{}: {}", k, v);
}
});
writer.await.unwrap();
reader.await.unwrap();
}
Tip: if your critical sections are short and do not contain .await calls, use std::sync::Mutex instead of tokio::sync::Mutex — it has less overhead. Only use the Tokio mutex when you need to hold the lock across an .await point.
Timeouts and Select
Tokio provides utilities for timeout handling and racing futures:
use tokio::time::{timeout, Duration};
async fn slow_operation() -> String {
tokio::time::sleep(Duration::from_secs(10)).await;
String::from("done")
}
#[tokio::main]
async fn main() {
// Timeout after 2 seconds
match timeout(Duration::from_secs(2), slow_operation()).await {
Ok(result) => println!("Got result: {}", result),
Err(_) => println!("Operation timed out"),
}
}
Use tokio::select! to race multiple futures and act on whichever finishes first:
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<String>(1);
tokio::spawn(async move {
sleep(Duration::from_secs(3)).await;
tx.send("hello".to_string()).await.unwrap();
});
tokio::select! {
val = rx.recv() => {
println!("Received: {:?}", val);
}
_ = sleep(Duration::from_secs(5)) => {
println!("Timed out waiting for message");
}
}
}
Common Pitfalls
Forgetting to .await: An async function call without .await does nothing. The compiler warns about unused futures, so pay attention to those warnings.
Blocking the runtime: Never call std::thread::sleep or perform CPU-heavy work directly in an async context. It blocks the entire worker thread. Use tokio::task::spawn_blocking for blocking operations:
let result = tokio::task::spawn_blocking(|| {
// CPU-heavy or blocking I/O work here
std::thread::sleep(std::time::Duration::from_secs(1));
"done"
}).await.unwrap();
Holding a MutexGuard across .await: With std::sync::Mutex, holding the guard across an .await point can cause deadlocks because the task might be moved to a different thread. Either use tokio::sync::Mutex or restructure your code to drop the guard before the .await.
Send bounds: Futures spawned with tokio::spawn must be Send. If your future holds a non-Send type (like Rc or RefCell), it cannot be spawned. Use Arc and Mutex instead.
Wrapping Up
Async Rust with Tokio gives you high-performance concurrent I/O with the safety guarantees Rust is known for. The core concepts are straightforward:
async fnreturns a lazy future;.awaitdrives it to completion.tokio::join!runs futures concurrently;tokio::spawnruns them in background tasks.- Channels (
mpsc,oneshot,broadcast) enable communication between tasks. Arc<Mutex<T>>provides shared mutable state across tasks.- Use
timeoutandselect!for deadline handling and racing futures.
Start with join! for structured concurrency, graduate to spawn when you need independent tasks, and reach for channels when tasks need to communicate. The async ecosystem in Rust is mature and battle-tested — Tokio powers production systems at companies like Discord, Cloudflare, and AWS.
Related articles
- Rust Tokio vs async-std: Choosing a Rust Async Runtime
A practical comparison of Tokio and async-std covering performance, ecosystem, API design, and migration considerations for async Rust projects.
- Rust Rust async/await and Futures Explained
How Rust's async/await desugars into a state machine, what a Future actually is, and the runtime model that makes it efficient on real workloads.
- Rust Rust Tokio Runtime Explained
How Tokio schedules tasks, manages workers, and integrates with the OS to power asynchronous Rust programs efficiently across cores.
- Rust Rust Concurrency: Channels, Mutex, and Arc Patterns
Learn Rust concurrency with channels, Mutex, Arc, and thread-safe patterns. Covers mpsc, shared state, thread pools, and real-world examples.