Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

🦀Tutorial: Rust Concurrency

Rust Concurrency Tutorial: From Threads to Fearless Sharing

Section titled “Rust Concurrency Tutorial: From Threads to Fearless Sharing”

Welcome! This tutorial is designed for students who are familiar with introductory Rust and want to build high-performance, parallel applications, which are often necessary for responsive and efficient edge computing.

This tutorial explores Rust’s “fearless concurrency,” building on the concepts in Chapter 11. We’ll start with basic threads and progress to the advanced patterns like Arc<Mutex<T>> that enable safe, concurrent state-sharing. Please note that the final two questions are quite advanced.

Good luck!


The simplest form of concurrency is spawning a new thread to run code in parallel. In this question we examine how you spawn a new thread that prints a message while the main thread also prints a message.

  1. Adapt the following code so that both the main thread and the spawned thread both count to 10 at the same time.
  2. Demonstrate that the output is likely not properly interleaved and briefly explain why.
  3. Change the delay in the main thread to be 50ms and comment out the very last thread::sleep() call and describe what happens.
use std::thread;
use std::time::Duration;
fn main() {
// 1. Spawn a new thread
thread::spawn(|| {
// do something here...
thread::sleep(Duration::from_millis(100));
});
// 2. The main thread also does work
// do something here...
thread::sleep(Duration::from_millis(100));
// 3. We add a final sleep in main to let the spawned thread finish
// Try commenting this out to see what happens!
thread::sleep(Duration::from_millis(2000));
}

Question 2. Using move to Give Ownership to Threads

Section titled “Question 2. Using move to Give Ownership to Threads”

Threads often need to use data from the main thread. In this question we examine how you move ownership of a variable (like a String) into a spawned thread so it can be used there.

  1. Using the code below as the starting point (and the code from Question 1 as a reference), create a thread that displays a message. There is no need for thread::sleep calls this time.
  2. Use the move keyword to transfer ownership of the message string to the thread.
  3. Explain why the line at // Point 3 will not compile.
use std::thread;
fn main() {
// 1. Create data in the main thread
let message = String::from("Hello from the main thread!");
// Point 2. Use move to transfer and display the message in the thread
let handle = thread::spawn(|| {
// display the message here.
});
// Point 3. Why will this not compile?
// println!("Main thread still has: {}", message);
// Wait for the thread to finish
handle.join().unwrap();
}

Question 3. Waiting for Threads with JoinHandle

Section titled “Question 3. Waiting for Threads with JoinHandle”

In Question 1, we used thread::sleep to wait, which is unreliable. We can use JoinHandle as the correct way to make the main thread wait for a spawned thread to complete.

  1. The thread::spawn function returns a JoinHandle<T>, which acts as a “controller” for the thread. Calling handle.join() blocks the current thread until the thread associated with handle has finished.
  2. Adapt the following segment of code to block the main thread until the spawned thread has completed.
use std::thread;
use std::time::Duration;
fn main() {
// Point 1. Adapt to return a JoinHandle...
thread::spawn(|| {
println!("Spawned thread running...");
let mut final_val = 0;
for i in 1..=10 {
thread::sleep(Duration::from_millis(100));
final_val = i;
}
println!("Spawned thread finished.");
// We can return a value from the thread
return final_val;
});
println!("Main thread is waiting...");
// 3. Fix this result to block and display the join handle result...
let result = 0;
println!("Main thread is done waiting.");
println!("The spawned thread returned: {}", result);
}

This code currently displays:

Terminal window
Main thread is waiting...
Main thread is done waiting.
The spawned thread returned: 0
Spawned thread running...

The final code should display:

Terminal window
Main thread is waiting...
Spawned thread running...
Spawned thread finished.
Main thread is done waiting.
The spawned thread returned: 10

Question 4. Communicating with Channels (mpsc)

Section titled “Question 4. Communicating with Channels (mpsc)”

It is a standard Rust pattern to demonstrate inter-thread communication using a channel from the standard library (std::sync::mpsc). A channel is a mechanism for safely sending data between threads and mpsc stands for “multiple producer, single consumer.”:

  • Multiple producers: you can clone the transmitter (tx) and send messages from multiple threads.
  • Single consumer: only one receiver (rx) can receive messages.

Think of it like a pipe: one end (tx) sends data, the other end (rx) receives it. The following code example provides a minimal setup.

Study the following code segment and adapt it to send back five string messages so that the output no longer panics and returns the expected output.

Note: you only need to adapt the code slightly in this question at the location marked //fix. The more important point is that you understand the code segment provided.

