Skip to content
Educora
Advanced22 min9 / 10

Smart pointers and concurrency

Learn the smart pointers `Box`, `Rc` and `RefCell`, then write parallel code without data races using threads, channels and `Arc<Mutex<T>>`.

Check yourself
In this lesson you will learn
  • Store a value on the heap with Box and build a recursive type
  • Create several owners with Rc and interior mutability with RefCell
  • Start threads with move closures and collect results with join
  • Share data between threads with channels and Arc<Mutex<T>>

Until now, every value had a single owner. But sometimes you need something else: a recursive structure whose size is unknown in advance, the same song in several playlists, changing shared data, or splitting work across several processor cores. For this, Rust has smart pointers and concurrency tools — and they obey the ownership rules too.

`Box<T>`: a value on the heap

Box::new(x) puts a value on the heap, and only a fixed-size pointer stays on the stack. When the box's owner goes out of scope, the value on the heap is dropped automatically too. Box is needed in three cases: recursive types whose size is unknown at compile time, trait objects such as Box<dyn Trait>, and large data that is expensive to move around on the stack.

Error E0072
enum List {
    Node(i32, List),
    Empty,
}
Correct with `Box`
enum List {
    Node(i32, Box<List>),
    Empty,
}
On the left, List contains a List inside itself, so the compiler says: recursive type List has infinite size. A Box is a fixed-size pointer and breaks the cycle.
Rust
enum List {
    Node(i32, Box<List>),
    Empty,
}

use List::{Empty, Node};

fn sum(list: &List) -> i32 {
    match list {
        Node(value, next) => value + sum(next),
        Empty => 0,
    }
}

fn main() {
    let boxed = Box::new(5);
    println!("boxed + 1 = {}", *boxed + 1);

    let list = Node(1, Box::new(Node(2, Box::new(Node(3, Box::new(Empty))))));
    println!("sum = {}", sum(&list));
}
Expected output
boxed + 1 = 6
sum = 6

`Rc<T>`: several owners

Rc (reference counting) lets one value have several owners. Rc::clone(&x) does not copy the data; it only increases a counter by one, which is very cheap. Each time an owner is dropped the counter goes down, and when it reaches zero the value is dropped. Rc only allows reading the value and works only within a single thread.

Rust
use std::rc::Rc;

fn main() {
    let song = Rc::new(String::from("lesson.mp3"));
    println!("owners: {}", Rc::strong_count(&song));

    let playlist_a = Rc::clone(&song);
    {
        let playlist_b = Rc::clone(&song);
        println!("owners: {}", Rc::strong_count(&song));
        println!("b plays {playlist_b}");
    }
    println!("owners: {}", Rc::strong_count(&song));
    println!("a plays {playlist_a}");
}
Expected output
owners: 1
owners: 3
b plays lesson.mp3
owners: 2
a plays lesson.mp3
playlist_b was dropped when the inner block ended, and the counter went from 3 to 2. The text itself is stored in memory only once.

`RefCell<T>`: interior mutability

RefCell checks the borrowing rules while the program runs instead of at compile time. borrow() lends the value for reading, and borrow_mut() for changing. This lets you change a value behind an immutable reference. The combination Rc<RefCell<T>> is the typical solution: several owners can both read and change shared data.

Rust
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let scores = Rc::new(RefCell::new(vec![80, 95]));
    let teacher = Rc::clone(&scores);

    teacher.borrow_mut().push(70);
    scores.borrow_mut().push(100);

    println!("{:?}", scores.borrow());
    let total: i32 = teacher.borrow().iter().sum();
    println!("total = {total}");
}
Expected output
[80, 95, 70, 100]
total = 345
TypeOwnersMutationShared across threads
Box<T>oneif the owner is mutyes
Rc<T>severalnono
RefCell<T>oneyes, checked at run timeno — it can be moved to another thread but not shared (it is not Sync)
Arc<T>severalnoyes
Arc<Mutex<T>>severalyes, through a lockyes

Threads

thread::spawn runs a closure in a new thread and returns a JoinHandle. join() waits for the thread to finish and gives back its result. The move in front of the closure hands ownership of the variables it uses to the thread. Note: lines that threads print themselves may appear in a different order on every run. That is why, below, the threads return results and main prints them in a fixed order.

Rust
use std::thread;

fn main() {
    let mut handles = Vec::new();
    for id in 1..=3_u64 {
        let handle = thread::spawn(move || {
            let sum: u64 = (1..=id * 1000).sum();
            (id, sum)
        });
        handles.push(handle);
    }
    for handle in handles {
        let (id, sum) = handle.join().unwrap();
        println!("thread {id}: sum = {sum}");
    }
}
Expected output
thread 1: sum = 500500
thread 2: sum = 2001000
thread 3: sum = 4501500
Error E0373
use std::thread;

fn main() {
    let names = vec!["Aysel", "Murad"];
    let handle = thread::spawn(|| {
        println!("{:?}", names);
    });
    handle.join().unwrap();
}
Correct with `move`
use std::thread;

fn main() {
    let names = vec!["Aysel", "Murad"];
    let handle = thread::spawn(move || {
        println!("{:?}", names);
    });
    handle.join().unwrap();
}
On the left: closure may outlive the current function, but it borrows names. The thread could live longer than main, so a reference is not enough. The program on the right prints ["Aysel", "Murad"].

Channels and `Arc<Mutex<T>>`

There are two ways to share data between threads. The first is a channel: mpsc::channel() creates a transmitter (tx) and a receiver (rx); one thread sends with send and another receives. You can loop over the receiver with for — the loop ends when all transmitters have been dropped. Messages from a single sender arrive in the order they were sent.

Rust
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();
    let worker = thread::spawn(move || {
        for step in ["download", "unpack", "install"] {
            tx.send(step).unwrap();
        }
    });
    for message in rx {
        println!("got: {message}");
    }
    worker.join().unwrap();
    println!("done");
}
Expected output
got: download
got: unpack
got: install
done

The second way is shared state. Arc is the thread-safe (atomic) version of Rc, and Mutex is a lock: the thread that calls lock() gets sole access to the data, and the lock is released automatically when the returned guard goes out of scope. Together, Arc<Mutex<T>> lets several threads change the same value, one at a time.

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

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = Vec::new();
    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut n = counter.lock().unwrap();
            *n += 1;
        }));
    }
    for handle in handles {
        handle.join().unwrap();
    }
    println!("Result: {}", *counter.lock().unwrap());
}
Expected output
Result: 10
Whatever order the threads run in, the lock guarantees the result is always 10. Each thread gets its own copy of the Arc — written with shadowing: let counter = Arc::clone(&counter);.

Key points

  • Box<T> keeps a value on the heap; it is needed for recursive types and dyn Trait.
  • Rc<T> gives several owners within one thread; Rc::clone only increases a counter.
  • RefCell<T> checks the borrowing rules at run time; breaking them causes a panic.
  • thread::spawn(move || ...) starts a thread and join() waits for its result; print in a fixed order from main.
  • Channels pass messages, while Arc<Mutex<T>> gives shared mutable state between threads.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
Why does enum List { Node(i32, List), Empty } not compile?