Skip to content

Rust Threads, Send, Sync, Arc and Locks

Rust threads can own independent work or share explicitly synchronized state. Send governs transfer, Sync governs shared-reference access across threads, and Arc supplies shared ownership rather than a lock. This lesson separates those responsibilities before introducing channels and asynchronous tasks.

Prerequisites and learning outcome

Complete Rc, Weak, Cell and RefCell. You will move owned data into a thread, borrow local data in a thread scope, join workers, and limit the lifetime of Mutex and RwLock guards. Raspberry Pi readings below are synthetic millidegree fixtures; this is not a sensor reader or a speed benchmark.

Build the complete Rust thread example

cargo new pi_threads
cd pi_threads

Keep edition = "2024" in Cargo.toml. Replace src/main.rs with:

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

#[derive(Debug, Default, PartialEq, Eq)]
struct Summary {
    measured: usize,
    missing: usize,
    matching: usize,
}

fn summarize(values: &[Option<i32>], threshold: i32) -> Summary {
    let mut report = Summary::default();
    for value in values {
        match value {
            Some(value) => {
                report.measured += 1;
                report.matching += usize::from(*value >= threshold);
            }
            None => report.missing += 1,
        }
    }
    report
}

fn parallel_report(
    batches: Vec<Vec<Option<i32>>>,
    threshold: i32,
) -> Result<Summary, &'static str> {
    let shared = Arc::new(Mutex::new(Summary::default()));
    let handles: Vec<_> = batches
        .into_iter()
        .map(|batch| {
            let destination = Arc::clone(&shared);
            thread::spawn(move || -> Result<(), &'static str> {
                // Compute privately before holding the shared lock.
                let local = summarize(&batch, threshold);
                let mut total = destination.lock().map_err(|_| "summary poisoned")?;
                total.measured += local.measured;
                total.missing += local.missing;
                total.matching += local.matching;
                Ok(())
            })
        })
        .collect();

    let mut first_error = None;
    for handle in handles {
        // Even after one failure, join every remaining worker.
        let result = match handle.join() {
            Ok(result) => result,
            Err(_) => Err("worker panicked"),
        };
        if let Err(error) = result {
            first_error.get_or_insert(error);
        }
    }
    if let Some(error) = first_error {
        return Err(error);
    }

    let total = shared.lock().map_err(|_| "summary poisoned")?;
    Ok(Summary {
        measured: total.measured,
        missing: total.missing,
        matching: total.matching,
    })
}

fn scoped_sum(values: &[i32]) -> i64 {
    let (left, right) = values.split_at(values.len() / 2);
    thread::scope(|scope| {
        let first = scope.spawn(|| left.iter().map(|value| i64::from(*value)).sum::<i64>());
        let second = scope.spawn(|| right.iter().map(|value| i64::from(*value)).sum::<i64>());
        let first_result = first.join();
        let second_result = second.join();
        first_result.expect("left fixture worker panicked")
            + second_result.expect("right fixture worker panicked")
    })
}

