Simplifying Concurrency: Easy Implementation of Double-Checked Locking Pattern in Rust

Photo by Eva Bronzini: https://www.pexels.com/photo/a-watertight-door-in-a-ship-6419610/

Introduction

Sometimes when locking data or objects it can be handy to reduce the overhead of acquiring a lock on such objects by first checking whether a lock is really necessary. This typically used in combination with lazy initialization in multi-threaded applications, sometimes as part of a Singleton pattern.

In Rust we can implement this pattern using the Mutex, Arc and Once structs.

Implementation in Rust

We will start by defining an ExpensiveCar struct:



use std::sync::{Arc, Mutex, Once};


#[derive(Debug,Clone)]
#[allow(dead_code)]
struct ExpensiveCar {
    name: String,
    price: u32,
}

impl ExpensiveCar {
    fn default() -> Self {
        ExpensiveCar {
            name: "default".to_string(),
            price: 100,
        }
    }
}

Because we need a parameterless function to produce our object, I defined a default() method which produces a default car. The #[allow(dead_code)] is used, to surpress warnings about name and price not being used.

Next we define our Lazy struct:

#[derive(Debug)]
pub struct Lazy<T> {
    once: Once,
    value: Mutex<Option<Arc<T>>>,
}

Some notes:

  1. The Once type ensures that the initialization is run at most once.
  2. We can use Mutex to make sure only one thread can access our value at one time. Since we start out with an unknown value, i.e. None, we wrap the value in an Option. To make sure this value can be shared amongst threads we use the Arc.

Next we implement the Lazy structure:

impl<T> Lazy<T> {
    // Constructor for the lazy value.
    pub fn new() -> Self {
        Lazy {
            once: Once::new(),
            value: Mutex::new(None),
        }
    }

    pub fn get_or_init<F>(&self, init: F) -> Arc<T>
        where
            F: FnOnce() -> T,
    {

        if let Some(value) = self.value.lock().unwrap().as_ref() {
            return value.clone();
        
        }


        self.once.call_once(|| {
            let mut value = self.value.lock().unwrap();
            *value = Some(Arc::new(init()));
        });


        self.value.lock().unwrap().as_ref().unwrap().clone()
    }
}

Again some notes:

  1. The new() method is the constructor. As you can see, the initial value in the Mutex is None.
  2. The get_or_init() provides us with access to the initialized value. The only argument it has is a function which returns a T. This function is called in case we have not initialized the value.
    • Next we check if the value has a value, if so we try to unwrap it, and return a clone. This is the fast path.
    • If no value has been initialized yet, we use the Once struct’s call_once() method, in which we try to unlock the value, and initialize it with the result of the supplied function, which in our example is parameterless. This is the slow path.
    • We now know that a value has been set, and we return by unwrapping it, and returning a clone.
    • As you can see there is a so-called ‘fast path’ and a ‘slow path’. Before we can enter either of them checks have been made, hence the name double-checked locking. Because of the distinction between the two paths, you can see that it can improve performance in some cases.

This was quite complicated code, let’s see it in action.

Testing time

Now we can write a small app using this pattern:

 fn main() {
     let locked_value=Lazy::new();
     {
         locked_value.get_or_init(|| ExpensiveCar::default());
         let result_value=locked_value.value.lock().unwrap();
         match result_value.as_ref() {
             Some(value) => println!("value is {:?}",value),
             None => println!("value is None"),
         }
     }
 }

What happens here:

  1. We initialize a Lazy struct with a None value.
  2. Next we call the get_or_init() function, and we initialize the value to default ExpensiveCar struct.
  3. Finally we lock the value mutex, and print its contents. Since we already initialized it, we get an instance of ExpensiveCar back.

Conclusion

Double checked locking was not the easiest pattern to implement, but once I got the concept of a fast and slow path, which in a way are quite similar in how you would implement a Singleton pattern, it became easier. Using the Mutex also makes the prevention of data-races easier, since only thread can access the resource at a time.

Leave a Reply

Your email address will not be published. Required fields are marked *