Skip to content

Concurrency and Parallelism

What's the difference between Send and Sync, and how are they implemented?

Answer
  • Send means a value of the type can be safely moved to another thread.
  • Sync means a value can be safely shared between threads through a reference. Formally, T is Sync if and only if &T is Send.

Both are auto traits: the compiler implements them automatically for a type when all of its fields are Send/Sync. For types built on raw pointers, the author has to opt in manually with unsafe impl Send.

Common examples of types that don't implement them:

  • Rc<T> is neither Send nor Sync, because its reference count isn't atomic.
  • Cell<T> and RefCell<T> are Send (if T: Send), but not Sync: they allow mutation through &T without any synchronization.
  • Raw pointers *const T and *mut T are neither Send nor Sync.
  • MutexGuard is !Send: on some platforms, a mutex has to be unlocked by the same thread that locked it.
rust
use std::sync::Arc;
use std::thread;

fn main() {
    let v = Arc::new(vec![1, 2, 3]);
    let v2 = Arc::clone(&v);
    thread::spawn(move || println!("{:?}", v2)).join().unwrap();
}

If you replace Arc with Rc, this won't compile, because Rc isn't Send.

How is Mutex different from RwLock, and when should you use each?

Answer

A Mutex gives exclusive access: only one thread can hold the lock at a time. An RwLock allows either one writer or many readers at the same time.

An RwLock has more overhead per lock and unlock. With short critical sections, it's often slower than a Mutex, even when the workload is mostly reads. It pays off when readers hold the lock for a long time and writes are rare.

rust
use std::sync::RwLock;

fn main() {
    let lock = RwLock::new(0);
    {
        let r1 = lock.read().unwrap();
        let r2 = lock.read().unwrap(); // several readers at once are fine
        println!("{} {}", *r1, *r2);
    }
    *lock.write().unwrap() = 5;
}

In practice: when in doubt, start with a Mutex. Switch to RwLock only after measuring. Also keep in mind that with a steady stream of readers, writers can end up waiting for a long time (writer starvation). Whether that happens depends on the lock's fairness policy, which differs between operating systems.

What is mutex poisoning, and how do you deal with it?

Answer

If a thread panics while holding a std::sync::Mutex, the mutex becomes poisoned. From then on, every call to lock() returns Err. This protects you from accidentally working with data that may have been left in an inconsistent state halfway through an update.

If you know the data is still valid, you can recover it with PoisonError::into_inner, which gives you the guard anyway. Mutex::clear_poison removes the poisoned flag.

parking_lot::Mutex has no poisoning at all, and some teams prefer it for that reason. It's no longer much faster, though: since Rust 1.62, the standard Mutex on Linux is built on futexes and performs about as well.

rust
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(0);
    let _ = std::panic::catch_unwind(|| {
        let _g = m.lock().unwrap();
        panic!("boom");
    });
    match m.lock() {
        Ok(g) => println!("{}", *g),
        Err(p) => println!("poisoned, value = {}", *p.into_inner()),
    }
}

The point is that Rust doesn't let you silently ignore a panic that happened while a lock was held.

What are the features of channels in std::sync::mpsc?

Answer

mpsc stands for multiple producer, single consumer. Values are sent by move. Sender is Send and Sync (and can be cloned), while Receiver is Send but not Sync.

There are two kinds of channels:

  • mpsc::channel() is unbounded. Sending never blocks, but if the receiver is slower than the senders, memory usage keeps growing.
  • mpsc::sync_channel(n) is bounded. send blocks when the buffer is full. With n = 0, every send waits until the receiver takes the value (a rendezvous channel).

Since Rust 1.67, std::sync::mpsc is built on the crossbeam-channel implementation. You'd still reach for crossbeam-channel or flume when you need multiple consumers or select, and for tokio::sync::mpsc in async code.

rust
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();
    for i in 0..3 {
        let tx = tx.clone();
        thread::spawn(move || tx.send(i).unwrap());
    }
    drop(tx);
    for v in rx {
        println!("{}", v);
    }
}

The for v in rx loop ends only after every Sender has been dropped. That's why you have to drop the original tx; otherwise the loop will wait forever.

What are scoped threads, and why were they added to the standard library?

Answer

std::thread::spawn requires a 'static closure, so you can't pass references to local data into a thread. Before std::thread::scope was stabilized in Rust 1.63, you had to wrap the data in an Arc or use the crossbeam crate.

Scoped threads guarantee that every thread spawned inside the scope finishes before the scope returns. That's why the compiler lets them borrow local variables without 'static, which removes the need for many Arcs and clones.

rust
use std::thread;

fn main() {
    let data = vec![1, 2, 3, 4];
    thread::scope(|s| {
        s.spawn(|| println!("{:?}", &data[..2]));
        s.spawn(|| println!("{:?}", &data[2..]));
    }); // both threads are joined here
    println!("{:?}", data);
}

