I’ve been having a bunch of fun in Rust recently. I’ve finally gotten past the point of fighting with the borrow checker and now am solidly in the plateau of productivity with the language.1 One of the first things that struck me about Rust was how confident I felt that if I wrote something sane-looking that passed the compiler, I was more likely than in other languages to have a program that actually worked. The borrow checker does a lot of the heavy lifting here, of course. But there are some other useful language conventions that help too.
One trick I’ve been reaching for is RAII Guards. Per the unofficial Rust Design Patterns book:
RAII stands for “Resource Acquisition is Initialisation” which is a terrible name. The essence of the pattern is that resource initialisation is done in the constructor of an object and finalisation in the destructor. This pattern is extended in Rust by using a RAII object as a guard of some resource and relying on the type system to ensure that access is always mediated by the guard object.
If you’ve used std::fs::File or std::sync::Mutex, you’ve already seen this
pattern! The idea is that we can use a few properties of the Rust compiler to
deterministically and safely handle resources. Those properties are: lifetime
tracking and deterministic destruction.
Take an example for std::fs::File. The File struct that you get back from
File::open has an implementation of Drop on it that automatically closes the
file descriptor that is opened by File::open:
// Pseudocode File implementation.
struct File {
handle: RawDescriptor, // The OS-level file descriptor
}
impl File {
fn open(path: String) -> File {
// ACQUISITION: Syscall to get the OS-level file descriptor.
let h = os::open_file_handle(path);
File { handle: h }
}
}
impl Drop for File {
fn drop(&mut self) {
// RELEASE: Called when the variable goes out of scope.
os::close_file_handle(self.handle);
}
}
// Usage Example
{
let my_file = File::open("data.txt");
my_file.write("Hello");
} // <--- my_file goes out of scope here; drop() is called deterministically.
The key thing here is that Rust calls destructors deterministically when a
variable goes out of scope. So, even though this looks a bit “magical”, there
isn’t any runtime ambiguity. Precisely when my_file goes out of scope, the
file descriptor is closed. This is in contrast to garbage-collected languages,
where you don’t have a strong guarantee of when object destructors are called.
We can use this deterministic destructor behavior to construct useful runtime
behavior. For example, the MutexGuard that Mutex::lock returns uses this
deterministic Drop call to unlock the Mutex:
// Pseudocode Mutex<T> implementation.
struct Mutex<T> {
data: UnsafeCell<T>,
is_locked: AtomicBool,
}
// The RAII Guard
struct MutexGuard<'a, T> {
mutex_ref: &'a Mutex<T>, // Holds a reference to the parent Mutex
}
impl<T> Mutex<T> {
fn lock(&self) -> MutexGuard<T> {
// ACQUISITION: Block until the lock is acquired
while self.is_locked.swap(true, Ordering::Acquire) { /* spin */ }
// Return the Guard that tracks this state
MutexGuard { mutex_ref: self }
}
}
impl<'a, T> Drop for MutexGuard<'a, T> {
fn drop(&mut self) {
// RELEASE: Reset the lock state on the parent Mutex
self.mutex_ref.is_locked.store(false, Ordering::Release);
}
}
// Usage Example
{
let guard = my_mutex.lock(); // Mutex is now locked
*guard += 1; // Access data safely
} // <--- guard goes out of scope; drop() runs, Mutex is now unlocked for others.
What I find powerful about this pattern is that you are using the type system of the language to ensure correctness. It’s like the idea of “making illegal states unrepresentable”. Using the above design, it’s surprisingly hard to accidentally grab a mutex lock and cause a deadlock by failing to later unlock it.
Newtype
RAII Guards pair well with the Newtype pattern to add yet more safety by utilizing the type system. The idea of the Newtype pattern is pretty simple: wrap an existing type in a thin wrapper struct to change its capabilities. I’ve written about this pattern before about Golang, where this is often done via Type Embedding.
This pairs nicely with RAII guards: often when you are using a guard, you want to lock down the resource to prevent unintentional misuse. For example, say you’re working with a database connection handle. The underlying type might just be a low-level handle representing an OS-level resource, and you want to prevent it from being accidentally duplicated (which could lead to connection reuse or aliasing bugs).
Here is a quite naive approach:
struct ConnectionPool {
connections: Mutex<Vec<u64>>, // Raw OS handles
}
impl ConnectionPool {
fn checkout(&self) -> u64 {
let mut conns = self.connections.lock().unwrap();
conns.pop().expect("no connections available")
}
fn checkin(&self, handle: u64) {
self.connections.lock().unwrap().push(handle);
}
}
The problem: since u64 has Copy, nothing stops you from doing this:
let handle = pool.checkout();
let oops_handle = handle;
pool.checkin(handle);
client.query(oops_handle, "SELECT * FROM users;"); // Whoops!
This is of course rather contrived, but it shows how if we’re relying on the end
programmer to remember to “return” resources that they’ve checked out, we are
open to a rich source of leakage and misuse. However, we can fix this by
wrapping the raw handle in a newtype that doesn’t implement Copy or Clone:
struct ConnectionHandle(u64); // Not Copy, not Clone
struct ConnectionPool {
connections: Mutex<Vec<u64>>,
}
impl ConnectionPool {
fn checkout(&self) -> ConnectionHandle {
let mut conns = self.connections.lock().unwrap();
ConnectionHandle(conns.pop().expect("no connections available"))
}
fn checkin(&self, handle: ConnectionHandle) {
self.connections.lock().unwrap().push(handle.0);
}
}
Now the compiler prevents the bug:
let handle = pool.checkout();
let oops_handle = handle; // This is a move, not a copy
pool.checkin(handle); // Error: handle was already moved to `oops_handle`
In this example, the newtype pattern forces single-ownership semantics onto a primitive that would otherwise be freely copyable. You also could go further and combine this with an RAII guard that automatically checks the connection back in when dropped.
The meta lesson here is that a feature-rich language like Rust has strong
benefits. Yes, you’ll sometimes need to decipher some rather verbose type
signatures like
Arc<RwLock<HashMap<TypeId, Box<dyn FnMut(&mut dyn Any) + Send>>>>, but in
exchange you get your friendly compiler to prevent you from footgunning yourself
in myriad ways.
In the learning pit of despair, Rust’s type system can feel like an ivory tower. I’d read one of FasterThanLime’s fantastic articles, but after I’d digested the great technical writing, I was left with a feeling of “OK, but now I need to return to my day job and write some enterprise software glue code”. Once at the plateau of productivity, the type/memory/structural safety you’ve been slowly absorbing more than pays for the initial discomfort.
-
The final boss, as it turns out, was allowing myself to use a healthy quantity of
Arc<T>s. ↩︎