Rust Shared State: RwLock, Atomics & Scoped Threads
On the Concurrency page you shared a counter between threads with Arc<Mutex<T>>: Arc lets many threads own the value, and Mutex lets only one of them touch it at a time. On the Channels page you avoided sharing completely by sending messages. Both are great tools, and you can build almost anything with them.
But think of a Mutex like the single key to an office bathroom. It works, yet it’s overkill if all you want is to read the notice board in the hallway. Rust’s standard library has a few more tools, each one a better fit for a certain job. On this page you’ll meet four of them: scoped threads, RwLock, atomics, and OnceLock/LazyLock. You’ll also learn how to avoid the one bug the compiler can’t catch for you: the deadlock.
Scoped threads: borrow instead of move
Section titled “Scoped threads: borrow instead of move”With thread::spawn, you usually need move and often Arc. That’s because a spawned thread might keep running after main has moved on, so Rust won’t let it borrow your local variables.
std::thread::scope fixes this with a promise: every thread started inside the scope finishes before the scope ends. Since the threads can’t outlive your data, they’re allowed to simply borrow it. No Arc, no move, no cloning.
use std::thread;
fn main() { let shop = "Corner Shop"; let sales = vec![12, 7, 30, 5, 18, 22, 9, 14]; let (morning, afternoon) = sales.split_at(4);
let (am, pm) = thread::scope(|s| { let am = s.spawn(|| morning.iter().sum::<i32>()); let pm = s.spawn(|| afternoon.iter().sum::<i32>()); (am.join().unwrap(), pm.join().unwrap()) });
println!("{shop}: morning {am}, afternoon {pm}, whole day {}", am + pm); println!("still mine: {:?}", sales);}Output:
Corner Shop: morning 54, afternoon 63, whole day 117still mine: [12, 7, 30, 5, 18, 22, 9, 14]Here’s what’s going on:
thread::scope(|s| { ... })opens the scope. You start threads withs.spawn(...)instead ofthread::spawn(...).- The two closures borrow
morningandafternoon, which are just slices ofsales. Nothing is copied. - Whatever the scope’s closure returns (here a tuple of two sums) becomes the value of
thread::scope. - After the scope,
salesstill belongs tomain, so you can keep using it.
Even if you forget to call join(), the scope waits for all its threads at the closing }. That’s exactly the promise that makes borrowing safe.
RwLock: many readers or one writer
Section titled “RwLock: many readers or one writer”Picture a menu board in a café. Lots of customers can read it at the same time, and that’s fine. But when the owner changes a price, everybody has to wait a moment until the new board is up.
That’s an RwLock (a “read-write lock”). It has two ways in:
read()gives you a shared read guard. Many threads can hold one at the same time.write()gives you an exclusive write guard. While a writer holds it, nobody else can read or write.
A Mutex doesn’t make that difference: even two readers have to take turns. Here’s a settings cache that several request handlers read, and an admin updates once:
use std::sync::{Arc, RwLock};use std::thread;
struct Settings { theme: String, max_upload_mb: u32,}
fn main() { let settings = Arc::new(RwLock::new(Settings { theme: String::from("light"), max_upload_mb: 10, }));
// Four request handlers read the settings at the same time. let mut handles = Vec::new(); for id in 1..=4 { let settings = Arc::clone(&settings); handles.push(thread::spawn(move || { let s = settings.read().unwrap(); // shared: others can read too format!("request {id}: theme={}, limit={}MB", s.theme, s.max_upload_mb) })); } for handle in handles { println!("{}", handle.join().unwrap()); }
// An admin changes the settings once. { let mut s = settings.write().unwrap(); // exclusive: everyone else waits s.theme = String::from("dark"); s.max_upload_mb = 25; } // the write guard is dropped here, so the lock is free again
let s = settings.read().unwrap(); println!("after update: theme={}, limit={}MB", s.theme, s.max_upload_mb);}Output:
request 1: theme=light, limit=10MBrequest 2: theme=light, limit=10MBrequest 3: theme=light, limit=10MBrequest 4: theme=light, limit=10MBafter update: theme=dark, limit=25MBThe threads can finish in any order, but the output is always the same. Each thread returns its line instead of printing it, and main prints them in the order it joins them.
When does RwLock beat Mutex? When reads are frequent, writes are rare, and each read takes a little while (say, looking through a big cache). Then readers really do run side by side.
When doesn’t it? If reads are tiny, or writes happen all the time, an RwLock is just a more complicated Mutex. It does extra bookkeeping, and the readers and writers mostly wait for each other anyway. When you’re not sure, start with a Mutex. It’s simpler, and you can switch later if measuring shows the lock is a bottleneck.
Atomics: counters without a lock
Section titled “Atomics: counters without a lock”For a single number or a single true/false flag, even a lock is more than you need. Atomic types such as AtomicUsize, AtomicI64 and AtomicBool can be changed safely by many threads at once, with no lock at all. The CPU makes each operation happen in one indivisible step, so no update gets lost.
Here, 8 threads each record 1000 page views. They also count votes: one upvote, then three downvotes, again and again. The vote total goes negative, which is why it’s an AtomicI64 and not an unsigned type:
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};use std::thread;
fn main() { let page_views = AtomicUsize::new(0); let net_votes = AtomicI64::new(0);
thread::scope(|s| { for _ in 0..8 { s.spawn(|| { for n in 0..1000 { page_views.fetch_add(1, Ordering::Relaxed); if n % 4 == 0 { net_votes.fetch_add(1, Ordering::Relaxed); // upvote } else { net_votes.fetch_sub(1, Ordering::Relaxed); // downvote } } }); } }); // all 8 threads have finished here
println!("page views: {}", page_views.load(Ordering::Relaxed)); println!("net votes: {}", net_votes.load(Ordering::Relaxed));}Output:
page views: 8000net votes: -4000Notice that page_views isn’t mut. Atomics change through a shared reference, which is exactly why many threads can use them at once. The methods you’ll use most:
fetch_add(n, ordering)/fetch_sub(n, ordering)add or subtract, and give back the old value.load(ordering)reads the current value.store(value, ordering)overwrites it.
What is Ordering?
Section titled “What is Ordering?”Every atomic operation asks for an Ordering. It tells the CPU and compiler how strictly this operation must stay in order with the memory operations around it. The full story is deep, but as a beginner you only need two rules:
- When you’re not sure, use
Ordering::SeqCst. It’s the strictest and safest choice. Ordering::Relaxedis fine for a simple counter that you only read at the end, like the one above. The total is still exactly right.
If you want to go further one day, the free book Rust Atomics and Locks (linked at the bottom) explains orderings clearly.
A stop flag
Section titled “A stop flag”An AtomicBool makes a great “please stop” signal. Here a background worker keeps autosaving until main flips the flag:
use std::sync::atomic::{AtomicBool, Ordering};use std::thread;use std::time::Duration;
fn main() { let stop = AtomicBool::new(false);
let report = thread::scope(|s| { let worker = s.spawn(|| { while !stop.load(Ordering::SeqCst) { // pretend to autosave the document here thread::sleep(Duration::from_millis(5)); } "autosave worker stopped cleanly" });
thread::sleep(Duration::from_millis(30)); // main does other work stop.store(true, Ordering::SeqCst); // ask the worker to stop worker.join().unwrap() });
println!("{report}");}Output:
autosave worker stopped cleanlyThe worker checks the flag each time around its loop. It finishes its current step and then exits on its own, which is much kinder than killing a thread halfway through saving a file.
OnceLock and LazyLock: set it up once
Section titled “OnceLock and LazyLock: set it up once”Sometimes you want one global value that every thread can read: a lookup table, an app name, a config loaded at startup. Building it is the tricky part. What if two threads try to build it at the same moment?
std::sync::LazyLock and std::sync::OnceLock solve this. The value is created exactly once, the first time someone needs it. If other threads ask while it’s being built, they simply wait, and then everyone shares the same value.
use std::collections::HashMap;use std::sync::{LazyLock, OnceLock};use std::thread;
// LazyLock: the recipe is written right here, and runs on first use.static CURRENCIES: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| { println!("building the currency table..."); HashMap::from([("US", "USD"), ("JP", "JPY"), ("DE", "EUR"), ("BR", "BRL")])});
// OnceLock: starts empty, and you decide how to fill it where you use it.static STORE_NAME: OnceLock<String> = OnceLock::new();
fn store_name() -> &'static str { STORE_NAME.get_or_init(|| { std::env::var("STORE_NAME").unwrap_or_else(|_| String::from("Rusty Shop")) })}
fn main() { let mut handles = Vec::new(); for country in ["US", "JP", "DE", "BR"] { handles.push(thread::spawn(move || { format!("{} in {country} charges in {}", store_name(), CURRENCIES[country]) })); } for handle in handles { println!("{}", handle.join().unwrap()); }}Output:
building the currency table...Rusty Shop in US charges in USDRusty Shop in JP charges in JPYRusty Shop in DE charges in EURRusty Shop in BR charges in BRLFour threads all want the table at about the same time, yet “building the currency table…” prints only once. And because these are static values that live for the whole program, the threads don’t need Arc at all.
Which one should you pick?
LazyLockwhen you know how to build the value at the place you declare it. It feels like a normal global.OnceLockwhen the value is decided later, for example from a command-line flag. You fill it withget_or_init(...)orset(...)(andsetfails politely if it’s already filled).
Both are part of the standard library. LazyLock arrived in Rust 1.80, so older code often uses the once_cell or lazy_static crates for the same job.
Avoiding deadlocks
Section titled “Avoiding deadlocks”The compiler stops data races, but there’s one concurrency bug it can’t catch: the deadlock. That’s when threads wait for each other forever and your program just freezes. No error, no crash, nothing.
The classic recipe is two locks taken in different orders. Imagine two people who each need both the pen and the notebook. One grabs the pen, the other grabs the notebook, and each waits politely for the other to let go. Forever.
// Thread A // Thread Blet from = alice.lock().unwrap(); let from = bob.lock().unwrap();let to = bob.lock().unwrap(); let to = alice.lock().unwrap();// A holds alice, waits for bob. // B holds bob, waits for alice. Stuck!Three habits keep you safe:
- Always take locks in the same order. If every thread locks
alicebeforebob(for example, sorted by account ID), the cycle can’t happen. - Keep lock scopes short. Hold a lock just long enough to read or change the data. Never hold it during slow work like a network call, a file write, or a long calculation. Every other thread is stuck waiting the whole time.
- Drop guards early. A guard unlocks when it’s dropped. You can end it with an inner block
{ ... }(like theRwLockwriter above) or calldrop(guard)yourself.
Here’s habit 3 in action. Each worker takes a job from a shared queue, unlocks the queue, and only then does the slow part:
use std::sync::Mutex;use std::thread;
fn main() { let queue = Mutex::new(vec!["resize photo", "send email", "build report"]); let finished = Mutex::new(Vec::new());
thread::scope(|s| { for _ in 0..3 { s.spawn(|| { let mut jobs = queue.lock().unwrap(); let job = jobs.pop(); drop(jobs); // unlock the queue BEFORE the slow part
if let Some(job) = job { let result = format!("done: {job}"); // imagine this takes seconds finished.lock().unwrap().push(result); } }); } });
// The scope is over, so no thread uses `finished` any more. let mut done = finished.into_inner().unwrap(); done.sort(); // threads finish in any order for line in done { println!("{line}"); }}Output:
done: build reportdone: resize photodone: send emailWithout drop(jobs), the guard would live until the end of the closure. Then the “slow” work would happen while the queue is still locked, and the three workers would run one after another instead of side by side.
One sneaky case to know about: in while let Some(job) = queue.lock().unwrap().pop() { ... }, the guard lives for the whole loop body. So the queue stays locked while you work on the job. Take the job in a separate let statement first, like the example does.
Which tool do I use?
Section titled “Which tool do I use?”| You want to… | Reach for |
|---|---|
| Send work or results from one thread to another | A channel (mpsc) |
| Let many threads read and change the same value | Arc<Mutex<T>> |
| Share data that’s read a lot and changed rarely | Arc<RwLock<T>> |
| Count things, or share a simple on/off flag | Atomics (AtomicUsize, AtomicBool, …) |
| Build a global value once, then only read it | LazyLock or OnceLock |
Let threads borrow local data, with no Arc |
thread::scope |
These mix freely. The examples on this page already combined thread::scope with atomics and with a Mutex. Pick the simplest tool that fits, and don’t worry about choosing “wrong”: switching later is usually a small change.
Common mistakes
Section titled “Common mistakes”Borrowing a local variable in thread::spawn. This looks harmless:
use std::thread;
fn main() { let sales = vec![12, 7, 30, 5];
let handle = thread::spawn(|| { let total: i32 = sales.iter().sum(); total });
println!("total: {}", handle.join().unwrap());}error[E0373]: closure may outlive the current function, but it borrows `sales`, which is owned by the current function --> src/main.rs:6:32 |6 | let handle = thread::spawn(|| { | ^^ may outlive borrowed value `sales`7 | let total: i32 = sales.iter().sum(); | ----- `sales` is borrowed here |help: to force the closure to take ownership of `sales` (and any other referenced variables), use the `move` keywordYou know you call join() right away, but thread::spawn can’t know that. As far as it’s concerned, the thread might run forever. You have two fixes: add move (then main can’t use sales afterwards), or use thread::scope, which can borrow:
let total: i32 = thread::scope(|s| s.spawn(|| sales.iter().sum()).join().unwrap());Forgetting Arc. Without Arc, the first thread’s move closure takes the Mutex, and nothing is left for the next one:
use std::sync::Mutex;use std::thread;
fn main() { let counter = Mutex::new(0); let mut handles = vec![];
for _ in 0..3 { handles.push(thread::spawn(move || { *counter.lock().unwrap() += 1; })); }
for handle in handles { handle.join().unwrap(); }}error[E0382]: use of moved value: `counter` --> src/main.rs:9:36 | 5 | let counter = Mutex::new(0); | ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait... 8 | for _ in 0..3 { | ------------- inside of this loop 9 | handles.push(thread::spawn(move || { | ^^^^^^^ value moved into closure here, in previous iteration of loopWrap it in Arc::new(...) and clone the Arc at the top of the loop, like on the Concurrency page. Or use thread::scope and drop the move. If you reach for Rc instead of Arc, you’ll get error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely. Rc is for one thread only (see Smart Pointers).
Practice
Section titled “Practice”1. Sum in four chunks
Section titled “1. Sum in four chunks”Make a Vec of the numbers 1 to 1000. Use thread::scope to split it into 4 chunks of 250 and sum each chunk in its own thread. Print each chunk’s sum and the grand total. Hint: numbers.chunks(250) gives you the slices. Collect the handles into a Vec, then join them in order.
Show solution
use std::thread;
fn main() { let numbers: Vec<u64> = (1..=1000).collect();
let sums: Vec<u64> = thread::scope(|s| { let mut handles = Vec::new(); for chunk in numbers.chunks(250) { handles.push(s.spawn(move || chunk.iter().sum::<u64>())); } handles.into_iter().map(|h| h.join().unwrap()).collect() });
println!("chunk sums: {:?}", sums); println!("total: {}", sums.iter().sum::<u64>());}Output:
chunk sums: [31375, 93875, 156375, 218875]total: 500500The move here only moves chunk, which is a small borrowed slice, into each thread. The vector itself is still borrowed, not moved, so you could use numbers again after the scope.
2. Unique order numbers
Section titled “2. Unique order numbers”An online shop gives every order a number, starting at 1000. Start 4 threads that each take 3 order numbers from one shared AtomicU64. Collect all 12 numbers, sort them, and print them. Hint: fetch_add(1, Ordering::SeqCst) returns the value before adding, so it hands out each number exactly once.
Show solution
use std::sync::atomic::{AtomicU64, Ordering};use std::thread;
fn main() { let next_order = AtomicU64::new(1000);
let mut numbers: Vec<u64> = thread::scope(|s| { let mut handles = Vec::new(); for _ in 0..4 { handles.push(s.spawn(|| { (0..3) .map(|_| next_order.fetch_add(1, Ordering::SeqCst)) .collect::<Vec<u64>>() })); } handles.into_iter().flat_map(|h| h.join().unwrap()).collect() });
numbers.sort(); println!("{} orders: {:?}", numbers.len(), numbers);}Output:
12 orders: [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011]Which thread gets which number changes from run to run, but no number is ever handed out twice or skipped. Sorting makes the printed list the same every time.
thread::scopeguarantees its threads finish before it ends, so they can borrow local data withoutArcormove.RwLockallows many readers or one writer. It helps with read-heavy data; otherwise aMutexis simpler.- Atomics (
AtomicUsize,AtomicI64,AtomicBool) handle counters and flags with no lock. UseSeqCstwhen unsure,Relaxedfor simple counters. LazyLockandOnceLockcreate a global value exactly once, safely, even when many threads ask at the same time.- Avoid deadlocks: take locks in the same order, keep lock scopes short, and drop guards early.
Quick check
Section titled “Quick check”Quick check
1. Why can threads started with s.spawn inside thread::scope borrow local variables?
2. Which statement about RwLock is true?
3. You need a page-view counter shared by many threads. What's the simplest good choice?
4. Thread A locks alice then bob. Thread B locks bob then alice. What can happen?
Score: 0 / 4
Read more
Section titled “Read more”- Shared-State Concurrency — The Rust Book: the official chapter on
MutexandArc, the base this page builds on. std::thread::scope— standard library docs: how scoped threads work, with more examples.std::sync::RwLock— standard library docs: read and write guards, poisoning, and a note on how fairness depends on the operating system.std::sync::atomic— standard library docs: every atomic type and a short overview of memory orderings.- Rust Atomics and Locks by Mara Bos: a free online book that explains atomics, memory ordering and how locks are built, step by step.