Concurrency · about 40 min

Send, Sync and Scoped Threads

Why this matters

The previous lessons claimed that Rust prevents data races at compile time. This lesson shows the mechanism: two marker traits, Send and Sync, that describe which types may cross thread boundaries. They explain the errors you get when you try to share an Rc or a RefCell between threads. Then we look at scoped threads, which let threads borrow local data safely, and atomics, the lightweight alternative to a mutex for simple counters and flags.

Send and Sync

They are auto traits: the compiler implements them automatically for any type whose fields are all Send (or Sync). You almost never implement them yourself. Nearly everything is both: integers, String, Vec<T> (if T is), Box<T>, Arc<T> (if T is Send + Sync), Mutex<T> (if T is Send).

The notable exceptions are types with unsynchronized shared internals:

typeSendSyncwhy
Rc<T>nonoits reference count is updated non-atomically
RefCell<T>yes (if T is)noits borrow counter is not thread-safe
Cell<T>yes (if T is)nosame reason
raw pointersnonothe compiler cannot know what they point to

thread::spawn requires its closure (and everything it captures) to be Send + 'static. That single bound is what turns thread-safety mistakes into compile errors:

use std::rc::Rc;
use std::thread;

fn main() {
    let shared = Rc::new(5);
    let clone = Rc::clone(&shared);
    let h = thread::spawn(move || {
        println!("{clone}");
    });
    h.join().unwrap();
}
error[E0277]: `Rc<i32>` cannot be sent between threads safely
 --> main.rs:7:27
  |
7 |       let h = thread::spawn(move || {
  |               ------------- ^------
  |               |             |
  |  _____________|_____________within this `{closure@main.rs:7:27: 7:34}`
  | |             |
  | |             required by a bound introduced by this call
8 | |         println!("{clone}");
9 | |     });
  | |_____^ `Rc<i32>` cannot be sent between threads safely
  |
  = help: within `{closure@main.rs:7:27: 7:34}`, the trait `Send` is not implemented for `Rc<i32>`
note: required because it's used within this closure
 --> main.rs:7:27
  |
7 |     let h = thread::spawn(move || {
  |                           ^^^^^^^
note: required by a bound in `spawn`
 --> /usr/src/rustc-1.85.1/library/std/src/thread/mod.rs:728:1

The message names the culprit (Rc<i32> is not Send) and where it is captured. Switch to Arc and it compiles. Similarly, Arc<RefCell<T>> fails because RefCell is not Sync; use Arc<Mutex<T>>.

So "fearless concurrency" is not magic: ownership prevents two threads from having &mut to the same data, and Send/Sync prevent sharing types whose internals are not thread-safe. What remains possible are logic errors such as deadlocks and race conditions in order (who finishes first), but not memory corruption from data races.

Scoped threads: borrowing local data

thread::spawn requires 'static because the new thread might outlive the current function. Often you know it will not: you spawn some threads, wait for them, and continue. thread::scope expresses exactly that:

use std::thread;

fn main() {
    let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
    let mut results = [0i32; 2];
    let (left, right) = data.split_at(data.len() / 2);
    let (r0, r1) = results.split_at_mut(1);
    thread::scope(|s| {
        s.spawn(|| r0[0] = left.iter().sum());
        s.spawn(|| r1[0] = right.iter().sum());
    });
    println!("data still owned by main: {:?}", data);
    println!("partial sums: {:?}", results);
}
data still owned by main: [1, 2, 3, 4, 5, 6, 7, 8]
partial sums: [10, 26]

Scoped threads are the simplest way to parallelize work over borrowed data. (Libraries like rayon build on the same idea with par_iter(); not available in the sandbox.)

Atomics

For a single counter or flag, a Mutex is more machinery than needed. Atomic types (AtomicUsize, AtomicU64, AtomicBool, ...) perform read-modify-write operations as single indivisible CPU instructions:

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

fn main() {
    let hits = AtomicUsize::new(0);
    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| {
                for _ in 0..10_000 {
                    hits.fetch_add(1, Ordering::Relaxed);
                }
            });
        }
    });
    println!("hits: {}", hits.load(Ordering::Relaxed));
}
hits: 40000

fetch_add cannot lose updates, and atomics are Sync, so they can be shared by reference (here from scoped threads; with spawn you would wrap them in an Arc). The Ordering argument controls how the operation is ordered relative to other memory operations. Relaxed is fine for an independent counter; when an atomic is used to publish other data ("the result is ready"), use Release for the write and Acquire for the read, or SeqCst if unsure. Memory ordering is a deep subject; for anything beyond counters and flags, prefer a Mutex or channels.

Choosing a concurrency tool