fn main() {
    let label = String::from("Pi 4B");
    let worker = thread::spawn(move || format!("{label}: owned worker"));
    println!("{}", worker.join().expect("label worker panicked"));

    let batches = vec![vec![Some(46_700), None], vec![Some(0), Some(60_000)]];
    let report = parallel_report(batches, 60_000).expect("fixture report failed");
    println!(
        "measured={}, missing={}, matching={}",
        report.measured, report.missing, report.matching
    );

    let values = vec![46_700, 0, -500];
    println!(
        "scoped sum={}, retained={}",
        scoped_sum(&values),
        values.len()
    );

    let settings = RwLock::new(String::from("fixture"));
    {
        let first = settings.read().expect("settings poisoned");
        let second = settings.read().expect("settings poisoned");
        println!("readers={},{}", &*first, &*second);
    }
    *settings.write().expect("settings poisoned") = String::from("ready");
    println!("setting={}", &*settings.read().expect("settings poisoned"));
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::Cell;
    use std::sync::TryLockError;

    #[test]
    fn shared_report_matches_sequential_accounting() {
        let input = vec![Some(46_700), None, Some(0), Some(60_000), Some(-500)];
        let batches = vec![input[..2].to_vec(), input[2..].to_vec()];
        assert_eq!(
            parallel_report(batches, 60_000),
            Ok(summarize(&input, 60_000))
        );
    }

    #[test]
    fn empty_batches_and_missing_values_remain_distinct_from_zero() {
        assert_eq!(parallel_report(vec![], 0), Ok(Summary::default()));
        assert_eq!(parallel_report(vec![vec![]], 0), Ok(Summary::default()));
        assert_eq!(
            parallel_report(vec![vec![None, Some(0)]], 0),
            Ok(Summary {
                measured: 1,
                missing: 1,
                matching: 1,
            })
        );
    }

    #[test]
    fn scoped_workers_borrow_empty_singleton_and_negative_input() {
        assert_eq!(scoped_sum(&[]), 0);
        assert_eq!(scoped_sum(&[0]), 0);
        assert_eq!(scoped_sum(&[-500, 46_700]), 46_200);
    }

    #[test]
    fn cell_can_be_transferred_without_being_shared() {
        let value = Cell::new(0);
        let handle = thread::spawn(move || {
            value.set(7);
            value.get()
        });
        assert_eq!(handle.join().unwrap(), 7);
    }

    #[test]
    fn mutex_guard_blocks_try_lock_until_dropped() {
        let value = Mutex::new(0);
        let mut guard = value.lock().unwrap();
        *guard = 7;
        assert!(matches!(value.try_lock(), Err(TryLockError::WouldBlock)));
        drop(guard);
        assert_eq!(*value.try_lock().unwrap(), 7);
    }

    #[test]
    fn rwlock_allows_readers_but_excludes_a_writer() {
        let value = RwLock::new(0);
        let first = value.read().unwrap();
        let second = value.read().unwrap();
        assert!(matches!(value.try_write(), Err(TryLockError::WouldBlock)));
        drop(first);
        drop(second);
        let mut writer = value.write().unwrap();
        *writer = 7;
        assert!(matches!(value.try_read(), Err(TryLockError::WouldBlock)));
        drop(writer);
        assert_eq!(*value.read().unwrap(), 7);
    }

    #[test]
    fn join_reports_a_worker_panic() {
        let worker = thread::spawn(|| panic!("intentional fixture failure"));
        assert!(worker.join().is_err());
    }

    #[test]
    fn returned_error_is_not_a_thread_panic() {
        let worker = thread::spawn(|| Err::<(), _>("invalid fixture"));
        assert_eq!(worker.join().unwrap(), Err("invalid fixture"));
    }

    #[test]
    fn poisoned_state_is_repaired_before_clearing_the_flag() {
        let value = Arc::new(Mutex::new(0));
        let destination = Arc::clone(&value);
        let worker = thread::spawn(move || {
            let mut guard = destination.lock().unwrap();
            *guard = -1; // Invalid for this test's nonnegative-state contract.
            panic!("intentional interrupted update");
        });
        assert!(worker.join().is_err());
        assert!(value.is_poisoned());
        let mut guard = value.lock().unwrap_err().into_inner();
        assert_eq!(*guard, -1);
        *guard = 0; // Explicit repair of this known fixture invariant.
        value.clear_poison();
        drop(guard);
        assert_eq!(*value.lock().unwrap(), 0);
        assert!(!value.is_poisoned());
    }
}
1
2
3
4
cargo check
cargo test
cargo fmt --check
cargo run --quiet

Expected binary output:

1
2
3
4
5
Pi 4B: owned worker
measured=3, missing=1, matching=1
scoped sum=46200, retained=3
readers=fixture,fixture
setting=ready

The nine tests distinguish returned application errors from intentional worker panics observed through join; they do not make the normal binary panic. Output is printed by the main thread after joining workers, so it does not depend on which worker the scheduler runs first.

Ownership transfer and thread lifetime are separate

thread::spawn consumes a FnOnce closure whose captured environment and returned value satisfy Send + 'static. The move closure owns the String, and join transfers the returned String back. 'static here is a type bound excluding shorter-lived borrowed data; it does not force an owned String to stay allocated forever. See spawn.

move controls capture mode, not whether the captured type is thread-safe. Moving a reference also does not turn it into owned storage or lengthen its lifetime. For operating-system thread creation failures, thread::Builder::spawn provides a Result; plain spawn panics if creation fails. This small lesson does not implement a production thread pool or a retry policy.

join consumes its handle, waits for the worker and distinguishes its successful return from a panic payload. A returned Result is an additional layer: Ok(Err(error)) represents a normal worker return carrying an application error. Our report handles both layers and joins all remaining workers after an error, rather than detaching them accidentally. Dropping a JoinHandle detaches, not cancels, the worker. See JoinHandle.

Send and Sync describe type capabilities

Send means ownership of a value can cross thread boundaries safely. Sync means shared references can cross safely: T is Sync precisely when &T is Send. They are unsafe auto traits, normally inferred from a type's components; their contracts are not ordinary marker promises to add when code fails to compile. See Send and Sync.

String can be transferred and shared by reference. Cell can be transferred, as tested, but not shared by reference across workers. Rc is neither Send nor Sync. A closure's captures determine its bounds, and its return value must also meet spawn's requirements. The compiler checks these constraints in safe code; it does not prove that an application cannot deadlock or return a logically wrong result.

Arc shares ownership, not arbitrary mutable access

Arc::clone creates another owner of the same allocation, with atomic reference counting. It does not clone Summary. Arc's cross-thread capability depends on T: wrapping RefCell in Arc does not supply synchronization or make the resulting value transferable across threads. See Arc.