After the scope, data is still available. No clones and no Arc.

What are atomic types, and what does Ordering mean?

Answer

Atomic types (AtomicBool, AtomicUsize, AtomicPtr, and others) provide lock-free operations on a single value: load, store, fetch_add, compare_exchange, and so on. Each operation takes an Ordering argument that describes the guarantees you need when several threads access the value at once.

OrderingWhat it means
RelaxedThe weakest guarantees. Each operation is still atomic. Fine for independent counters and statistics.
ReleaseTypically used for writes that must be visible to later reads marked Acquire.
AcquireTypically used for reads that must see earlier writes marked Release. The pair is common in Arc and lock-free code.
SeqCstThe strongest guarantees: all operations behave as if they happened one after another in a single sequence.

In practice, the difference only matters in complex lock-free algorithms. Every individual operation is atomic regardless of ordering. If you're not sure which one to choose, SeqCst is the safe option; for a simple counter, Relaxed is enough.

rust
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

static CNT: AtomicUsize = AtomicUsize::new(0);

fn main() {
    let handles: Vec<_> = (0..4)
        .map(|_| {
            thread::spawn(|| {
                for _ in 0..1000 {
                    CNT.fetch_add(1, Ordering::Relaxed);
                }
            })
        })
        .collect();
    for h in handles {
        h.join().unwrap();
    }
    println!("{}", CNT.load(Ordering::Relaxed)); // always 4000
}

What is a data race, and how is it different from a race condition?

Answer

A data race happens when two or more threads access the same memory at the same time, at least one of them writes, and there's no synchronization. It's undefined behavior. Safe Rust rules out data races completely, thanks to the borrowing rules and the Send and Sync traits.

A race condition is a broader, logic-level problem: the result depends on the timing between threads. A typical example is "check, then act": a thread checks a condition, and before it acts on it, another thread changes the state. Rust doesn't protect you from this; every individual access can be perfectly synchronized, and the logic can still be wrong.

rust
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let balance = Arc::new(Mutex::new(100));

    let handles: Vec<_> = (0..2)
        .map(|_| {
            let balance = Arc::clone(&balance);
            thread::spawn(move || {
                // Race condition: the check and the update use two separate locks
                if *balance.lock().unwrap() >= 100 {
                    *balance.lock().unwrap() -= 100;
                }
            })
        })
        .collect();

    for h in handles {
        h.join().unwrap();
    }
    println!("{}", *balance.lock().unwrap()); // may print -100
}

There's no data race here, since the Mutex protects every access, but both threads can pass the check before either one withdraws. The fix is to do the check and the update under a single lock:

rust
let mut b = balance.lock().unwrap();
if *b >= 100 {
    *b -= 100;
}

What is a deadlock, and how do you avoid it in Rust?

Answer

A deadlock is when two or more threads wait for each other and none of them can make progress. The classic cause is two threads acquiring the same locks in a different order.

Rust's type system doesn't prevent deadlocks. Here's how to avoid them:

  • Always acquire locks in the same fixed order.
  • Keep critical sections short, and don't call unknown code (like callbacks) while holding a lock.
  • Use try_lock where it makes sense.
  • Remember that std::sync::Mutex isn't reentrant: locking it a second time from the same thread will deadlock or panic.
  • In async code, don't hold a regular mutex guard across .await. Release it first, or use tokio::sync::Mutex if you really need to hold the lock across an .await.
rust
use std::sync::Mutex;

fn next_value(m: &Mutex<i32>) -> i32 {
    let v = {
        let g = m.lock().unwrap();
        *g
    }; // the lock is released here
    v + 1
}

fn main() {
    let m = Mutex::new(10);
    println!("{}", next_value(&m));
}

The lock is taken inside a block and released right away. The rest of the function runs without holding it.

A common pitfall: in match m.lock().unwrap().get() { ... }, the temporary guard lives until the end of the whole match, so any attempt to lock m again inside it will deadlock.

How does Rayon work, and when should you use it?

Answer

Rayon is a data-parallelism library built on work stealing. Its best-known feature is par_iter, which turns a regular iterator into a parallel one, often by changing a single method call. Under the hood, it recursively splits the work in half and spreads the pieces across a thread pool.

Rayon is a great fit for CPU-bound work over collections. It's a poor fit for I/O, for code that spends most of its time waiting on locks, and for steps that depend heavily on each other.

rust
use rayon::prelude::*;

fn main() {
    let sum: u64 = (1u64..=1_000_000).into_par_iter().map(|x| x * x).sum();
    println!("{}", sum);
}

If the closure passed to map makes a blocking call, it ties up a pool thread and parallelism suffers. This is a common trap when mixing Rayon with synchronous I/O. Also, don't call Rayon directly from async code: run it in a separate thread and send the result back through a channel.

What is work stealing, and why is it efficient?

Answer

