Message Passing
Message Passing vs. Shared State
Message passing (mpsc channels / actor tasks) and shared state (Arc<Mutex<T>>, RwLock<T>, Atomic*) are dual concurrency primitives: you can implement a channel using a Mutex<VecDeque<T>> + Condvar, and you can implement shared state using an actor thread that owns T and processes commands over a channel. Choosing between them depends on data ownership topology, latency vs. decoupling, and contention patterns.
Side-by-Side Architectural Comparison
| Dimension | Message Passing (mpsc / Actor Model) |
Shared State (Arc<Mutex<T>> / RwLock<T> / Atomic*) |
|---|---|---|
| Data ownership | Single owner at all times (transfers ownership or routes to 1 actor thread) | Shared ownership across $N$ threads simultaneously |
| Locking & Deadlocks | No multi-lock ordering needed; lock-free enqueue on unbounded channels | Requires disciplined lock ordering if acquiring $>1$ lock |
| Cancellation & Shutdown | Built-in: dropping all Senders (or Receiver) propagates EOF automatically |
Manual: requires a separate AtomicBool or Condvar shutdown flag |
| Read Latency | Higher (requires sending a request message + context switch to actor thread + awaiting reply) | Lowest (RwLock::read or Mutex::lock reads in place on the calling thread in nanoseconds) |
| Memory Overhead | Each queued message occupies buffer memory until processed | Constant memory (single in-place instance of T) |
| Panic Isolation | If actor panics, channel returns SendError / RecvError cleanly |
If writer panics while holding lock, Mutex / RwLock becomes poisoned |
When to Choose Message Passing vs. Shared State:
Use Message Passing When:
- Work flows through stages (Producer -> Worker -> Sink)
- One dedicated thread owns an external resource (Socket, SQLite conn, UI loop)
- You want automatic backpressure (`sync_channel`) and EOF shutdown cascading
Use Shared State When:
- Many threads need low-latency in-place reads/updates on a shared cache or index
- State is a simple counter/flag (`AtomicU64`, `AtomicBool`) or write-once config (`OnceLock`)
- Copying or context-switching to an actor thread per lookup is too expensive
Hybrid Architecture: Combining Both in Real Systems
Production Rust systems routinely combine both models rather than treating them as mutually exclusive:
- Shared state (
Arc<RwLock<RoutingTable>>,OnceLock<Config>,AtomicU64) is used on the hot request path so any worker thread can read configuration, routing rules, and increment counters in nanoseconds without waiting on an actor queue. - Message passing (
mpsc::sync_channel) is used to offload asynchronous background jobs, batch disk writes, and coordinate graceful shutdown across worker threads:
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::thread;
struct AppTelemetry {
// 1. Shared-state atomic counter for zero-wait inline reads/increments:
total_requests: AtomicU64,
// 2. Message-passing channel for offloading slow audit-log formatting:
audit_tx: mpsc::Sender<String>,
}
fn main() {
let (audit_tx, audit_rx) = mpsc::channel::<String>();
let telemetry = AppTelemetry {
total_requests: AtomicU64::new(0),
audit_tx,
};
// Background audit-log consumer thread:
let logger_thread = thread::spawn(move || {
let mut logged = Vec::new();
for entry in audit_rx {
logged.push(entry);
}
logged
});
thread::scope(|s| {
for worker in 0..3 {
let tel = &telemetry;
s.spawn(move || {
let seq = tel.total_requests.fetch_add(1, Ordering::Relaxed) + 1;
tel.audit_tx
.send(format!("worker {worker} handled request (seq={seq})"))
.unwrap();
});
}
});
// Drop `telemetry` (and its `audit_tx`) so `logger_thread` finishes:
let final_count = telemetry.total_requests.load(Ordering::Relaxed);
drop(telemetry);
let logs = logger_thread.join().unwrap();
println!("total_requests (atomic): {}", final_count);
println!("audit logs collected: {} entries", logs.len());
}
total_requests (atomic): 3
audit logs collected: 3 entries