needtool
independent work, collect resultsthread::spawn + join, or scoped threads
work over borrowed local datathread::scope
pass data from producers to a consumerchannels (mpsc)
shared mutable stateArc<Mutex<T>> (or RwLock)
a shared counter or flagatomics

Common mistakes

Recap

Going further: read the Rust Book chapter 16.4 and the std::sync::atomic module docs. Mara Bos's book "Rust Atomics and Locks" (free online) is the deep dive into everything in this module.

Exercises

Exercise 1 · write code

Borrow data in scoped threads

Read integers from stdin (one per line). Using std::thread::scope, spawn two scoped threads that borrow the vector (no cloning, no Arc): one computes the sum of the first half, the other the sum of the second half (split_at(len / 2)). Print both and the total:

first half: 6
second half: 15
total: 21

for 1 2 3 4 5 6.

File: main.rs

Starting point

use std::io;
use std::thread;

fn main() {
    let nums: Vec<i64> = io::stdin().lines().map(|l| l.unwrap().trim().parse().unwrap()).collect();
    let (left, right) = nums.split_at(nums.len() / 2);
    let a = thread::spawn(|| left.iter().sum::<i64>());
    let b = thread::spawn(|| right.iter().sum::<i64>());
    let (x, y) = (a.join().unwrap(), b.join().unwrap());
    println!("first half: {x}");
    println!("second half: {y}");
    println!("total: {}", x + y);
}

Example

input

1
2
3
4
5
6

expected output

first half: 6
second half: 15
total: 21

Example

input

7

expected output

first half: 0
second half: 7
total: 7
Hint 1

thread::spawn requires 'static data, so borrowing left fails. thread::scope(|s| { ... }) guarantees all threads finish inside the scope.

Hint 2

Inside: let a = s.spawn(|| left.iter().sum::<i64>()); and return the joined results from the scope closure.

Reference solution
use std::io;
use std::thread;

fn main() {
    let nums: Vec<i64> = io::stdin().lines().map(|l| l.unwrap().trim().parse().unwrap()).collect();
    let (left, right) = nums.split_at(nums.len() / 2);
    let (x, y) = thread::scope(|s| {
        let a = s.spawn(|| left.iter().sum::<i64>());
        let b = s.spawn(|| right.iter().sum::<i64>());
        (a.join().unwrap(), b.join().unwrap())
    });
    println!("first half: {x}");
    println!("second half: {y}");
    println!("total: {}", x + y);
}

Exercise 2 · write code

Count with an atomic

Read words from stdin (one per line) into a Vec<String>. Using thread::scope with 4 threads (thread t checks the words at indexes i % 4 == t), count how many words are palindromes (same forwards and backwards, compared by chars) into a shared AtomicUsize with fetch_add(1, Ordering::Relaxed). Print:

palindromes: 3

for level, rust, noon, a, ab.

File: main.rs

Starting point

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

fn main() {
    let words: Vec<String> = io::stdin().lines().map(|l| l.unwrap().trim().to_string()).collect();
    let count = AtomicUsize::new(0);
    println!("palindromes: {}", count.load(Ordering::Relaxed));
}

Example

input

level
rust
noon
a
ab

expected output

palindromes: 3
Hint 1

Scoped threads may borrow words and count; the threads are joined automatically when the scope ends.

Hint 2

Inside the loop over t, make references let words = &words; let count = &count; and use move || so each thread gets its own t and the references.

Hint 3

w.chars().eq(w.chars().rev()) tests for a palindrome.

Reference solution
use std::io;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

fn is_palindrome(w: &str) -> bool {
    w.chars().eq(w.chars().rev())
}

fn main() {
    let words: Vec<String> = io::stdin().lines().map(|l| l.unwrap().trim().to_string()).collect();
    let count = AtomicUsize::new(0);
    thread::scope(|s| {
        for t in 0..4 {
            let words = &words;
            let count = &count;
            s.spawn(move || {
                for (i, w) in words.iter().enumerate() {
                    if i % 4 == t && is_palindrome(w) {
                        count.fetch_add(1, Ordering::Relaxed);
                    }
                }
            });
        }
    });
    println!("palindromes: {}", count.load(Ordering::Relaxed));
}

Exercise 3 · quiz

Send or not?

Which of these can be moved into a thread spawned with std::thread::spawn (that is, which are Send + 'static)? Select all that apply.

Answer
  • Vec<String>

  • Arc<Mutex<i32>>

  • Arc<Vec<u8>>

Owned data made of Send parts is Send. Rc is not Send: its non-atomic counter would be corrupted if two threads cloned it at once. A reference to a local is Send, but not 'static, because main's local might die first (use thread::scope for that). Arc is Send and Sync when its contents are, which is why Arc<Mutex<T>> is the standard way to share.