For a project, I'll need a thread-safe way of caching the result of a calculation, which can be reset/invalidated due to new elements being added. In which case, it needs to be recalculated (lazily) once the calculation is accessed again.
In pseudo-code, something along the lines of:
struct Validator<T, R> {
elements: Vec<T>,
cache: LazyCache<R>,
}
impl<T, R> Validator<T, R> {
fn add(&self, val: T) {
self.elements.push(val);
self.cache.invalidate();
}
fn long_calculation(&self) -> R {
if !self.cache.is_valid() {
let ret = do_calculation(&self.elements);
self.cache.store(ret);
}
self.cache.get()
}
}
Of course, a naive implementation of this wouldn't be thread safe, or if the cache
is naively guarded with a Mutex
, it wouldn't allow for many concurrent reads.
The main issue here is that I need exclusive access to the cache
during recalculations (it's mutated), but I'll need to check before every read, whether it's valid, and we don't want to run the calculations multiple times.
Therefore, inspired by double checked locking, I came up with the following RwLock
based implementation.
pub struct Context<T> {
lock: RwLock<Option<T>>,
}
impl<T> Context<T> where T: Copy {
pub fn new() -> Self {
Self {
lock: RwLock::new(None),
}
}
pub fn reset(&self) {
let mut writer = self.lock.write();
*writer = None;
}
pub fn validate<F>(&self, f: F) -> T
where
F: FnOnce() -> T,
{
let reader = self.lock.read(); // <-- (1)
if let Some(value) = &*reader {
*value
} else {
drop(reader); // <-- (2)
let mut writer = self.lock.write(); // <-- (3)
if writer.is_some() {
return *writer.as_ref().unwrap(); // <-- (4)
}
*writer = Some(f());
*writer.as_ref().unwrap()
}
}
The "pattern" of double checking can be seen after acquiring the write()
lock, because multiple threads could see a None
value when acquiring the read
lock (at (1)
), then drop it (at (2)
) and wait for the write
lock to be acquired (at (3)
). One of the (potentially) many threads will acquire it, update the string in the critical section, before dropping the lock again. At which point, another thread will grab the write lock, and sees that there is a value already in the cache and return it directly (at (4)
).
miri
believes this is sound, and my preliminary tests also confirmed that.
Is that true? Is there a better way to do this?
Fully working test case can be found in this playground.