A Guide to Flexible & Easy Thread-Safe Rust: Unveiling the Multiton Pattern for Efficient Lazy Initialization: Difference between revisions
Created page with "500px The Singleton may be one of the most familiar design patterns. However sometimes it is necessary not have just one object of a certain type in your program, but several. That is when the Multiton comes in. The Multiton simply looks like this: 800px The Multiton basically manages a number of instances of an object, usually stored in a dictonary or a hashmap which can be retrieved using a key, usually..." |
No edit summary |
||
| Line 5: | Line 5: | ||
The Multiton simply looks like this: | The Multiton simply looks like this: | ||
[[file:Multiton.jpg| | [[file:Multiton.jpg|600px]] | ||
The Multiton basically manages a number of instances of an object, usually stored in a dictonary or a hashmap which can be retrieved using a key, usually a string key. The objects do not change, and are only initialized once in the dictionary. | The Multiton basically manages a number of instances of an object, usually stored in a dictonary or a hashmap which can be retrieved using a key, usually a string key. The objects do not change, and are only initialized once in the dictionary. | ||
Latest revision as of 19:26, 23 November 2025
The Singleton may be one of the most familiar design patterns. However sometimes it is necessary not have just one object of a certain type in your program, but several. That is when the Multiton comes in.
The Multiton simply looks like this:
The Multiton basically manages a number of instances of an object, usually stored in a dictonary or a hashmap which can be retrieved using a key, usually a string key. The objects do not change, and are only initialized once in the dictionary. In our example if an object with a non-existing key is requested, it is constructed. However, you could prefill the multiton with the objects, and just let the clients request them. Sounds cryptic? Let’s have a look at an example.
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. In the example app we will manage printers. Imagine an office where each department has their own printer. This means each printer is their own instance, but there is a fixed number of printers as there is a fixed number of departments
Let’s start by importing the necessary types:
use std::collections::HashMap;
use std::sync::{Arc,Mutex,OnceLock,RwLock};
use std::thread;
use std::time::Duration;
use std::fmt;
Line by line:
- Each printer instance will be identified by a unique key, that is why we need the HashMap.
- The std::sync::Arc, which stands for Atomically Reference Counted is a smart pointer. It allows for multiple threads to safely share ownership of the same resource.
- std::sync::Mutex stands for Mutual Exclusion. This provides safe access to a resource by ensuring only one thread at a time can access the resource.
- Because there will only be one PrinterManager, our actual multiton, we need to make sure it is only initialized once, even when accessed from multiple threads at a time. For that we use std::sync::OnceLock which provides exactly this functionality.
- To test our multiton, we will be spawning separate threads, hence the inclusion of the std::thread module.
- To simulate slow printing, threads will sleep for a certain amount of time, so include the std::time::Duration type.
- Errors and statuses will need to be formatted, so the std::fmt module is needed.
Defining the printer status Each printer has, apart from its name, a status, as it can be idle, printing or in an error state (for example a paper jam). This is how we implement the printer status:
#[derive(Clone, Debug)]
enum PrinterStatus {
Idle,
Printing(String),
}
impl std::fmt::Display for PrinterStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PrinterStatus::Idle => write!(f, "Idle"),
PrinterStatus::Printing(doc) => write!(f, "Printing: {}", doc),
}
}
}
Some notes:
- Note that the Printing enum value has an associated value, which is the name of the document being printed.
- We implement the std::fmt::Display trait so the status can be printed in a user friendly manner.
Defining the printer A printer in our simplified setup only has a department to which it belongs and its current status:
#[derive(Debug)]
struct Printer {
department: String,
status: PrinterStatus
}
The implementation is quite straightforward as well:
impl Printer {
fn new(department: &str) -> Self {
Printer {
department: department.to_string(),
status: PrinterStatus::Idle,
}
}
fn print(&mut self, document: &str) {
self.status = PrinterStatus::Printing(document.to_string());
println!("{} is printing: {}", self.department, document);
thread::sleep(Duration::from_secs(2));
self.status = PrinterStatus::Idle;
println!("{} finished printing: {}", self.department, document);
}
}
Some notes:
- The printer initial state is PrinterStatus::Idle
- The print() method simulates printing by sleeping for two seconds.
Implementing printer management With all the preliminary work out of the way, we can finally start on the multiton itself. We start with the PrinterManager struct itself:
struct PrinterManager {
instances: RwLock<HashMap<String, Arc<Mutex<Printer>>>>,
}
This struct basically contains HashMap of Printer objects. The HashMap is wrapped in a RwLock. This works like a mutex but with an important difference, since there are two kinds of locks. The first is a read lock, which means the wrapped resource can only be read and not mutated. This is a non-exclusive. The write lock when obtained is an exclusive lock. For the implementation we will start with the constructor which is quite straightforward:
fn new() -> Self {
PrinterManager {
instances: RwLock::new(HashMap::new()),
}
}
The constructor initializes the read write lock, with a new hashmap. The get_instance() method is a bit more involved. This method does two things:
- If the printer for the department already exists it is returned.
- If not, a write lock is obtained on the instances, and a new printer is added. Also, the new printer is returned.
fn get_instance(&self, department: &str) -> Result<Arc<Mutex<Printer>>, String> {
{
let instances_guard = self.instances.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(printer_arc) = instances_guard.get(department) {
println!("Retrieving existing Printer for department: {}", department);
return Ok(Arc::clone(printer_arc));
}
}
let mut instances_guard = self.instances.write()
.map_err(|_| "Failed to acquire write lock")?;
if let Some(printer_arc) = instances_guard.get(department) {
return Ok(Arc::clone(printer_arc));
}
println!("Creating new Printer for department: {}", department);
let new_printer = Printer::new(department);
let printer_arc = Arc::new(Mutex::new(new_printer));
instances_guard.insert(department.to_string(), Arc::clone(&printer_arc));
Ok(printer_arc)
}
Note that after obtaining the write lock, we test one more time whether the printer exists, since it could have been inserted by another thread. When no printer is found, it is created, added to the instances map, and returned, wrapped in an Arc and a Mutex for safe access and sharing. The complete implementation of the PrinterManager looks like this:
impl PrinterManager {
fn new() -> Self {
PrinterManager {
instances: RwLock::new(HashMap::new()),
}
}
fn get_instance(&self, department: &str) -> Result<Arc<Mutex<Printer>>, String> {
{
let instances_guard = self.instances.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(printer_arc) = instances_guard.get(department) {
println!("Retrieving existing Printer for department: {}", department);
return Ok(Arc::clone(printer_arc));
}
}
let mut instances_guard = self.instances.write()
.map_err(|_| "Failed to acquire write lock")?;
if let Some(printer_arc) = instances_guard.get(department) {
return Ok(Arc::clone(printer_arc));
}
println!("Creating new Printer for department: {}", department);
let new_printer = Printer::new(department);
let printer_arc = Arc::new(Mutex::new(new_printer));
instances_guard.insert(department.to_string(), Arc::clone(&printer_arc));
Ok(printer_arc)
}
}
All we need to make sure now, is that there is only one instance of the PrinterManager active at any one time. We do this in the following way:
static PRINTER_MANAGER: OnceLock<Arc<PrinterManager>> = OnceLock::new();
fn get_printer_manager() -> Arc<PrinterManager> {
Arc::clone(PRINTER_MANAGER.get_or_init(|| {
Arc::new(PrinterManager::new())
}))
}
Line by line:
- Despite the fact that this article is about a multiton, the PrinterManager itself is implemented as a thread-safe singleton. As mentioned before, the OnceLock type makes sure that the PRINTER_MANAGER static variable is only initialized once, regardless of how many threads attempt to access it simultaneously. The static variable acts as a global access point which as mentioned before will only be initialized once.
- The get_printer_manager() function is the public interface for accessing the singleton instance. It uses the get_or_init() method which atomically checks if the variable has been initialized, and if not not initializes it using the provided closure.
Testing
The tests will be quite extensive. In the tests we will create instances of some printers for the different departments. Some will be created from the main thread, others will be created in separate threads. What is also tested is that the printers for a certain department are the same instance. The code is quite self-explanatory:
fn main() {
let manager = get_printer_manager();
println!("\n--- Requesting Printers for 'HR' department ---");
let hr_printer_1 = match manager.get_instance("HR") {
Ok(printer) => printer,
Err(e) => {
eprintln!("Error getting HR printer 1: {}", e);
return;
}
};
let hr_printer_2 = match manager.get_instance("HR") {
Ok(printer) => printer,
Err(e) => {
eprintln!("Error getting HR printer 2: {}", e);
return;
}
};
let hr_printer_1_id = match hr_printer_1.lock() {
Ok(printer) => printer.department.clone(),
Err(e) => {
eprintln!("Error locking HR printer 1: {}", e);
return;
}
};
let hr_printer_2_id = match hr_printer_2.lock() {
Ok(printer) => printer.department.clone(),
Err(e) => {
eprintln!("Error locking HR printer 2: {}", e);
return;
}
};
println!("HR Printer 1 ID: {}", hr_printer_1_id);
println!("HR Printer 2 ID: {}", hr_printer_2_id);
assert!(Arc::ptr_eq(&hr_printer_1, &hr_printer_2));
println!("Are HR Printer 1 and HR Printer 2 the same instance? {}", Arc::ptr_eq(&hr_printer_1, &hr_printer_2));
match hr_printer_1.lock() {
Ok(mut printer) => printer.print("Employee Handbook"),
Err(e) => eprintln!("Error locking HR printer for printing: {}", e),
}
println!("\n--- Requesting Printers for 'Finance' and 'IT' departments ---");
let finance_printer = match manager.get_instance("Finance") {
Ok(printer) => printer,
Err(e) => {
eprintln!("Error getting Finance printer: {}", e);
return;
}
};
let it_printer = match manager.get_instance("IT") {
Ok(printer) => printer,
Err(e) => {
eprintln!("Error getting IT printer: {}", e);
return;
}
};
let finance_printer_id = match finance_printer.lock() {
Ok(printer) => printer.department.clone(),
Err(e) => {
eprintln!("Error locking Finance printer: {}", e);
return;
}
};
let it_printer_id = match it_printer.lock() {
Ok(printer) => printer.department.clone(),
Err(e) => {
eprintln!("Error locking IT printer: {}", e);
return;
}
};
println!("Finance Printer ID: {}", finance_printer_id);
println!("IT Printer ID: {}", it_printer_id);
assert!(!Arc::ptr_eq(&finance_printer, &it_printer));
println!("Are Finance Printer and IT Printer the same instance? {}", Arc::ptr_eq(&finance_printer, &it_printer));
match finance_printer.lock() {
Ok(mut printer) => printer.print("Quarterly Report"),
Err(e) => eprintln!("Error locking Finance printer for printing: {}", e),
}
match it_printer.lock() {
Ok(mut printer) => printer.print("Network Configuration"),
Err(e) => eprintln!("Error locking IT printer for printing: {}", e),
}
println!("\n--- Demonstrating Thread Safety ---");
let manager_clone_1 = Arc::clone(&manager);
let manager_clone_2 = Arc::clone(&manager);
let handle1 = thread::spawn(move || {
if let Ok(sales_printer) = manager_clone_1.get_instance("Sales") {
if let Ok(mut printer) = sales_printer.lock() {
printer.print("Sales Forecast Q3");
} else {
eprintln!("Error locking Sales printer in thread 1");
}
thread::sleep(Duration::from_millis(50)); // Simulate work
if let Ok(mut printer) = sales_printer.lock() {
printer.print("Sales Report Q2");
} else {
eprintln!("Error locking Sales printer for second print in thread 1");
}
} else {
eprintln!("Error getting Sales printer in thread 1");
}
});
let handle2 = thread::spawn(move || {
if let Ok(marketing_printer) = manager_clone_2.get_instance("Marketing") {
if let Ok(mut printer) = marketing_printer.lock() {
printer.print("Marketing Campaign Plan");
} else {
eprintln!("Error locking Marketing printer in thread 2");
}
thread::sleep(Duration::from_millis(50));
if let Ok(sales_printer_again) = manager_clone_2.get_instance("Sales") {
if let Ok(mut printer) = sales_printer_again.lock() {
printer.print("Sales Leads List");
} else {
eprintln!("Error locking Sales printer again in thread 2");
}
} else {
eprintln!("Error getting Sales printer again in thread 2");
}
} else {
eprintln!("Error getting Marketing printer in thread 2");
}
});
if let Err(e) = handle1.join() {
eprintln!("Error joining thread 1: {:?}", e);
}
if let Err(e) = handle2.join() {
eprintln!("Error joining thread 2: {:?}", e);
}
println!("\n--- Final check of instances ---");
match manager.instances.read() {
Ok(final_instances) => {
println!("Total unique printer instances managed: {}", final_instances.len());
for (key, printer_arc) in final_instances.iter() {
match printer_arc.lock() {
Ok(printer) => println!(" Department: {}, Status: {}", key, printer.status),
Err(e) => eprintln!(" Department: {}, Error accessing printer: {}", key, e),
}
}
}
Err(e) => eprintln!("Error accessing final instances: {}", e),
}
}
Conclusion
The multiton pattern is a good solution if the number of resources you have is limited, like in our example with the printers, or must be limited, since it is also useful with things like database connection pooling.
Rust makes it relatively easy to implement thread-safe solutions. Concepts like Arc and Mutex are well-documented and straightforward to use, and the OnceLock makes building thread-safe singletons relatively easy.
Read the full article here: https://medium.com/rustaceans/a-guide-to-flexible-easy-thread-safe-rust-unveiling-the-multiton-pattern-for-efficient-lazy-72bb2049cd9d
