October 31, 2022
This article is the 3rd in a series related to building a wireless thermostat in Rust, running on a Raspberry Pi – although this one has very little to do with the Raspberry Pi and is more relevant to any multi-threaded application. When creating my application, I implemented a simple and effective design for controlling multi-threaded access to shared memory. You can find the previous articles here: first article - Raspberry Pi Wireless Thermostat in Rust and second article - Cross Compiling Made Easy. I have a few code snippets below, but you can find the entire code repository on github.
When designing a multi-threaded application, one of the core considerations is how the different threads share data. There’s always something that the threads share – you’re spinning off threads to work on a shared problem, after all. If nothing else, configuration data or database connection pools have to be shared. There are two main ways of approaching this problem.
Both approaches work, and Rust has good support for both mechanisms in the standard library. To choose between them, consider the following.
I decided on a shared state approach for the application I am creating. This decision also implied that my worker threads would work by polling – checking on things periodically instead of receiving an outside message. Here is what led me to that conclusion.
A shared state made sense based on these requirements. My design enforces a low thread count, so contention was not an issue (a common problem with shared state approaches). I need to keep track of time, so some state data was always required. Implementing two different state data mechanisms in the same application would be added complexity. With each thread doing its own thing and getting/setting shared state independently, we isolate each thread from the others. This choice led to a simpler overall application design.
But, as I reviewed the Rust Book’s content on shared-state, the complexity of managing access from each thread seemed daunting. Plus, sprinkling thread-locking code in the application made it a mess – my nice single-purpose functions now had Mutex locking logic. Also, when accessing data through a Mutex’s lock dealing with Rust’s borrow checker across threads proved challenging.
To make this manageable, I used an approach I’ve used before on C++ projects – encapsulating the shared data into a separate class and pushing all Mutex logic into the class. Rust doesn’t have classes, but a couple of structs did the trick.
To start, I created a struct to hold all the shared state data in a single place. Then I could deal with a single reference to this struct instead of managing each data element independently.
pub struct SharedData {
continue_background_tasks: bool,
current_temp: f32,
thermostat_value: usize,
thermostat_on: bool,
thermostat_change_datetime: OffsetDateTime,
}
Then we define a struct that holds an Arc pointer – an Atomic Reference Count pointer to a Mutex that guards our shared data struct. We use this struct to control access to/from our shared data.
pub struct AccessSharedData {
pub sd: Arc<Mutex<SharedData>>,
}
We will make many copies of this pointer – they all point to our Mutex, which is how we gain access to our shared memory space. We do this by customizing the Clone() method for AccessSharedData – it looks like the following.
// Clone here makes a copy of the Arc pointer - not the entire class of data
// All clones point to the same internal data
impl Clone for AccessSharedData {
fn clone(&self) -> Self {
AccessSharedData {
sd: Arc::clone(&self.sd),
}
}
}
To use this, we first create an instance of our SharedData struct (the simple .new() method isn’t shown above but is straightforward).
let common_data = SharedData::new(
true,
configuration.initial_thermostat_value as f32 + 5.0,
configuration.initial_thermostat_value,
false,
OffsetDateTime::UNIX_EPOCH,
);
Then, we initialize an instance of AccessSharedData.
// The wrapper around our shared data that gives it safe access across threads
let sd = AccessSharedData {
sd: Arc::new(Mutex::new(common_data)),
};
We then give each thread we spawn() a cloned copy of the AccessSharedData struct. The clone call creates a copy of the Arc pointer, which we then move into the new thread. A similar method passes in a clone of the AccessSharedData struct to the actix_web HttpServer::new() method, so it is also available in HTTP client handlers.
// Create another clone of our pointer to shared data, and send it into a new thread that continuously
// checks to see how the current temperature and current thermostat setting compare - and will
// trigger turning on the relay for the furnace as needed.
let sdc = sd.clone();
let control_handle = spawn( async move {
tracing::debug ! ("kicking off control_thermostat");
match run_control_thermostat( & sdc, configuration.poll_interval).await {
Ok(_) => tracing::info ! ("control_thermostat ended"),
Err(e) => tracing::error ! ("control_thermostat returned an error {:?}", e),
}
});
Now that everyone has a copy of the Arc pointer, we need to create a means to use it to read/write our shared data struct. A simple set of getters/setters for each member of our struct handles the task of acquiring a lock, getting/setting the data, and automatically releasing the lock. By putting this logic surrounding the get/set and utilizing the end of the method as a scope boundary to force the lock to be released, we gain control over the access to our data.
impl AccessSharedData {
pub fn continue_background_tasks(&self) -> bool {
let lock = self.sd.lock().unwrap();
lock.continue_background_tasks
}
pub fn set_continue_background_tasks(&self, new_val: bool) {
let mut lock = self.sd.lock().unwrap();
lock.continue_background_tasks = new_val;
}
//repeated for remaining struct members
}
When each get/set function returns, the lock is released. This approach also means it is impossible to deadlock around access to the structure – everything is locked and released immediately and prohibits anything else from getting in the way. With all of our locking mechanisms in place, using the shared data in the rest of our application is trivial.
sd.set_current_temp(temp_f);
let temp_now = sd.current_temp();
let thermostat_now = sd.thermostat_value();
This strategy is encapsulation at its finest. Application code uses straightforward calls to methods to get/set the shared data elements whenever needed. Behind the scenes, we manage the Mutex’s locking for that access to be thread-safe and safe from deadlocks. And Rust’s borrow checker is extremely happy that we aren’t allowing direct shared mutable references to our shared data. If needed, you could easily extend this design pattern to implement a more robust access pattern.
Putting locking logic inside getters/setters enables our shared data struct to hide the complexities of multi-threaded access – much like the standard library does for thread-safe collections. Using clones of an Arc makes it possible to acquire a handle to the shared memory struct in each thread. These two concepts are the core ideas that make our Easy Shared Data design pattern work.