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

playground

Syntax 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".