Rust's async concurrency model is powerful, but it's easy to over-engineer or misuse. This guide gives you a set of practical checklists for busy developers who need to make sound decisions quickly. We'll cover runtime selection, shared state, channels, error handling, and testing—with concrete steps and trade-offs. No fluff, just actionable advice.
1. When to Choose Async vs. Threads: A Decision Checklist
Many teams start with threads because they're familiar, then switch to async when they hit scaling limits. But the choice isn't just about throughput. Ask these questions before you commit.
Checklist for async: You need to handle thousands of concurrent I/O-bound tasks (HTTP requests, database queries, file reads). Your workload is bursty with many idle waits. You want to avoid the memory overhead of a thread per connection. You're building a long-running server or service. If your project is CPU-bound or has a small number of tasks that run continuously, threads may be simpler and faster.
Checklist for threads: Your tasks are CPU-intensive with minimal I/O waiting. You have a fixed, small number of concurrent operations (e.g., 4–8 worker threads). You need predictable scheduling and don't want to deal with runtime pinning. You're writing a short-lived CLI tool where async setup overhead isn't justified.
For mixed workloads, consider using both: a thread pool for CPU work and async tasks for I/O. Rust's tokio::task::spawn_blocking lets you offload blocking work from the async runtime. The key is to match each pattern to the task's nature, not to force everything into one model.
A common mistake is using async for CPU-heavy computation, which can starve the runtime's event loop. If you have a compute-intensive loop, either move it to a dedicated thread or break it into smaller chunks with tokio::task::yield_now.
Quick Decision Matrix
Use this simple table to guide your initial choice:
| Workload Type | Recommended Pattern |
|---|---|
| Thousands of I/O connections | Async (tokio, async-std) |
| Few CPU-heavy tasks | Threads (std::thread) |
| Mixed I/O + CPU | Async with blocking thread pool |
| Short-lived CLI | Threads (simpler setup) |
2. Selecting an Async Runtime: What to Look For
Rust's async ecosystem has several runtimes, but tokio is the most popular. However, the right choice depends on your deployment environment and feature needs.
Checklist for runtime selection: Does your target platform support the runtime? Tokio works on Linux, macOS, Windows, and some embedded targets. Smol is lighter and works on more platforms but has fewer integrations. Do you need I/O driver support for your protocols? Tokio has built-in support for TCP, UDP, Unix sockets, and async filesystem operations. Smol relies on external crates for some features. Do you need a work-stealing scheduler? Tokio's multi-threaded scheduler distributes tasks across cores, which is great for throughput but adds complexity. For single-threaded workloads, tokio::runtime::current_thread or smol may be simpler.
Implementation tip: Start with the default multi-threaded tokio runtime. It's well-tested and handles most cases. If you hit issues with contention or latency, profile before switching. Many performance problems are caused by bad task design, not the runtime itself.
For embedded or WASM targets, consider embassy or wasm-bindgen-futures. These runtimes are specialized and avoid allocations or threading assumptions. Always check the runtime's minimum supported Rust version and maintenance status.
Runtime Comparison at a Glance
Here's a quick comparison of common runtimes:
| Runtime | Best For | Trade-offs |
|---|---|---|
| Tokio | Production servers, broad protocol support | Heavier binary, complex feature flags |
| Smol | Minimal dependencies, simple projects | Fewer integrations, less community tooling |
| Embassy | Embedded systems, no_std | Limited to single-threaded, niche |
3. Shared State: Arc vs. Channels vs. Lock-Free
Concurrency bugs often come from shared state. Rust's type system helps, but you still need to choose the right pattern.
Checklist for shared state: Do many tasks need read-only access to the same data? Use Arc<T> with RwLock if you need occasional writes, or Arc<Mutex<T>> for frequent writes. Do tasks need to pass ownership of data between each other? Use channels (tokio::sync::mpsc, flume). Channels decouple producers and consumers and can be bounded to control backpressure. Do you have a single writer and many readers? Consider tokio::sync::watch or broadcast. Do you need high throughput for a single value? Lock-free primitives like crossbeam::atomic::AtomicCell or std::sync::atomic can be faster but harder to compose.
Common pitfalls: Holding a lock across an .await point is a deadlock risk. In async code, never hold a std::sync::Mutex guard across .await; use tokio::sync::Mutex instead. Also, unbounded channels can grow memory indefinitely under load. Always prefer bounded channels with a reasonable capacity, and monitor the fill level.
For complex state machines, consider using actors (e.g., actix or a simple loop over a channel). Each actor owns its state and communicates via messages, which simplifies reasoning.
When to Avoid Shared State Altogether
If possible, design your system to avoid shared mutable state. Use message passing as the default. Rust's channels are efficient and safe. Shared state should be a deliberate optimization, not a first choice.
4. Error Handling in Async Contexts
Async error handling introduces challenges: backpressure, cancellation, and error propagation across tasks. Here's a checklist to handle errors robustly.
Checklist: Always handle errors from spawned tasks. Use JoinHandle to await the task and handle its Result. If you ignore the handle, errors are silently dropped. Use structured error types (e.g., anyhow or thiserror) to preserve context. For fallible streams, use StreamExt::try_for_each or TryStreamExt to propagate errors. Implement backpressure: if a downstream task fails, consider stopping upstream producers or buffering with a bounded channel. Use tokio::select! with cancellation tokens to handle timeouts and graceful shutdown. When a task is cancelled, clean up resources using Drop or tokio::spawn with a graceful shutdown signal.
Implementation tip: Create a central error handling function that logs the error, increments a metric, and decides whether to restart the task or abort. This avoids scattered unwrap() calls.
For retry logic, use a library like backoff or implement exponential backoff with jitter. Be careful not to retry indefinitely—set a maximum retry count and a deadline.
Edge Case: Cancellation Safety
Not all async code is cancellation-safe. If a task is cancelled mid-operation, resources may leak or state may be inconsistent. Use tokio::sync::CancellationToken to signal cancellation cooperatively. Avoid tokio::spawn for tasks that must complete; use a oneshot channel to wait for completion.
5. Testing Async Code: A Practical Checklist
Testing async code requires special setup: a runtime, controlled time, and deterministic execution. Here's how to write reliable tests.
Checklist: Use #[tokio::test] or #[async_std::test] to run async tests. For time-dependent code, use tokio::time::pause() to control virtual time. This makes tests fast and deterministic. Test error paths: simulate network failures, timeouts, and panics in spawned tasks. Use tokio::test(flavor = "current_thread") to avoid multi-threaded test flakiness. For integration tests, spin up a real server or use a mock service. Use tower-test for middleware testing.
Common mistake: Forgetting to advance time in tests. If you use tokio::time::sleep in production, your test must advance virtual time or use a real clock (which makes tests slow). Use tokio::test(flavor = "current_thread") with time::pause() to avoid waiting.
For property-based testing of concurrent code, consider loom (a model checker for Rust concurrency). Loom explores interleavings and can find deadlocks or data races that are rare in practice.
Test Structure Example
Here's a pattern for testing a channel-based worker:
#[tokio::test]
async fn test_worker_processes_messages() {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let handle = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
// process message
}
});
tx.send(42).await.unwrap();
drop(tx); // close channel
handle.await.unwrap();
}6. Performance Optimization: Profiling Before Optimizing
Premature optimization is common in async code. Before you tweak runtime settings or add complex data structures, profile your actual bottlenecks.
Checklist: Use tokio-console to visualize task lifetimes and resource usage. It shows which tasks are blocking, how long they wait, and where contention occurs. Measure with std::time::Instant or a tracing library like tracing with a timing layer. Look for tasks that spend most of their time waiting on locks or channels. Consider increasing the number of worker threads if tasks are CPU-bound and you have idle cores. But beware: too many threads can cause cache contention and context-switch overhead.
Common pitfalls: Using tokio::spawn for tiny tasks (e.g., a single I/O call) adds overhead. Inline the work instead. Using unbounded channels under load can cause memory exhaustion. Always set a bound and monitor the fill level. Using Mutex in hot paths—even tokio::sync::Mutex—can become a bottleneck. Consider using a lock-free structure or sharding.
For high-throughput systems, use a work-stealing scheduler (tokio's default) and pin tasks to specific cores using tokio::task::LocalSet or thread affinity. But start simple; only add complexity when profiling shows a clear need.
Performance Anti-Patterns
Here are three patterns that look efficient but hurt performance:
- Spawning a task per byte: Overhead of spawning outweighs work. Batch operations.
- Using
select!in a tight loop: Polling many futures repeatedly can burn CPU. Use channels orStreaminstead. - Blocking the runtime: Calling
std::thread::sleepor synchronous I/O in an async task blocks the entire thread. Usetokio::time::sleepand async I/O.
7. Mini-FAQ: Common Mistakes and Quick Fixes
Here are answers to frequent questions from Rust developers new to async concurrency.
Why is my async code slower than synchronous code?
Likely because you're spawning too many tasks or using the wrong runtime. Profile with tokio-console. Also, check if you're holding locks across .await points, causing contention.
Should I use tokio::sync::Mutex or std::sync::Mutex?
In async code, prefer tokio::sync::Mutex if you need to hold the lock across .await. For short critical sections that don't await, std::sync::Mutex is faster. Never hold a std::sync::Mutex guard across an .await—it can deadlock the entire thread.
How do I handle graceful shutdown?
Use a tokio::sync::CancellationToken or a channel that signals tasks to stop. When a task receives the signal, it should finish its current work and exit. Use tokio::signal for OS signals like SIGTERM.
Why do my tests hang?
You may have an infinite loop or a task that is waiting on a channel that never closes. Use tokio::time::timeout in tests to detect hangs. Also, ensure you drop senders to close channels.
What's the best way to limit concurrency?
Use a semaphore (tokio::sync::Semaphore) or a bounded channel. The semaphore pattern: acquire a permit before spawning a task, release it when done. This prevents unbounded task growth.
How do I share a database connection pool?
Use Arc<Pool> where Pool is from sqlx or diesel. Most connection pools are already async-safe. Clone the Arc into each task.
My async code panics with 'not currently running on a Tokio runtime.'
You're trying to use tokio APIs outside of a tokio context. Wrap your main with #[tokio::main] or manually create a runtime with Runtime::new(). Ensure all async code runs inside the runtime.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!