Arc> combines two responsibilities: shared lifetime and serialized access. An immutable shared object might need Arc without a lock, while scoped borrowing can avoid Arc entirely. Arc itself does not justify concurrent mutation; use the access mechanism required by the data. Mutex can be shared when T is Send, even if T is not Sync, because it gives exclusive access rather than concurrently handing out unprotected &T references.

Scoped threads borrow local input

scoped_sum borrows slices backed by its caller's Vec. thread::scope joins its scoped workers before returning, so their borrows cannot escape that scope. The Vec remains owned by the caller. This replaces the need to clone or allocate owned input merely to satisfy an unscoped spawn's lifetime bound. See thread scopes.

Our helper joins both handles before inspecting their results. Panics from unjoined scoped workers are propagated by the scope; manually joining permits handling their results. A scope guarantees bounded thread lifetime, not ordered scheduling. Do not infer a speedup from splitting this tiny fixture in two.

Lock guards delimit synchronized access

Mutex::lock blocks until it can return a guard, or reports poison when applicable. The guard supplies access and unlocks when dropped. Our worker computes a local report first, then performs only the merge while locked. The try_lock test checks contention without deliberately hanging on another blocking lock. See Mutex.

Keep lock scopes short. Do not hold a lock while joining a worker that needs it. A forgotten guard, repeated acquisition of the same mutex, inconsistent order between multiple locks, or re-entrant callbacks can prevent progress. Safe Rust prevents data races through these APIs, not every deadlock. Sleeping is not a correctness mechanism for enforcing thread order.

RwLock permits multiple readers or one writer. Its scheduling policy depends on the underlying implementation; do not assume fairness or that it is faster than Mutex. We release both readers before requesting a writer. The tests use try_write/try_read rather than a potentially blocking recursive acquisition. See RwLock.

Poison signals a possible broken invariant

A panic while holding a Mutex guard can poison the mutex. lock then yields an error containing the guard, not a repaired value. Our test deliberately sets an invalid negative state, joins the failed worker, explicitly resets the known invariant, and only then clears poison. PoisonError::into_inner alone is not a validation strategy.

Poisoning is advisory and is not a soundness guarantee: not all panic situations trigger it. Our report rejects poisoned summaries rather than trusting partial counts. RwLock poisoning concerns a writer panic, not simply a reader panic. Production code must define whether state can be inspected, reconstructed or abandoned; blindly unwrapping or clearing a flag does not establish consistency.

Deliberately failing: move does not make Rc transferable

In a separate scratch project, replace src/main.rs with:

1
2
3
4
5
6
7
8
use std::rc::Rc;
use std::thread;

fn main() {
    let label = Rc::new(String::from("Pi 4B"));
    let worker = thread::spawn(move || label.len());
    println!("{}", worker.join().unwrap());
}

cargo check reports E0277 because Rc is not Send. Replacing Rc with Arc works for this immutable String; adding mut instead or changing capture syntax does not repair the missing capability.

Exercises and troubleshooting

  1. Repair the scratch program with Arc. Expect the returned byte length to be 5. Explain why no mutex is needed for this read-only payload.
  2. Replace the scratch payload with Arc> and read its borrow inside the worker. Expect E0277: RefCell is not Sync. For genuinely shared mutable text, use Arc> and a scoped guard; for exclusive ownership, move the String without shared ownership.
  3. In a scratch program, create a local String and spawn a non-move closure that reads it. Expect E0373 for the potential escaping borrow even if join follows immediately. Repair by transferring ownership or using thread::scope.
  4. Try to move a MutexGuard into an unscoped spawned thread. Expect E0277: the standard guard is not Send. Acquire it in the worker instead, with a suitable owner or scope.
  5. Change the normal report threshold to 60_001: matching becomes 0, while measured and missing are unchanged. Add empty batches or a negative measured reading; neither absence nor a numeric sign should silently change record accounting.
  6. Read the poison test and distinguish the worker's panic Result from the mutex's poison Result. Explain why clear_poison must follow a deliberate recovery decision rather than merely silence an error.
  7. Sketch the deadlock where main locks the shared report and joins a worker that tries to lock it. Do not run an indefinitely blocking example; explain the wait cycle and release-before-join repair.

Verification and next step

On October 10, 2026, the lesson was verified on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, nine debug and release tests, formatting, debug/release output comparisons and five further debug output comparisons passed. Arc, mutex, owned-capture and scoped-borrow repairs passed, as did the changed-threshold variant. Rc transfer, Arc>, MutexGuard transfer and escaping local capture produced the expected E0277 or E0373 errors. The tests observed returned errors, worker panics, poison recovery and nonblocking guard conflicts. These finite runs do not establish every possible schedule, freedom from deadlock in other programs, hardware behaviour or a performance improvement.

Next, study channels, atomics and shutdown to transfer messages, choose atomic operations and define how workers finish.

Previous: Rc, Weak, Cell and RefCell · Course overview

Donate