Language and Runtime Concurrency Models: Rust
Rust's compiler checks concurrency rules that other languages leave to discipline: a value can be sent to another thread only if its type is safe to send, shared only if it is safe to share, and mutated only through a lock or atomic when shared.
Learning Objectives
Apply ownership and borrowing to concurrent code; understand Send and Sync.
Use std::thread, Arc, Mutex, RwLock, channels, atomics and scoped threads.
Understand async/await and futures, executors and runtimes (Tokio/async-std are third-party), pinning, cancellation by dropping futures, async versus blocking mutexes, shared-state versus message-passing design, data-race guarantees and unsafe caveats, and Loom-style testing.
Plain-Language Explanation
Rust's compiler checks concurrency rules that other languages leave to discipline: a value can be sent to another thread only if its type is safe to send, shared only if it is safe to share, and mutated only through a lock or atomic when shared. Data races in safe Rust are compile-time errors — though deadlocks, logic races and async pitfalls remain your responsibility.
All Rust examples in this lesson are shown for comparison and were not run — there is no Rust toolchain on the build machine. They use stable Rust 1.80-era APIs.
Precise Technical Explanation
Ownership and borrowing in concurrent code thread::spawn requires a 'static closure that owns its data (move); borrowing locals is possible with scoped threads. A value moved into a thread can no longer be used by the parent.
Send and Sync Auto traits: T: Send — ownership may move to another thread; T: Sync — &T may be shared (equivalently &T: Send). Rc<T> is neither (non-atomic count); Arc<T> is both when T: Send + Sync; Cell/RefCell are Send but not Sync; MutexGuard is not Send.
spawn returns a JoinHandle<T>; join() returns Result<T, Box<dyn Any + Send>> — Err if the thread panicked (panic propagation).
Arc, Mutex, RwLock Arc<Mutex<T>> is the shared-mutable-state idiom; the data lives *inside* the mutex, so it cannot be accessed without locking; guards unlock on drop. Lock poisoning: if a thread panics while holding a lock, later lock() returns Err(PoisonError).
std::sync::mpsc (multi-producer, single-consumer; sync_channel(n) is bounded), crossbeam channels (MPMC, select!), Tokio's async channels.
std::sync::atomic::{AtomicUsize, AtomicBool, …} with Ordering::{Relaxed, Acquire, Release, AcqRel, SeqCst} — the C++20 model.
std::thread::scope (Rust 1.63) lets threads borrow from the enclosing stack; all are joined at the end of the scope — structured concurrency for threads.
Async/await and futures async fn returns an impl Future; futures are inert until polled; .await polls; the compiler generates a state machine.
Executors and runtimes (Tokio/async-std as third-party runtimes)
The standard library has no executor. Tokio (most common), async-std, smol and embassy (embedded) provide schedulers, timers and I/O drivers. Tokio's multi-threaded runtime work-steals across cores.
Async state machines may be self-referential, so once polled they must not move in memory: Pin<&mut F>, Box::pin, pin!.
Cancellation by dropping futures and runtime-specific behavior
Dropping a future cancels it at its last .await point — immediately and silently. Code after an .await may never run, so state must be consistent at every .await ("cancellation safety"). tokio::select! drops the losing branches; JoinHandle::abort cancels spawned tasks.
Async mutex versus blocking mutex std::sync::Mutex is fine in async code if never held across .await (and is faster); tokio::sync::Mutex can be held across .await but is slower. Holding a std guard across .await makes the future non-Send (compile error on multi-threaded runtimes) — the compiler catches a classic bug.
Shared-state versus message-passing design Both are first-class; channels move ownership (no sharing at all), Arc<Mutex<T>> shares with enforced locking.
Data-race safety guarantees and unsafe-code caveats Safe Rust cannot have data races; unsafe code (raw pointers, unsafe impl Send/Sync) can, and must uphold the invariants manually. Deadlocks and logical race conditions are still possible in safe Rust.
Loom-style concurrency testing concept The loom crate runs a test under a model checker that explores all interleavings (and weak-memory behaviors) of small concurrent code; shuttle does randomised scheduling; Miri detects undefined behavior in unsafe code.
Key Terms
Send / Sync Safe to move / share across threads Shared ownership + exclusive access Mutex marked after a panic while locked Thread that may borrow from its scope Runtime that polls futures
Correctness when a future is dropped at any .await
Diagram or Execution Timeline
playgroundSyntax and API Overview
The specification's thread example (not run):
The closure's return value travels through the JoinHandle; join() returns Err if the thread panicked (expect turns that into a panic in main). It would print 42. The closure owns everything it uses — had it captured a local by reference, the compiler would reject it (the thread might outlive the local).
use std::thread;
fn main() {
let handle = thread::spawn(|| {
20 + 22
});
let result = handle.join().expect("worker panicked");
println!("{result}");
}Complete Code Example
Shared state, channels, scoped threads and atomics (not run):
Async with Tokio (third-party runtime; not run):
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
fn main() {
// shared state: the data lives inside the mutex
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..4 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..10_000 {
*c.lock().unwrap() += 1; // guard unlocks when it goes out of scope
}
}));
}
for h in handles { h.join().unwrap(); }
println!("Arc<Mutex<i32>> total = {}", *counter.lock().unwrap()); // 40000
// message passing: ownership moves through the channel
let (tx, rx) = mpsc::sync_channel::<String>(2); // bounded: senders block when full
for id in 0..3 {
let tx = tx.clone();
thread::spawn(move || tx.send(format!("hello from {id}")).unwrap());
}
drop(tx); // close our sender so the iterator can end
let mut msgs: Vec<String> = rx.iter().collect();
msgs.sort();
println!("{msgs:?}");
// scoped threads may borrow local data; all are joined at the end of the scope
let
…use std::time::Duration;
use tokio::time::{sleep, timeout};
#[tokio::main]
async fn main() {
let a = tokio::spawn(async { sleep(Duration::from_millis(200)).await; "a" });
let b = tokio::spawn(async { sleep(Duration::from_millis(100)).await; "b" });
let (ra, rb) = tokio::join!(a, b); // ~200 ms, not 300
println!("{} {}", ra.unwrap(), rb.unwrap());
match timeout(Duration::from_millis(50), sleep(Duration::from_secs(5))).await {
Ok(_) => println!("finished"),
Err(_) => println!("timed out: the inner future was dropped, i.e. cancelled"),
}
}Line-by-Line Explanation
Arc gives shared ownership; Mutex<i32> owns the integer, so it is reachable only via lock().
sync_channel(2) is bounded; dropping the original tx lets rx.iter() finish when all clones are gone.
thread::scope threads borrow data and total safely because the scope joins them before returning.
The commented Rc line shows the compile-time Send check.
Tokio: spawn schedules tasks on the runtime; join! awaits both; timeout drops the inner future when time runs out — dropping *is* cancellation.
Output
Not run (no Rust toolchain on the build machine). By the language's semantics, the first program prints 40000, the three greetings in sorted order and 21; the Tokio program prints a b and the timeout line.
Interleaving or State Trace
Scoped threads: three threads sum [1,2], [3,4], [5,6] in any order; fetch_add makes the total 21 regardless of order; the scope end joins all three before total is read.
Correctness and Safety Analysis
The type system guarantees: no data races (only Sync data is shared; mutation needs locks/atomics), no use-after-free across threads (ownership/lifetimes), and joins before borrows end (scoped threads). Not guaranteed: deadlock freedom, cancellation safety, and correctness of unsafe code.
Common Mistakes and Misconceptions
Holding a std::sync::MutexGuard across .await.
Blocking calls (std::thread::sleep, blocking I/O) inside async tasks — use spawn_blocking.
Assuming code after .await always runs (dropped futures).
unsafe impl Send to silence the compiler.
Ignoring lock poisoning.
Failure Modes and Debugging Steps
Deadlocks: parking_lot's deadlock detection, thread dumps with gdb; async stalls: tokio-console; races in unsafe code: Miri, loom, TSan on nightly.
Performance and Scalability Implications
Zero-cost abstractions: Mutex/Arc compile to the platform primitives; Rayon offers work-stealing data parallelism (par_iter); Tokio scales async I/O across cores.
Security Considerations
Memory-safe concurrency removes a major vulnerability class; audit unsafe blocks and FFI boundaries carefully.
Platform and Runtime Caveats
Scoped threads: Rust 1.63+. std::sync::LazyLock: 1.80+.
Async runtimes are third-party and not interchangeable (Tokio types need the Tokio runtime).
Real-World Applications
Firefox's Stylo (parallel CSS with Rayon), Tokio-based services (Discord, Cloudflare), databases (TiKV), embedded async with Embassy.
Best Practices
Prefer message passing or scoped threads; use Arc<Mutex<T>> deliberately.
Keep locks out of .await; choose async-aware primitives when needed.
Write cancellation-safe async code; use JoinSet for structured task ownership.
Test concurrent algorithms with loom.
Exercises
E1 · Beginner. Why does Rust reject sending an Rc<T> to another thread?
Answer: Rc uses a non-atomic reference count; two threads cloning/dropping it could race. Rc is not Send; use Arc.
E2 · Intermediate. What happens to an async function's remaining code if its future is dropped while awaiting?
Answer: It never runs; the state machine is destroyed at that .await (destructors of live locals run). Any invariant that relied on that code must already hold at the .await.
E3 · Advanced challenge. Why does holding a std::sync::MutexGuard across .await fail to compile with tokio::spawn?
Answer: MutexGuard is not Send; a future that holds it across a suspension point stores it in its state, making the future non-Send, while tokio::spawn requires Send futures (they may resume on another thread). The compiler thus prevents unlocking on a different thread and holding a blocking lock while suspended.
Quiz
? What does T: Sync mean?
+ &T can be shared between threads safely. :: Equivalent to &T: Send.
- T can be moved to another thread. :: That is Send.
- T is immutable. :: Interior mutability types can be Sync (Mutex<T>).
- T is protected by a lock. :: Not necessarily.
? How is an async operation cancelled in Rust?
+ By dropping its future. :: Execution stops at the last await point.
- By calling future.cancel(). :: There is no such method on Future.
- By throwing an exception. :: Rust has no exceptions.
- It cannot be cancelled. :: Dropping cancels it.
? Which does safe Rust guarantee? (select all that apply)
+ No data races :: Enforced by Send/Sync and borrowing.
- No deadlocks :: Deadlocks are possible.
+ Scoped threads are joined before borrowed data goes out of scope :: thread::scope guarantees it.
- Correctness of unsafe blocks :: unsafe code must uphold invariants manually.Summary
Rust enforces thread safety in the type system (Send/Sync, ownership, borrowing), provides threads, Arc/Mutex/RwLock, channels, atomics and scoped threads in the standard library, and async/await with third-party executors where dropping a future cancels it. Data races are impossible in safe code; deadlocks, logic races and cancellation safety still need design and testing.
Further Reading
*The Rust Programming Language*, chapter 16 "Fearless Concurrency"; *Asynchronous Programming in Rust*.
Mara Bos, *Rust Atomics and Locks* (free online).
Tokio tutorial; loom documentation.
The Rustonomicon, "Send and Sync".