Build Async Systems with Rust Tokio Runtime
Build async systems in Rust using the Tokio runtime with tasks, channels, select, synchronization primitives, graceful shutdown, and structured concurrency patterns.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Tokio is Rust’s most widely used async runtime. It provides a multi-threaded scheduler, I/O driver, timer, and synchronization primitives. This approach handles spawning tasks, communicating via channels, using select! for multiplexing, shared state with Arc<Mutex<T>>, graceful shutdown with CancellationToken, and structured concurrency with tokio::task::JoinSet.
When to Use This
- Network servers and clients (HTTP, gRPC, WebSocket)
- Concurrent data processing pipelines
- Applications needing high throughput with low overhead
- Systems requiring safe shared state across async tasks
Prerequisites
- Rust 1.75+
tokiocrate withfullfeatures
Solution
1. Basic Tokio Application
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
println!("Starting Tokio runtime");
// Spawn two concurrent tasks
let task1 = tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
println!("Task 1 done");
42
});
let task2 = tokio::spawn(async {
sleep(Duration::from_millis(50)).await;
println!("Task 2 done");
"hello"
});
// Await both tasks
let result1 = task1.await.unwrap();
let result2 = task2.await.unwrap();
println!("Results: {} / {}", result1, result2);
}
2. Channels — mpsc, oneshot, broadcast
use tokio::sync::{mpsc, oneshot, broadcast};
#[tokio::main]
async fn main() {
// --- mpsc: multi-producer, single-consumer ---
let (tx, mut rx) = mpsc::channel::<String>(32);
for i in 0..5 {
let tx = tx.clone();
tokio::spawn(async move {
tx.send(format!("message-{}", i)).await.unwrap();
});
}
drop(tx); // Drop original sender so rx closes after all clones drop
while let Some(msg) = rx.recv().await {
println!("Received: {}", msg);
}
// --- oneshot: single value, single use ---
let (otx, orx) = oneshot::channel::<i32>();
tokio::spawn(async move {
otx.send(99).unwrap();
});
let val = orx.await.unwrap();
println!("Oneshot value: {}", val);
// --- broadcast: multi-producer, multi-consumer ---
let (btx, mut brx1) = broadcast::channel::<String>(16);
let mut brx2 = btx.subscribe();
btx.send("broadcast msg".to_string()).unwrap();
println!("Receiver 1: {}", brx1.recv().await.unwrap());
println!("Receiver 2: {}", brx2.recv().await.unwrap());
}
3. select! Macro — Multiplexing Async Operations
use tokio::select;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let (tx1, mut rx1) = mpsc::channel::<String>(16);
let (tx2, mut rx2) = mpsc::channel::<String>(16);
tokio::spawn(async move {
sleep(Duration::from_millis(50)).await;
tx1.send("from channel 1".to_string()).await.unwrap();
});
tokio::spawn(async move {
sleep(Duration::from_millis(30)).await;
tx2.send("from channel 2".to_string()).await.unwrap();
});
// Process whichever channel is ready first
loop {
select! {
msg1 = rx1.recv() => {
match msg1 {
Some(m) => println!("Channel 1: {}", m),
None => break,
}
}
msg2 = rx2.recv() => {
match msg2 {
Some(m) => println!("Channel 2: {}", m),
None => break,
}
}
_ = sleep(Duration::from_secs(2)) => {
println!("Timeout — no messages for 2s");
break;
}
}
}
}
4. Shared State with Arc and Mutex
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration};
struct Counter {
count: i32,
}
#[tokio::main]
async fn main() {
let counter = Arc::new(Mutex::new(Counter { count: 0 }));
let mut handles = Vec::new();
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(tokio::spawn(async move {
sleep(Duration::from_millis(10)).await;
let mut c = counter.lock().await;
c.count += 1;
c.count
}));
}
let mut results = Vec::new();
for handle in handles {
results.push(handle.await.unwrap());
}
println!("Final count: {}", counter.lock().await.count);
println!("All results: {:?}", results);
}
5. JoinSet — Structured Concurrency
use tokio::task::JoinSet;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let mut set = JoinSet::new();
for i in 0..20 {
set.spawn(async move {
sleep(Duration::from_millis(10 * i as u64)).await;
i * i
});
}
// Collect results as tasks complete (out of order)
let mut results = Vec::new();
while let Some(res) = set.join_next().await {
results.push(res.unwrap());
}
results.sort();
println!("Squared results: {:?}", results);
}
6. Graceful Shutdown with CancellationToken
use tokio::sync::CancellationToken;
use tokio::time::{sleep, Duration};
async fn worker(id: usize, token: CancellationToken) {
loop {
tokio::select! {
_ = token.cancelled() => {
println!("Worker {} shutting down gracefully", id);
break;
}
_ = sleep(Duration::from_millis(200)) => {
println!("Worker {} doing work...", id);
}
}
}
}
#[tokio::main]
async fn main() {
let token = CancellationToken::new();
// Spawn 3 workers
let mut handles = Vec::new();
for id in 0..3 {
let token = token.clone();
handles.push(tokio::spawn(worker(id, token)));
}
// Run for 1 second, then cancel
sleep(Duration::from_secs(1)).await;
println!("Cancelling all workers...");
token.cancel();
for handle in handles {
handle.await.unwrap();
}
println!("All workers stopped");
}
7. TCP Server with Tokio
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Server listening on :8080");
loop {
let (socket, addr) = listener.accept().await?;
println!("Connection from {}", addr);
tokio::spawn(async move {
let (reader, mut writer) = socket.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break, // EOF
Ok(_) => {
let response = format!("Echo: {}", line);
if writer.write_all(response.as_bytes()).await.is_err() {
break;
}
}
Err(_) => break,
}
}
println!("Connection {} closed", addr);
});
}
}
8. Semaphore — Bounded Concurrency
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let semaphore = Arc::new(Semaphore::new(3)); // Max 3 concurrent
let mut handles = Vec::new();
for i in 0..10 {
let sem = Arc::clone(&semaphore);
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap();
println!("Task {} acquired permit", i);
sleep(Duration::from_millis(100)).await;
println!("Task {} releasing permit", i);
// _permit dropped here, releases the slot
}));
}
for handle in handles {
handle.await.unwrap();
}
}
How It Works
- Tokio Runtime: The
#[tokio::main]macro sets up a multi-threaded runtime with a work-stealing scheduler. Tasks are lightweight (allocated on the heap) and cooperatively scheduled on a pool of OS threads. - Tasks:
tokio::spawncreates a task that runs concurrently. Each task is a green thread — the runtime multiplexes thousands of tasks onto a small number of OS threads. - Channels:
mpscis for multi-producer single-consumer communication (like Go’s channels).oneshotsends a single value once.broadcastfans out messages to multiple subscribers. select!: Like Go’sselect, it waits on multiple async operations. When one completes, the corresponding branch executes. Other branches are dropped.Arc<Mutex<T>>:Arcprovides shared ownership across threads.tokio::sync::Mutexprovides async-aware locking — the task yields while waiting for the lock instead of blocking the OS thread.JoinSet: Manages a group of spawned tasks.join_nextreturns results as tasks complete, in any order. Aborting the set cancels all remaining tasks.CancellationToken: A cooperative cancellation signal.cancelled()returns a future that completes whencancel()is called. Used withselect!to break out of loops.
Variants
RwLock for Read-Heavy Workloads
use std::sync::Arc;
use tokio::sync::RwLock;
#[tokio::main]
async fn main() {
let data = Arc::new(RwLock::new(vec![1, 2, 3]));
// Multiple readers can hold the lock simultaneously
let r1 = Arc::clone(&data);
let r2 = Arc::clone(&data);
let h1 = tokio::spawn(async move {
let guard = r1.read().await;
println!("Reader 1: {:?}", *guard);
});
let h2 = tokio::spawn(async move {
let guard = r2.read().await;
println!("Reader 2: {:?}", *guard);
});
h1.await.unwrap();
h2.await.unwrap();
// Writer gets exclusive access
let mut guard = data.write().await;
guard.push(4);
println!("After write: {:?}", *guard);
}
Task::yield_now for Cooperative Scheduling
#[tokio::main]
async fn main() {
// Long-running CPU-bound task that yields periodically
tokio::spawn(async {
let mut sum: u64 = 0;
for i in 0..1_000_000 {
sum += i;
if i % 10_000 == 0 {
tokio::task::yield_now().await;
}
}
println!("Sum: {}", sum);
});
// This task can run between yields
tokio::spawn(async {
println!("Concurrent task running");
});
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
Interval for Periodic Tasks
use tokio::time::{interval, Duration};
#[tokio::main]
async fn main() {
let mut tick = interval(Duration::from_millis(250));
for i in 0..5 {
tick.tick().await;
println!("Tick {}", i);
}
}
Best Practices
-
For a deeper guide, see Build Async Pipelines with C# async/await and Task.Run.
-
Use
tokio::sync::Mutexoverstd::sync::Mutex: The std Mutex blocks the OS thread while waiting. Tokio’s Mutex yields the task, letting other tasks run on the same thread. -
Prefer channels over shared state: Channels provide a cleaner concurrency model. Use
mpscfor producer-consumer,broadcastfor pub-sub. -
Always handle
JoinError:task.awaitreturnsResult<T, JoinError>. A task can panic — unwrap panics propagate. Useunwrap()only in examples. -
Use
JoinSetfor structured concurrency: It ensures all tasks are awaited or aborted. Aborting aJoinSetcancels all remaining tasks. -
Use
CancellationTokenfor shutdown: Checktoken.cancelled()inselect!loops. This provides clean, cooperative shutdown. -
Avoid
std::sync::Mutexin async code: Holding a std Mutex across.awaitpoints can deadlock if another task on the same thread tries to acquire it.
Common Mistakes
- Blocking in async context:
std::thread::sleep,std::fs::read, or CPU-heavy loops block the runtime thread. Usetokio::time::sleep,tokio::fs::read, andspawn_blockingfor CPU work. - Forgetting to drop senders: If you don’t drop all senders,
rx.recv()hangs forever. Drop the original sender after spawning producers. - Holding
std::sync::Mutexacross.await: This can deadlock. The lock is held while the task is suspended, and another task on the same thread may try to acquire it. - Not handling task panics: A panicked task returns
JoinError. If youunwrap()without checking, the panic propagates to the awaiting task. - Using
tokio::spawnwithout awaiting: Spawned tasks run in the background. Ifmainexits before they complete, they are cancelled. UseJoinSetor store handles.
FAQ
What is the difference between tokio::spawn and std::thread::spawn?
tokio::spawn creates a lightweight async task (green thread) managed by the Tokio runtime. std::thread::spawn creates an OS thread. Tasks are much cheaper — you can have millions of tasks but only thousands of threads.
When should I use spawn_blocking?
Use tokio::task::spawn_blocking for CPU-bound work or blocking I/O (like std::fs operations). It runs the closure on a dedicated blocking thread pool, keeping the async runtime responsive.
Should I use tokio::sync::Mutex or std::sync::Mutex?
In async code, prefer tokio::sync::Mutex. It yields the task while waiting for the lock. Use std::sync::Mutex only when the lock is held briefly and never across an .await point.
How do I limit the number of concurrent tasks?
Use tokio::sync::Semaphore. Acquire a permit before starting work and drop it when done. The semaphore ensures at most N tasks run concurrently.
What is JoinSet and why use it?
JoinSet is a collection of spawned tasks. It provides join_next to get results as they complete and abort_all to cancel all tasks. It ensures no tasks are leaked — when the JoinSet is dropped, all remaining tasks are aborted.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Concurrent Patterns with Go Goroutines and Channels
Build concurrent systems in Go using goroutines, channels, select statements, worker pools, fan-out/fan-in, pipelines, context cancellation, and rate limiting with tickers.
RecipeConcurrent Async Tasks with asyncio.gather and Task Groups
Execute multiple async operations concurrently in Python using asyncio.gather, asyncio.TaskGroup, error handling with return_exceptions, timeouts, and semaphores for rate limiting.
GuideConcurrency Patterns Guide
A guide to common concurrency patterns and what works for writing safe, efficient concurrent code.
RecipeBuild Async Pipelines with C# async/await and Task.Run
Build async pipelines in C# using async/await, Task.Run, Task.WhenAll, Task.WhenAny, CancellationTokenSource, Channels, and Parallel.ForEachAsync for concurrent I/O and CPU work.
RecipeScale Concurrent Applications with Java Virtual Threads
Scale Java applications with virtual threads from Project Loom. Use Thread.ofVirtual, Executors.newVirtualThreadPerTaskExecutor, structured concurrency, and scoped values.
GuideComplete Guide to Python Asyncio
Master asynchronous Python programming with asyncio. Covers coroutines, tasks, event loops, async/await, gather, semaphores, queues, HTTP clients, websockets, and debugging async code.