DEV Community

Iñigo Etxaniz
Iñigo Etxaniz

Posted on

Rust Concurrency Explained: A Beginner's Guide to Arc and Mutex

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 other thread2.
  • What They Do: Each time they get to work (thread1 and thread2 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 with count (the shared one) and count_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()
    }
}
Enter fullscreen mode Exit fullscreen mode

Breaking Down the Code

  • Counters for Each Thread: We've got three counters here - count, count_thread1, and count_thread2. The first one is shared between both threads, while the other two are individual to each thread. Each counter is wrapped in a Mutex. Why? Because Mutex 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 or 2) to write_lock.

  • Lock Order Consistency: Notice something about how we lock our counters? We always lock count first, then count_thread1 or count_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 of RwLock. The reason is twofold: firstly, to demonstrate the capabilities of RwLock 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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 shared Service instance.

Threads in Action

  • Creating Threads: We spawn two threads, thread1 and thread2. Each thread is given a cloned reference to our Service instance (service1 and service2). This cloning is done using Arc::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 or increment_counts_thread2. These methods modify the shared data in a controlled manner, thanks to our RwLock implementation in the Service struct.
  • Expected Outcomes: As each thread performs its operations, we expect the shared counter (count) to be incremented by both threads, whereas count_thread1 and count_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)
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • The Use of match: We use match to handle the Result 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.

GitHub Repository Link

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.

Top comments (6)

Collapse
 
isaacdlyman profile image
Isaac Lyman

This is an excellent article. Thank you.

One question: If RwLock is better than Mutex even in a situation like this where you aren’t doing concurrent reads, when would you use Mutex?

Collapse
 
ietxaniz profile image
Iñigo Etxaniz

You are absolutely right. In this concrete scenario Mutex would be better as we are not doing any concurrent reads. The thing is that I consider this simple example as the building block of more general or complex use cases and by default I usually use read-write locks without thinking too much on it.

Collapse
 
isaacdlyman profile image
Isaac Lyman

Are there any advantages to Mutex, then? Does it use less memory or make deadlocks easier to catch?

Thread Thread
 
ietxaniz profile image
Iñigo Etxaniz • Edited

Don't know about that, but I guess it will be more efficient as it has less things to check... The thing is that only one thread will have access to data when using mutex while it is locked, whilst using RwLock many reads can be done simultaneously. So RwLock has to check if it is write or read lock and what kind of current lock exists.

Collapse
 
judevector profile image
Jude Ndubuisi 🥷🧑‍💻

As someone who just started his Rust learning journey, I love this article. This will help me understand this awesome language alot, am already loving it 😊

Collapse
 
ietxaniz profile image
Iñigo Etxaniz

Hi Jude,

Thanks for your kind words! It's great to hear you're finding the article helpful as we both navigate the exciting journey of learning Rust. Rust can be quite unique at first, but it's a rewarding experience. Let's keep learning together!