use std::sync::mpsc; // mpsc = "multiple producer, single consumer"
use std::thread;
use std::time::Duration;
fn main() {
// 1. Create a new channel
let (tx, rx) = mpsc::channel();
// 2. Spawn a thread, giving it the transmitter (tx)
thread::spawn(move || {
// creating a vector of five string messages
let messages = vec![
// fix
];
for msg in messages {
// Send each message
tx.send(msg).unwrap();
thread::sleep(Duration::from_millis(200));
}
});
// 4. In main, `recv()` blocks until a message arrives
// `rx.iter()` would also work, which loops until the channel closes.
for _ in 0..5 {
let received: String = rx.recv().unwrap();
println!("Main got: {}", received);
}
}

Expected Output:

Terminal window
Main got: Hello EEN1097
Main got: from
Main got: the
Main got: spawned
Main got: thread!

Question 5. The Problem: Sharing State (and Failing)

Section titled “Question 5. The Problem: Sharing State (and Failing)”

In this question, you’ll explore one of the most common challenges in concurrent programming: how to safely share data between threads. Rust’s ownership system enforces strict rules to prevent data races, but those same rules can make it surprisingly hard to share mutable state — especially when you first try it without the right tools. Imagine you want to have several threads all update a shared counter.

In languages like C++ or Python, you might simply share a global variable and add a lock around it. In Rust, however, ownership and lifetimes are enforced at compile time, which means the compiler prevents you from even attempting unsafe sharing.

Rust provides tools like:

  • Mutex<T> a mutual exclusion lock that allows safe access to shared data, but only one thread at a time can hold the lock.
  • Arc<T> an atomic reference-counted smart pointer that enables multiple ownership across threads.

In this question, you’ll first see why you can’t just share a Mutex directly, and then understand why Arc is needed.

  1. Create a Mutex-protected counter by starting with this line: let counter = Mutex::new(0); The Mutex ensures only one thread can modify the counter at a time.
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
//…
}
  1. Attempt to spawn a thread that modifies the counter by writing the following code. Note that this fails to compile. Read the compiler’s error as it’s telling you something important about ownership and lifetimes.
thread::spawn(|| {
let mut num = counter.lock().unwrap();
*num += 1;
});
  1. If you add move (as suggested by the compiler), the counter is moved into the new thread, and the main thread loses ownership. The code will compile. If you omit move, the closure tries to borrow the counter, but Rust forbids that because the spawned thread could outlive the borrowed reference.
  2. Add a line at the end of the program to once again use the counter — for example, let _ = counter.lock().unwrap(); The let _ notation is a special pattern in Rust used to intentionally discard a value. It’s a placeholder that tells the compiler, “I know a value is being returned or created here, but I have no intention of using it, so don’t warn me about it.”
  3. The issue has once again appeared. The fundamental issue is that Rust’s ownership rules do not allow a value (like Mutex<T>) to be owned by multiple threads at once. To truly share the Mutex between threads, each thread needs a reference-counted, thread-safe handle, which is exactly what Arc<Mutex<T>> provides.

You’ll fix this problem in the next question by introducing Arc.


Question 6. The Solution: Arc<Mutex<T>> for Sharing State Safely

Section titled “Question 6. The Solution: Arc<Mutex<T>> for Sharing State Safely”

In Question 5, you discovered that simply putting data inside a Mutex is not enough to share it between threads. Rust still prevents the program from compiling because the underlying problem is ownership: a value can only ever have one owner, and a Mutex<T> by itself cannot be safely shared across threads.

The correct solution (and a pattern you will use again and again in Rust) is to combine:

  • Arc<T>: Atomic reference counting. Allows multiple threads to own the same value.
  • Mutex<T>: Mutual exclusion. Allows safe mutation of shared data.

Together, Arc<Mutex<T>> gives you shared ownership with safe interior mutability. This question guides you through rebuilding the broken example from Question 5, step-by-step using the correct pattern. Complete the following steps using the code from Question 5 as the starting point:

  1. Wrap your shared counter in a Mutex by starting with: let counter = Mutex::new(0); and then wrap that inside an Arc. So, let counter = Arc::new(Mutex::new(0)); Use the compiler error to identify the correct library to use.
  2. Prepare to spawn multiple threads by creating a vector to store their JoinHandles: using let mut handles = vec![]; (Note: you should have an error at this point as the Vec type is unknown as the compiler cannot infer it directly.). Use a simple for loop to create 10 different threads.
  3. Clone the Arc for each of the 10 threads as every new thread needs its own reference-counted pointer using: let counter_clone = Arc::clone(&counter); (Note: Cloning an Arc is cheap as it only increments the atomic reference count.)
  4. Move the clone into each thread so that each thread will own its clone and use it to access the shared Mutex using: let handle = thread::spawn(move || { … }); Push each of the 10 handles onto the handles vector. (When you do this the error mentioned in Point 2 should be resolved as the compiler can now resolve the type of the vector).
  5. Lock the Mutex and modify the data. Inside the thread: use the following code: let mut num = counter_clone.lock().unwrap(); And increment the number by 1. Now when num goes out of scope, the lock is automatically released.
  6. Finally, Join all the threads to ensure that main does not exit early and read the final value, by locking the mutex one last time and printing the result. You will need to loop through each of the 10 handles to perform the join.

