Skip to content

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.

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 117
still mine: [12, 7, 30, 5, 18, 22, 9, 14]
▶ Try it Yourself

Here’s what’s going on:

  • thread::scope(|s| { ... }) opens the scope. You start threads with s.spawn(...) instead of thread::spawn(...).
  • The two closures borrow morning and afternoon, which are just slices of sales. Nothing is copied.
  • Whatever the scope’s closure returns (here a tuple of two sums) becomes the value of thread::scope.
  • After the scope, sales still belongs to main, 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.

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=10MB
request 2: theme=light, limit=10MB
request 3: theme=light, limit=10MB
request 4: theme=light, limit=10MB
after update: theme=dark, limit=25MB
▶ Try it Yourself

The 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.

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: 8000
net votes: -4000
▶ Try it Yourself

Notice 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.

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::Relaxed is 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.

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 cleanly
▶ Try it Yourself

The 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.

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 USD
Rusty Shop in JP charges in JPY
Rusty Shop in DE charges in EUR
Rusty Shop in BR charges in BRL
▶ Try it Yourself

Four 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?

  • LazyLock when you know how to build the value at the place you declare it. It feels like a normal global.
  • OnceLock when the value is decided later, for example from a command-line flag. You fill it with get_or_init(...) or set(...) (and set fails 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.

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 B
let 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:

  1. Always take locks in the same order. If every thread locks alice before bob (for example, sorted by account ID), the cycle can’t happen.
  2. 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.
  3. Drop guards early. A guard unlocks when it’s dropped. You can end it with an inner block { ... } (like the RwLock writer above) or call drop(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 report
done: resize photo
done: send email
▶ Try it Yourself

Without 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.

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.

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` keyword

You 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 loop

Wrap 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).

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: 500500
▶ Try it Yourself

The 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.

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]
▶ Try it Yourself

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::scope guarantees its threads finish before it ends, so they can borrow local data without Arc or move.
  • RwLock allows many readers or one writer. It helps with read-heavy data; otherwise a Mutex is simpler.
  • Atomics (AtomicUsize, AtomicI64, AtomicBool) handle counters and flags with no lock. Use SeqCst when unsure, Relaxed for simple counters.
  • LazyLock and OnceLock create 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

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