A Guide to Flexible & Easy Thread-Safe Rust: Unveiling the Multiton Pattern for Efficient Lazy Initialization

Photo by Chris McClave: https://www.pexels.com/photo/white-hammock-on-the-beach-104750/

Introduction

Sometimes creating an object can be costly, either because it uses a lot of computer resources or takes time to set up, especially if it relies on external things like web services or databases.

In such cases, it’s a smart idea to create the object only when it’s actually needed, and that’s where the lazy initialization pattern comes into play.

You might think it’s similar to a singleton pattern, but there’s a crucial difference. In a singleton, you have only one instance of an object throughout the application’s lifetime. With lazy initialization, you can create multiple instances of these expensive objects, each with its own state.

If you want to do that, you’d use a multiton. This pattern looks like a singleton at first glance, but instead of a single instance, you can have a limited number of instances of an object, and you can control and manage their creation using a map or dictionary.

Lazy initialization looks like this:

Lazy initialization consists of three main components:

  1. The Object: This is the client or the thing that wants the expensive object.
  2. The ObjectProxy: It’s responsible for creating the object or objects when needed.
  3. The ExpensiveObject: This is the costly object itself.

The Multiton simply looks like this:

Implementation in Rust

The basic implementation in Rust isn’t too difficult, but to make it thread-safe, we need to take some extra precautions.

Let’s start by importing the necessary classes:

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;

In our example code, we’ll create a garage for expensive cars, where “expensive” refers to the cost of constructing the objects.

First, we implement the ExpensiveCar struct:

#[derive(Clone)]
struct ExpensiveCar {
    color: String,
}

impl ExpensiveCar {
    fn new(color: String) -> ExpensiveCar {
        ExpensiveCar { color }
    }
}

These cars are uniquely identified by their color.

Next, we implement the ExpensiveCarGarage:

struct ExpensiveCarGarage {
    car_collection:Arc<Mutex<HashMap<String,ExpensiveCar>>>,
}

impl ExpensiveCarGarage {
    fn new() -> ExpensiveCarGarage {
        ExpensiveCarGarage {
            car_collection: Arc::new(Mutex::new(HashMap::new())),
        }
    }



    fn get_car(&self, color: &str) -> Option<ExpensiveCar> {
        let mut car_collection = self.car_collection.lock().unwrap();
        if car_collection.contains_key(color) {
            println!("Key exists");
            car_collection.get(color).cloned()
        } else {
            println!("Key does not exist");
            let car = ExpensiveCar::new(color.to_string());
            car_collection.insert(color.to_string(), car.clone());
            Some(car)
        }
    }
}

Notable details:

  • Normally, car_collection would be a simple HashMap, but for thread-safety, we use an Arc structure. To ensure only one thread accesses and modifies this variable at a time, we wrap it in a Mutex.
  • In the get_car() method, we clone our car_collection, which means we get a reference to the same location in memory. We then test whether the key exists. If it does, we return the corresponding car; otherwise, we create the car, add it to the car collection, and return it.

Testing

Now let’s test it:

fn main() {
    let garage=Arc::new(ExpensiveCarGarage::new());
    let mut handles = vec![];

    for _ in 0..10 {
        let garage = Arc::clone(&garage);
        let handle = thread::spawn(move || {
            let car = garage.get_car("red");
            println!("car color: {}", car.unwrap().color);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}

In summary:

  • We create an Arc instance for our garage.
  • We create a vector to hold our JoinHandle objects.
  • In the loop, we clone our garage to have a reference to the same memory location, and we create tasks to try to get a car and print the result. These tasks are then added to the vector.
  • In the last loop, we execute all our tasks and wait for them to finish.

Conclusion

Lazy initialization can be extremely useful, especially in scenarios like connection pooling where creating a connection is both time and resource-intensive. When combined with the multiton pattern, it offers a flexible solution to this problem.

Rust makes it relatively easy to implement thread-safe solutions. Concepts like Arc and Mutex are well-documented and straightforward to use.

Leave a Reply

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