Synchronization Primitives Guide
Rust & Tokio Synchronization Primitives Guide
When building concurrent or asynchronous applications in Rust, choosing the right synchronization primitive is critical for performance and safety. Below is a comprehensive guide to the standard library (std) and Tokio synchronization tools.
📊 Quick Comparison Table
| Primitive | Ecosystem | Concurrency Pattern | Best Use Case |
|---|---|---|---|
std::sync::Arc |
Std | Shared Ownership | Sharing memory safely across multiple threads. Almost always wraps another primitive (e.g., Arc<Mutex<T>>). |
std::sync::Mutex |
Std | Exclusive Access | Brief, synchronous locks (pushing to a Vec, updating a number). Never hold across an .await. |
tokio::sync::Mutex |
Tokio | Async Exclusive Access | When you absolutely must hold a lock while waiting for an asynchronous operation (across an .await). |
std::sync::RwLock |
Std | Multi-Read / Single-Write | High-Read / Low-Write patterns. Allows dozens of threads to read simultaneously without blocking. |
tokio::sync::watch |
Tokio | SPMC State Broadcast | Broadcasting the latest configuration or status. Only the newest value is kept; slow receivers drop old data. |
tokio::sync::broadcast |
Tokio | MPMC Event Stream | Pub/Sub systems like chat rooms where multiple listeners must receive every message in order. |
1. The Standard Arc<Mutex<T>> (Synchronous Mutability)
Use std::sync::Mutex when you need to safely mutate data across threads, and the mutation is fast and synchronous.
Rule of Thumb: It is significantly faster than Tokio's Mutex. Use it as long as you do not call .await while the lock is locked.
use std::sync::{Arc, Mutex};
use std::thread;
// Wrap our data in a Mutex (for safe mutation) and Arc (for safe sharing)
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
// 1. Lock the mutex
let mut num = counter_clone.lock().unwrap();
// 2. Modify the value quickly
*num += 1;
// 3. Lock is automatically released here when `num` goes out of scope
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap());
2. The tokio::sync::Mutex (Async Mutability)
Use Tokio's Mutex only when your lock must remain active while the thread yields to the async runtime (i.e., across an .await boundary). It is heavier because it requires internal bookkeeping to allow other tasks to run on the thread while the current task sleeps.
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let shared_state = Arc::new(Mutex::new(String::from("Idle")));
let state_clone = Arc::clone(&shared_state);
tokio::spawn(async move {
// Acquire the async lock
let mut state = state_clone.lock().await;
*state = String::from("Fetching Data...");
// SAFE: Holding a Tokio Mutex across an .await point
sleep(Duration::from_secs(2)).await;
*state = String::from("Complete");
});
}
3. The Arc<RwLock<T>> (High-Read / Low-Write)
If you have a configuration struct or global state that is read constantly by many UI components or threads, but only updated occasionally, RwLock maximizes performance by allowing parallel reads.
use std::sync::{Arc, RwLock};
use std::thread;
let config = Arc::new(RwLock::new(vec!["dark_mode", "auto_save"]));
// READER THREAD 1
let config_read1 = Arc::clone(&config);
thread::spawn(move || {
let read_guard = config_read1.read().unwrap();
println!("Thread 1 sees: {:?}", *read_guard);
});
// READER THREAD 2 (Can run at the exact same time as Thread 1)
let config_read2 = Arc::clone(&config);
thread::spawn(move || {
let read_guard = config_read2.read().unwrap();
println!("Thread 2 sees: {:?}", *read_guard);
});
// WRITER THREAD (Will block until all readers are finished)
let config_write = Arc::clone(&config);
thread::spawn(move || {
let mut write_guard = config_write.write().unwrap();
write_guard.push("telemetry_enabled");
});
4. Tokio Watch Channel (tokio::sync::watch)
A highly optimized Single-Producer, Multi-Consumer (SPMC) channel. Perfect for broadcasting state where receivers only care about the latest data.
use tokio::sync::watch;
#[tokio::main]
async fn main() {
// Initialize with a default state
let (tx, mut rx1) = watch::channel("Offline");
// Receivers can be cloned incredibly cheaply
let mut rx2 = rx1.clone();
tokio::spawn(async move {
// Wait for the state to change
while rx1.changed().await.is_ok() {
println!("UI Component 1 Update: {}", *rx1.borrow());
}
});
tokio::spawn(async move {
while rx2.changed().await.is_ok() {
println!("UI Component 2 Update: {}", *rx2.borrow());
}
});
// Sender updates the state instantly
tx.send("Connecting...").unwrap();
tx.send("Online").unwrap();
}