Rust Concurrency Explained: A Beginner's Guide to Arc and Mutex
Iñigo Etxaniz
Posted on November 17, 2023
Introduction
Jumping into Rust programming has been quite a ride. At first, it felt a bit like trying to solve a puzzle, especially when it came to understanding how Rust handles data. The borrowing concept? Got it, no big deal. But things got trickier when I started wondering how you could let two parts of your program share and change the same data without stepping on each other's toes. That's where Arc
and Mutex
come into play.
So, I thought, why not make an example to figure this out? The idea was to set up two threads in a Rust program, with both threads messing around with the same bunch of data. They would each bump up a couple of counters - one that they both share and another that's just for themselves. Then, right after each bump, we'd take a look at where the counters stand.
This wasn't just about writing some code; it was more about getting my head around Rust's way of handling data when multiple things are happening at once. Here's the story of what I did, what I learned, and how it all works.
The Application Idea: Playing with Counters
So, what's this example all about? Well, it's pretty straightforward:
-
Two Threads, Two Tasks: Imagine two little workers (threads) in our program. They're both doing a similar job but in their own way. One is labeled
thread1
and the otherthread2
. -
What They Do: Each time they get to work (
thread1
andthread2
doing their thing), they do a couple of things:-
thread1
ups the numbers on two counters: a shared one (count
) and its own (count_thread1
). Then it shouts out (prints) the current score. -
thread2
does pretty much the same, but withcount
(the shared one) andcount_thread2
(its own personal counter).
-
- Keep It DRY (Don't Repeat Yourself): I wanted to be smart about this and not write the same piece of code twice for showing the score. So, both threads use the same function to print the status. It's like they're using the same megaphone to announce their results.
- Playing Nice with Sharing: Here's the catch, though. When one thread is updating the counters and about to announce the score, we don't want the other one to barge in and mess things up. It's like saying, "Hold on, let me finish talking before you jump in." This means we need to be a bit clever about locking things up.
That's the gist of our little Rust adventure. Two threads, a few counters, and making sure they don't trip over each other while they're at it.
Explaining the Service Code: Mutex Magic in Rust
Alright, let's dive into the heart of our Rust code - the Service
struct. This is where the magic happens, and by magic, I mean carefully managing access to shared data with mutexes. Here's the code:
pub struct Service {
count: std::sync::RwLock<u64>,
count_thread1: std::sync::RwLock<u64>,
count_thread2: std::sync::RwLock<u64>,
write_lock: std::sync::RwLock<u8>,
}
impl Service {
pub fn new() -> Self {
Service {
count: std::sync::RwLock::new(0),
count_thread1: std::sync::RwLock::new(0),
count_thread2: std::sync::RwLock::new(0),
write_lock: std::sync::RwLock::new(0),
}
}
pub fn get_counts(&self) -> Result<(u64, u64, u64), String> {
let count = *self
.count
.read()
.map_err(|e| format!("Failed to read-lock count: {}", e))?;
let count_thread1 = *self
.count_thread1
.read()
.map_err(|e| format!("Failed to read-lock count_thread1: {}", e))?;
let count_thread2 = *self
.count_thread2
.read()
.map_err(|e| format!("Failed to read-lock write_lock: {}", e))?;
Ok((count, count_thread1, count_thread2))
}
pub fn increment_counts_thread1(&self) -> Result<(u64, u64, u64), String> {
let mut count = self
.count
.write()
.map_err(|e| format!("Failed to write-lock count: {}", e))?;
let mut count_thread1 = self
.count_thread1
.write()
.map_err(|e| format!("Failed to write-lock count_thread1: {}", e))?;
let mut write_lock = self
.write_lock
.write()
.map_err(|e| format!("Failed to write-lock write_lock: {}", e))?;
*count += 1;
*count_thread1 += 1;
*write_lock = 1;
drop(count);
drop(count_thread1);
self.get_counts()
}
pub fn increment_counts_thread2(&self) -> Result<(u64, u64, u64), String> {
let mut count = self
.count
.write()
.map_err(|e| format!("Failed to write-lock count: {}", e))?;
let mut count_thread2 = self
.count_thread2
.write()
.map_err(|e| format!("Failed to write-lock count_thread2: {}", e))?;
let mut write_lock = self
.write_lock
.write()
.map_err(|e| format!("Failed to write-lock write_lock: {}", e))?;
*count += 1;
*count_thread2 += 1;
drop(count);
drop(count_thread2);
*write_lock = 2;
self.get_counts()
}
}
Breaking Down the Code
Counters for Each Thread: We've got three counters here -
count
,count_thread1
, andcount_thread2
. The first one is shared between both threads, while the other two are individual to each thread. Each counter is wrapped in aMutex
. Why? BecauseMutex
ensures that only one thread can mess with the data at a time.The
write_lock
Mutex: This little guy is the key to making sure our print status function doesn't get interrupted by the other thread. We're using it to lock down the entire increment operation, from start to finish, including the print part. And to keep the compiler happy (and avoid warnings about unused variables), we're assigning a value (1
or2
) towrite_lock
.Lock Order Consistency: Notice something about how we lock our counters? We always lock
count
first, thencount_thread1
orcount_thread2
. This is crucial. Locking in the same order every time is a simple yet effective way to dodge deadlocks. If you start locking in different orders in different parts of your code, you're setting up a classic deadlock scenario.
Choosing RwLock Over Mutex: A Consideration of Context
In the Rust ecosystem, we often use Mutex
for safe, exclusive access to data across multiple threads. Think of Mutex
as a way to say, "One at a time, please," ensuring that only one thread can access the data at any given moment. It's a straightforward, foolproof approach to concurrency.
But then there's RwLock
, a slightly more complex cousin of Mutex
.
The Subtlety of RwLock
-
Reading and Writing:
RwLock
allows multiple threads to read data at the same time, which can be a big win for performance if you have a lot of read operations. However, it still ensures that write operations get exclusive access. -
In Our Case: While our current example might not have simultaneous reads, we chose
RwLock
to illustrate how it could be beneficial in a broader application context. It's about understanding the tools at your disposal and choosing the right one for the right job.
The Practical Angle
-
Why Not Just Stick with Mutex?: You might wonder why we didn't just use
Mutex
since our example doesn’t explicitly require the concurrent read capabilities ofRwLock
. The reason is twofold: firstly, to demonstrate the capabilities ofRwLock
for educational purposes, and secondly, to prepare the code for potential scalability where concurrent reads might become more relevant. -
Big Picture: In a larger application, where you might have numerous threads frequently reading data, the benefits of
RwLock
become more pronounced. By allowing concurrent reads,RwLock
can significantly enhance performance, reducing the waiting time for threads that just want to read data.
Choosing RwLock
in our example is a nod to these broader considerations. It’s about anticipating future needs and understanding how different concurrency tools can be leveraged in various scenarios. This is a key part of thinking like a Rustacean — not just solving the problem at hand but doing so in a way that's efficient, scalable, and idiomatic to Rust.
Explaining the main
Function and the Role of Arc
In our Rust application, the main
function is where we see the concurrency in action. Let's dissect how it uses Arc
to enable multiple threads to interact with the same instance of our Service
struct.
The main
Function Code
Here's what our main
function looks like:
mod service;
use service::Service;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
fn main() {
let service = Arc::new(Service::new());
let service1 = Arc::clone(&service);
let service2 = Arc::clone(&service);
let thread1 = thread::spawn(move || {
for i in 0..5 {
match service1.increment_counts_thread1() {
Ok(counts) => println!("Thread 1: Iteration {}: Counts = {:?}", i, counts),
Err(e) => {
eprintln!("Thread 1: Iteration {}: Error = {}", i, e);
// Handle error, e.g., retry, log, or break
break;
}
}
thread::sleep(Duration::from_millis(250));
}
});
let thread2 = thread::spawn(move || {
for i in 0..5 {
match service2.increment_counts_thread2() {
Ok(counts) => println!("Thread 2: Iteration {}: Counts = {:?}", i, counts),
Err(e) => {
eprintln!("Thread 2: Iteration {}: Error = {}", i, e);
// Handle error, e.g., retry, log, or break
break;
}
}
thread::sleep(Duration::from_millis(250));
}
});
if let Err(e) = thread1.join() {
eprintln!("Thread 1 panicked: {:?}", e);
}
if let Err(e) = thread2.join() {
eprintln!("Thread 2 panicked: {:?}", e);
}
}
Understanding Arc
in Rust
-
Arc: A Smart Pointer for Concurrency:
Arc
stands for Atomic Reference Counting. It's a type of smart pointer in Rust, which means it keeps track of how many references exist to a certain piece of data. Once all references are gone, the data is automatically cleaned up. This is super handy in avoiding memory leaks. -
Why Use
Arc
?: In a multi-threaded context, we need a way to safely share data between threads.Arc
allows multiple threads to own a reference to the same data, ensuring that the data stays alive as long as at least one thread is using it. -
Thread-Safety:
Arc
is thread-safe, meaning it can be used across multiple threads without the risk of causing data races. This is crucial in our example where multiple threads are accessing and modifying the sharedService
instance.
Threads in Action
-
Creating Threads: We spawn two threads,
thread1
andthread2
. Each thread is given a cloned reference to ourService
instance (service1
andservice2
). This cloning is done usingArc::clone
, which increments the reference count rather than copying the actual data. -
Interacting with Shared Data: Inside each thread, we call either
increment_counts_thread1
orincrement_counts_thread2
. These methods modify the shared data in a controlled manner, thanks to ourRwLock
implementation in theService
struct. -
Expected Outcomes: As each thread performs its operations, we expect the shared counter (
count
) to be incremented by both threads, whereascount_thread1
andcount_thread2
are exclusive to their respective threads. The threads also print out the current state of these counters after each increment.
In summary, Arc
in our main
function demonstrates Rust's powerful and safe approach to concurrency. By allowing multiple threads to share ownership of data, Arc
enables concurrent access while ensuring that the data lives as long as it's needed and no longer. This, combined with the thread-safe operations on our Service
struct, showcases a typical pattern for managing shared state in multi-threaded Rust applications.
Seeing It in Action: Output of the Program
After understanding the roles of Arc
, RwLock
, and our thread setup, let's see what happens when we actually run the program. Here's the output you can expect:
cargo run .
Compiling service_example v0.1.0 (/home/inigo/Documents/Tutorials/RUST/service_example)
Finished dev [unoptimized + debuginfo] target(s) in 0.37s
Running `target/debug/service_example .`
Thread 1: Iteration 0: Counts = (1, 1, 0)
Thread 2: Iteration 0: Counts = (2, 1, 1)
Thread 1: Iteration 1: Counts = (3, 2, 1)
Thread 2: Iteration 1: Counts = (4, 2, 2)
Thread 1: Iteration 2: Counts = (5, 3, 2)
Thread 2: Iteration 2: Counts = (6, 3, 3)
Thread 1: Iteration 3: Counts = (7, 4, 3)
Thread 2: Iteration 3: Counts = (8, 4, 4)
Thread 1: Iteration 4: Counts = (9, 5, 4)
Thread 2: Iteration 4: Counts = (10, 5, 5)
This output clearly shows how the counters are incremented by each thread. Notice how the shared counter (count
) increases with each operation, regardless of which thread is executing, while the individual counters (count_thread1
and count_thread2
) are incremented only by their respective threads.
Error Management in Rust: Embracing Verbosity for Clarity
When transitioning to Rust from a language like Go, one thing you might find familiar is the verbosity in error handling. Rust, much like Go, emphasizes explicit and clear error management, though the styles differ slightly. Let's delve into how this plays out in Rust using our example, and why embracing this verbosity can be beneficial.
Rust's Explicit Error Handling
- Explicit is Better than Implicit: Rust enforces an explicit approach to error handling. Unlike languages that use exceptions, Rust requires you to acknowledge and handle potential errors at every step.
-
Avoiding
.unwrap()
in Production: While.unwrap()
is convenient for quick tests or examples, it's risky in production code because it causes the program to panic in case of an error. Rust encourages handling errors gracefully to avoid unexpected crashes.
Verbosity in Rust vs. Go
- Clear and Predictable Code: Rust’s verbose error handling, akin to Go's, ensures that your code is clear about how it deals with various failure scenarios. This explicitness leads to more predictable and maintainable code.
- Our Code's Error Handling Approach:
match service1.increment_counts_thread1() {
Ok(counts) => println!("Thread 1: Iteration {}: Counts = {:?}", i, counts),
Err(e) => {
eprintln!("Thread 1: Iteration {}: Error = {}", i, e);
break;
}
}
-
The Use of
match
: We usematch
to handle theResult
type returned by our functions. This way, we explicitly define the flow for both successful and error outcomes.
Balancing Verbosity and Practicality
-
Context Matters: There are times when using
.unwrap()
might be okay, such as in prototype code or when an error is impossible by logic. However, as a general rule, explicit error handling is preferred. - Familiarity Over Time: If you're used to Go's error handling, Rust's approach will feel familiar, though it may take some time to adapt to the nuances. Eventually, you'll likely appreciate the robustness it brings to your programs.
In summary, Rust's approach to error handling, while verbose, shares similarities with Go's explicit style. This verbosity is a small price to pay for the clarity and safety it brings to your code. As you grow more accustomed to Rust's patterns, you'll find that this explicitness becomes an invaluable tool in writing reliable, bug-resistant applications.
Conclusions: Navigating the Learning Path in Rust
As we come to the end of this post, it's clear that diving into Rust's world, especially its approach to concurrency, is both challenging and rewarding. I'm by no means an expert in Rust — just someone who's starting small and grappling with concepts that initially seemed daunting.
Embracing Rust's Concurrency Model
-
Starting Small: My journey in Rust began with tackling small, challenging concepts. This exploration into
Arc
,RwLock
, and threading is a part of that journey, an attempt to understand the depths of Rust's concurrency. - Safety First: One of the most significant learnings from this exercise is the importance Rust places on safety, particularly in concurrent environments. The language's design nudges you towards patterns that prevent common errors like data races.
The Beginner's Experience
- From Confusion to Clarity: As a beginner, the complexity of ownership, borrowing, and concurrency in Rust can be quite overwhelming. But, as with any new skill, the confusion gradually gives way to clarity with practice and patience.
- Community Support: One thing that stands out in my learning process is the Rust community's support. Whether it's through forums, documentation, or open-source contributions, there's always help available.
A Small Step in a Larger Journey
- Continual Learning: This example, which I found challenging initially, represents just a small step in the broader journey of mastering Rust. It’s a testament to starting with what seems tough and breaking it down into manageable pieces.
- Encouragement for Fellow Learners: To those who are also on their beginning stages with Rust, keep at it. The initial hurdles are part of the process, and every challenge overcome is a stride towards becoming a proficient Rustacean.
Explore the Code
If you're curious to see how the theory translates into code, or if you want to try your hand at modifying and playing with it, check out the repository on GitHub. It's a space for learning, experimenting, and sharing insights.
Wrapping Up
In summary, my journey with Rust is still in its early stages, filled with learning and discovery. This example is a reflection of that — a small piece of a much larger puzzle. As I continue to learn, I look forward to uncovering more such pieces, understanding them, and fitting them together in the beautiful tapestry that is Rust programming.
Posted on November 17, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.