By completing these steps, you will have built the canonical Rust pattern for shared-state concurrency.

If you successfully complete all of these steps, the output should be simply:

Terminal window
Final count: 10

Question 7. Beyond Mutex: Using RwLock for Read-Heavy Workloads — Advanced

Section titled “Question 7. Beyond Mutex: Using RwLock for Read-Heavy Workloads — Advanced”

In the previous questions, you learned how Mutex gives exclusive access to shared state and how Arc<Mutex<T>> enables multiple threads to share and mutate data safely. However, a Mutex is often too restrictive. A Mutex enforces mutual exclusion for both reading and writing, which means: even if 100 threads only want to read a value, they must still take the lock one at a time. This quickly becomes a bottleneck.

To resolve this, Rust offers a different synchronisation primitive: RwLock<T> (Read-Write Lock) that allows many concurrent readers (read()) and one exclusive writer (write()).

This pattern is ideal for read-mostly data such as: configuration values, lookup tables, sensor calibration parameters, and shared static metadata. In this question, you will explore how RwLock behaves in a multi-threaded environment.

Complete the following tasks, by heavily editing your code from Question 6, and removing any reference to counter:

  1. Finish the line of code provided below to wrap a shared configuration String with the value “EEN1097 Configuration” in an Arc<RwLock<String>>, to give you shared ownership (via Arc) and protected mutable access (via RwLock) to that string.
let config = Arc::new(RwLock::new(...));
  1. Similar to Question 6, spawn five reader threads, where each one should acquire a read lock using a clone of the config value (as in Question 6). Use .read().unwrap(), print the value they observe and hold the lock briefly using thread::sleep(Duration::from_millis(100)) to simulate slow reading. Push the handles onto a vector in the same manner as Question 6.
  2. Outside the loop, spawn a new single writer thread. Add a sleep() so that it waits a short time so that readers start first. Then attempt to acquire a write lock using .write().unwrap(). For example:
let mut c = config_clone.write().unwrap();
*c = String::from("Updated Config string");

Modify the shared data and observe that the writer must wait for all read locks to be released.

  1. Join all threads as in Question 6. Ensure the main thread waits for the readers and writer to finish. Use the Debug trait to output the final data.

The output should be:

Terminal window
Reader 0 sees: EEN1097 Configuration
Reader 2 sees: EEN1097 Configuration
Reader 4 sees: EEN1097 Configuration
Reader 3 sees: EEN1097 Configuration
Reader 1 sees: EEN1097 Configuration
Writer is trying to lock...
Writer has updated the config!
The configuration is now: RwLock { data: "Updated EEN1097 Config", poisoned: false, .. }
  1. Reflect on lock behaviour:
  • Why can many readers run concurrently?
  • Why must the writer wait?
  • What happens if the writer runs first?
  • How does this differ from using a Mutex?

This question helps you understand when and why RwLock is preferable to Mutex, especially on edge devices where configuration or read-mostly data is common.


Question 8. Why Rc<T> Cannot Be Shared Across Threads (The Send Trait in Action) — Advanced

Section titled “Question 8. Why Rc<T> Cannot Be Shared Across Threads (The Send Trait in Action) — Advanced”

In the previous question, you learned that safe shared ownership across threads requires Arc<T>. This question dives deeper into why Rust refuses to allow Rc<T> to cross thread boundaries, and how the Send trait enforces this rule at compile time.

Complete the following tasks, by creating a new program (code for steps 1-4 provided below):

  1. Create a value wrapped in** Rc<T>** begin with:
use std::rc::Rc;
use std::thread;

And then in main():

let data = Rc::new(String::from("I am not thread-safe"));
  1. Attempt to clone the Rc<T> and move it into a new thread by trying to write:
let data_clone = Rc::clone(&data);
thread::spawn(move || {
println!("Thread got: {}", data_clone);
});
  1. You should observe that this does not compile. Read the compiler message carefully as Rust will tell you that Rc<T> does not implement Send, which is required for values moved into a new thread.
  2. Explain why Rc<T> is not allowed across threads. Hint: consider how its reference count is updated.
  3. Reflect on the solution. Replace Rc<T> with Arc<T> and observe that the code now compiles, thanks to atomic reference counting.

Starting point (Will Not Compile before Point 5):

use std::rc::Rc; // Not thread-safe!
use std::thread;
fn main() {
let data = Rc::new(String::from("I am not thread-safe"));
/*
let data_clone = Rc::clone(&data);
thread::spawn(move || {
println!("Thread got: {}", data_clone);
});
*/
println!("This example will not compile.");
println!("Rc<T> cannot be sent safely to another thread.");
}