Work stealing is a scheduling strategy where each worker thread has its own task queue. A worker pushes and pops tasks at one end of its own queue. When its queue is empty, it steals a task from the other end of another worker's queue.

Why it works well:

  • Threads rarely compete for a shared queue, so there's little contention.
  • Workers mostly run tasks they just created, which keeps the CPU cache warm.
  • Idle threads pick up work automatically, so the load evens out.

Rayon and Tokio's multi-threaded runtime both use this approach.

In practice: with short tasks of similar size, work stealing does an almost perfect job. Very long tasks get in the way, because a thread stuck on one can't take part in balancing. In Tokio, the same idea translates into "don't block the worker thread."

What is a thread pool, and why isn't spawning threads by hand always a good idea?

Answer

Creating a thread isn't free: it takes a system call, memory for the stack, and bookkeeping in the kernel. When tasks are short, giving each one its own thread is wasteful.

A thread pool creates threads up front and reuses them for many tasks. In Rust, the usual options are rayon::ThreadPool, the Tokio runtime (with spawn_blocking for blocking work), or the threadpool crate.

Spawning a thread by hand makes sense for long-lived jobs, like a background logger or a dedicated I/O loop.

rust
use rayon::ThreadPoolBuilder;

fn main() {
    let pool = ThreadPoolBuilder::new().num_threads(4).build().unwrap();
    let r = pool.install(|| (0..100).sum::<i32>());
    println!("{}", r);
}

A dedicated pool is handy when you want to keep latency-sensitive tasks separate from background ones.

What is a Barrier, and when do you need one?

Answer

A barrier synchronizes a group of threads. Each thread that reaches the barrier waits there until all the others arrive, and then they all continue together.

It's used in step-by-step simulations, in parallel algorithms where one phase must fully finish before the next one starts, and in benchmarks where you want all threads to start at the same moment. The standard library provides std::sync::Barrier.

rust
use std::sync::{Arc, Barrier};
use std::thread;

fn main() {
    let barrier = Arc::new(Barrier::new(3));
    let mut handles = vec![];
    for i in 0..3 {
        let b = Arc::clone(&barrier);
        handles.push(thread::spawn(move || {
            println!("{} before", i);
            b.wait();
            println!("{} after", i);
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
}

It's guaranteed that every "before" line is printed before any "after" line.

What is a Condvar, and why is it better than busy waiting?

Answer

A Condvar (condition variable) lets a thread sleep until some condition becomes true.

A thread holding a lock calls wait, which releases the lock and puts the thread to sleep. Another thread changes the shared state and calls notify_one or notify_all. When the waiting thread wakes up, wait reacquires the lock before returning. This is the right way to wait for an event, instead of burning CPU in a loop that keeps checking a flag.

rust
use std::sync::{Arc, Condvar, Mutex};
use std::thread;

fn main() {
    let pair = Arc::new((Mutex::new(false), Condvar::new()));
    let pair2 = Arc::clone(&pair);

    thread::spawn(move || {
        let (lock, cvar) = &*pair2;
        *lock.lock().unwrap() = true;
        cvar.notify_one();
    });

    let (lock, cvar) = &*pair;
    let mut started = lock.lock().unwrap();
    while !*started {
        started = cvar.wait(started).unwrap();
    }
    println!("started");
}

The while loop is required because of spurious wakeups: wait can return even when nobody called notify, so a single wait isn't enough. Condvar::wait_while wraps this loop for you.

What is a spinlock, and when does it make sense?

Answer

A spinlock is a lock that, instead of putting the thread to sleep, keeps checking in a tight loop until the lock becomes free.

It only makes sense when the critical section is extremely short, so a context switch would cost more than the wait itself. You'll mostly find spinlocks in kernels, drivers, and no_std code. In regular user-space code they're usually a bad idea: if the thread holding the lock gets preempted by the OS, everyone else spins and wastes CPU for nothing.

The standard library doesn't provide a spinlock; the spin crate does. For normal code, std::sync::Mutex or parking_lot::Mutex is the right answer: both spin briefly before putting the thread to sleep, so you get the benefit of spinning without its downsides.

What is thread-local storage, and when do you actually need it?

Answer

A thread-local value has a separate copy in each thread. In Rust, you declare one with the thread_local! macro, which creates a LocalKey.

It's useful for per-thread caches, random number generators (for example, rand::rng()), tracing context, and allocators.

You can only access the value through with (or helpers like set and get), and references to it can't escape the closure. To use the data in another thread, copy or clone it out.

rust
use std::cell::RefCell;

thread_local! {
    static COUNTER: RefCell<u64> = const { RefCell::new(0) };
}

fn bump() {
    COUNTER.with(|c| *c.borrow_mut() += 1);
}

fn main() {
    bump();
    bump();
    COUNTER.with(|c| println!("{}", c.borrow()));
}

This is a common pattern for statistics, and for test hooks that would otherwise need global state.