Rust Concurrency: Fearless Concurrency Explained
Concurrency is notoriously difficult to get right. Data races, deadlocks, and subtle timing bugs plague multi-threaded code in most languages. Rust’s approach — “fearless concurrency” — means the compiler catches data races at compile time. If your code compiles, it is free from data races.
This guide covers threads, message passing, shared state, the Send and Sync traits, and async/await in Rust.
Threads
Rust’s standard library provides OS threads through std::thread:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("Thread: {}", i);
thread::sleep(Duration::from_millis(100));
}
});
for i in 1..5 {
println!("Main: {}", i);
thread::sleep(Duration::from_millis(100));
}
handle.join().unwrap(); // Wait for the thread to finish
---thread::spawn takes a closure and returns a JoinHandle. Calling join() blocks the current thread until the spawned thread finishes. Without join(), the main thread might exit before the spawned thread completes.
Moving Data into Threads
Closures can capture variables from their environment, but the borrow checker enforces safety:
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Vector: {:?}", v);
---); // v is moved into the closure
// println!("{:?}", v); // Error: v has been moved
handle.join().unwrap();The move keyword forces the closure to take ownership of captured variables. This prevents the main thread from modifying the vector while the thread is reading it.
Message Passing
Rust supports the actor model through channels. One thread sends messages, another receives them. Channels are provided by std::sync::mpsc (multiple producer, single consumer):
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hello");
tx.send(val).unwrap();
// println!("{}", val); // Error: val has been moved
});
let received = rx.recv().unwrap();
println!("Got: {}", received);
---send() moves ownership of the value into the channel. The receiving end can call recv() (blocking) or try_recv() (non-blocking). The Sender implements Clone, so multiple threads can send to the same channel:
let (tx, rx) = mpsc::channel();
for i in 0..5 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(format!("Message from thread {}", i)).unwrap();
});
---
// Drop the original sender to close the channel
drop(tx);
for received in rx {
println!("{}", received);
---When all senders are dropped, the iterator returns None and the loop ends. This pattern is clean for fan-out workloads.
Shared State with Mutex
A mutex (mutual exclusion) allows only one thread to access data at a time:
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
} // Mutex is automatically unlocked when num goes out of scope
println!("m = {:?}", m);
---lock() returns a MutexGuard smart pointer that implements Deref and Drop. When the guard goes out of scope, the mutex is automatically unlocked. This is RAII (Resource Acquisition Is Initialization) — the same pattern used in C++.
Sharing a Mutex Across Threads
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap()); // 10
---Arc<T> (Atomic Reference Counting) enables shared ownership across threads. It works like Rc<T> but is thread-safe. Without Arc, Rust would refuse to share ownership — each thread needs its own access to the counter.
Send and Sync Traits
Rust’s concurrency guarantees are built on two marker traits:
Send— A type can be transferred across thread boundaries. Most types areSend. Exceptions includeRc<T>(not thread-safe) and raw pointers.Sync— A type can be shared across threads via reference. A typeTisSyncif&TisSend. Most types areSync. Exceptions includeRefCell<T>andCell<T>.
These traits are automatically implemented by the compiler based on field types. You can manually implement them (using unsafe) but must verify safety yourself.
use std::rc::Rc;
use std::sync::Arc;
fn is_send<T: Send>() {}
fn is_sync<T: Sync>() {}
is_send::<Rc<i32>>(); // Compile error: Rc is not Send
is_send::<Arc<i32>>(); // OK
is_sync::<i32>(); // OK
is_sync::<Mutex<i32>>(); // OK
Async/Await
For I/O-bound concurrency (network requests, file operations), async/await provides efficient single-threaded concurrency:
use tokio::time::{sleep, Duration};
async fn do_work(id: u32) -> String {
sleep(Duration::from_millis(100)).await;
format!("Task {} done", id)
---
#[tokio::main]
async fn main() {
let tasks = vec![
do_work(1),
do_work(2),
do_work(3),
];
let results = futures::future::join_all(tasks).await;
for r in results {
println!("{}", r);
}
---Async functions return Future values. The await keyword yields control back to the runtime until the future is ready. Unlike threads, async tasks can run on a single OS thread, making them efficient for thousands of concurrent I/O operations.
The Async Runtime
Rust’s standard library provides the Future trait but no runtime. You choose a runtime — Tokio is the most popular:
[dependencies]
tokio = { version = "1", features = ["full"] }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?;
loop {
let (socket, addr) = listener.accept().await?;
tokio::spawn(async move {
println!("New connection from {}", addr);
// handle socket...
});
}
---Each incoming connection spawns a lightweight task. With threads, you might handle 10,000 concurrent connections before exhausting system resources. With async, you can handle millions.
Choosing Between Threads and Async
| Use Case | Approach |
|---|---|
| CPU-bound work | Threads |
| I/O-bound work (network, disk) | Async |
| Shared state with frequent writes | Threads + Arc<Mutex> |
| Many concurrent connections | Async (Tokio) |
| Simplicity | Threads |
| High throughput I/O | Async |
For CPU-intensive workloads, threads let you utilize multiple cores. For I/O workloads where tasks spend most of their time waiting, async is more efficient because tasks are paused without blocking a thread.
Best Practices
- Prefer message passing over shared state when possible — channels make ownership flow explicit
- Scope locks tightly — acquire a mutex, do the work, release it. Never hold a lock across an
.awaitpoint - Use
rayonfor data parallelism — the rayon crate provides effortless parallel iterators - Test under concurrency —
loomis a testing tool for concurrent Rust code - Understand
SendandSync— they are the foundation of Rust’s concurrency safety
Summary
Rust’s concurrency model eliminates data races at compile time. Threads provide OS-level parallelism, channels enable safe message passing, and Arc<Mutex<T>> allows shared mutable state with automatic lock management. For high-concurrency I/O workloads, async/await with Tokio provides efficient single-threaded concurrency. The Send and Sync traits ensure that unsafe concurrent patterns are caught by the compiler.
The Send and Sync Traits
Rust’s type system guarantees thread safety through two marker traits. Send indicates a type can be safely transferred across thread boundaries. Sync indicates a type can be safely shared between threads via reference. Most types are automatically Send and Sync, but types with interior mutability (like RefCell and Rc) are not. Understanding these traits is essential for writing correct multi-threaded code — the compiler will reject programs that violate these constraints at compile time.
Fearless Concurrency Patterns
Rust’s ownership model eliminates data races at compile time. Common patterns include: Arc<Mutex<T>> for shared mutable state, channels (std::sync::mpsc) for message passing, Rayon for data parallelism, and atomic types for lock-free operations on primitive values. These patterns cover the vast majority of concurrency needs without runtime overhead or garbage collection.
Rust in Production: Case Studies
Rust has been adopted in production by major technology companies for performance-critical and safety-critical systems. Mozilla uses Rust in the Firefox browser engine (Servo/Quantum) for improved memory safety and performance in CSS layout and rendering. Dropbox rewrote their sync engine core in Rust, achieving significant performance improvements and eliminating entire classes of bugs. Cloudflare uses Rust for edge computing infrastructure, including their Pingora HTTP proxy that handles 10% of the world’s internet traffic. Figma rewrote their multiplayer editing engine in Rust, achieving predictable performance and eliminating crashes. Discord used Rust for their read states service, handling millions of concurrent connections with minimal resources. These case studies demonstrate Rust’s value in systems where performance, reliability, and memory safety are critical. The common pattern: rewrite performance-critical components in Rust while maintaining the rest of the system in the original language, proving that incremental adoption works in practice.
Embedded Systems Programming
Rust is gaining traction in embedded systems for its memory safety guarantees without a garbage collector or runtime. The cortex-m-quickstart template provides a starting point for ARM Cortex-M microcontrollers. The RTIC framework provides real-time interrupt-driven concurrency. Embassy offers an async embedded runtime with USB, networking, and BLE support. Rust’s type system prevents common embedded errors like misconfiguring peripherals, incorrect register access, and data races between interrupt handlers and main code. The embedded ecosystem continues to mature with HAL implementations for major microcontroller families from STM, Nordic, and Espressif.
FAQ
What background knowledge is recommended? A basic understanding of programming fundamentals will help, but many concepts are explained from first principles.
How can I practice these skills? Start with small projects, contribute to open source, and build a portfolio. Consistent practice is more effective than occasional deep dives.
What tools do I need to get started? Most topics require only a text editor, the relevant runtime, and package manager. Specific tool recommendations are included throughout.
For a comprehensive overview, read our article on Getting Started With Rust.
For a comprehensive overview, read our article on Rust Async Programming.