Description
It is well known that SeqLocks - the underlying algorithm of AtomicCell
- rely on data races which is technically always UB, though in practice it is assumed that a volatile read not ordered with a write is fine to perform, only that the read value must be discarded if it is determined after-the-fact that a writer was concurrently running with the volatile read.
However, crossbeam's SeqLock cannot reliably detect the presence of a writer running concurrently while a reader is in its critical section, this is due to the atomic load of the timestamp in validate_read()
being too weakly ordered/synchronised, and can miss a writer's modification to the timestamp.
The following test fails with a modified Miri. We need to stop Miri from eagerly throwing a data-race UB when the unordered volatile read or write happens (by simply changing the throw ub macro to println), since we need to keep it running. You can clone the modified Miri here: https://github.com/cbeuw/miri/tree/no-throw-on-data-race. Running ./miri install
will compile and install Miri and cargo-miri binaries under a new rustup toolchain called miri
MIRIFLAGS="-Zmiri-seed=1" cargo +miri miri test --lib -- atomic::seq_lock::tests::test_racy_read_discarded
use core::ptr;
use std::thread::{self, yield_now};
#[derive(Copy, Clone)]
struct EvilSend<T>(T);
unsafe impl<T> Send for EvilSend<T> {}
unsafe impl<T> Sync for EvilSend<T> {}
#[test]
fn test_racy_read_discarded() {
static LK: SeqLock = SeqLock::new();
let mut a = 0u32;
let b = ptr::addr_of_mut!(a);
let shared = EvilSend(b);
let writer = thread::spawn(move || {
// Same as AtomicCell::atomic_store()
let _guard = LK.write();
unsafe { ptr::write(shared.0, 1) };
});
{
// Same as AtomicCell::atomic_load()
if let Some(stamp) = LK.optimistic_read() {
if stamp != 0 {
// Writer has already run
return;
}
// We entered the critical section before the writer did, so
// *shared.0 == 0 (although we shouldn't be asserting it here since
// we could be technically using a value obtained from a data-racing read)
for _ in 0..100 {
// Give the writer some chance to ram in...
yield_now();
}
// If the writer did come into our critical section, this val will be 1
// but then validate_read(stamp) should fail because it'll see the new stamp
// written by the writer
let val = unsafe { ptr::read_volatile(&*shared.0) };
if LK.validate_read(stamp) {
if val == 1 {
panic!(
"The value was modified by a racing writer, and validate_read thought it was ok!"
);
} else {
// There was no concurrent writer while we are in the critical section
// the lock worked
}
} else {
// validate_read() detected a concurrent writer, the lock worked
}
}
}
writer.join().unwrap();
}
This is what happened
shared = 0 //Initial value of shared variable
┌───────state = 0 //Initial timestamp
│
│ Reader │ Writer
│ │
│ state.load(Acquire) in optimistic_read() │
│ │ // Now a writer starts running in our critial section
│ │
│ │ state.swap(1, Acquire) in write()
read-from│ │ fence(Release) in write()
│ │
│ │ *shared = 1
│ │
│ val = *shared // Read 1 which must be discarded │
│ │
│ │
│ fence(Acquire) // in validate_read(), does nothing!
└──────►state.load(Relaxed) │
│
// So stamp is still 0, the writer is completely │
// missed! │
As we can see, the crux of the issue is that while the volatile read on the shared variable could see the writer's modification, the atomic load on the timestamp may not. To ensure the correctness of validate_read()
, if the value of the shared variable read comes from a concurrent writer (hence a "real" data race happened), then state.load()
must read-from the modification to state
of a (not necessarily the same, if there were multiple writers that ran in the critical section) writer.
Proposed solutions
I could think of three ways to fix it, in two categories
Ensure validate_read()
always observes the latest timestamp
If the state.load()
in validate_read()
can somehow always see the globally latest timestamp (a total modification order exists for each memory location that has been atomically accessed), then it'll always catch a racing writer. There are two ways to do it
SeqCst
A SeqCst load cannot see anything earlier than the latest SeqCst store to the same location. If all stores and RMWs to state
in writers are SeqCst, then a state.load(SeqCst)
in validate_read()
will see the latest writer's modification. The fence can be removed. optimistic_read()
can still be relaxed though.
I do not like this solution, since we only care about reading the latest value on one location, but there is a Single Total Order of all SeqCst accesses on all locations - it's likely very damaging in performance, and also expresses the wrong intention in our code.
Edit: I was wrong. The fence between the volatile read and SeqCst timestamp read cannot be removed since the volatile read can actually be reordered (by the compiler or CPU) after the timestamp read: https://pdfs.semanticscholar.org/ac35/455b128baf4e280f2571160c242b67b3f85e.pdf Slide 19
RMW
The R in RMW is special - it always reads the latest value, regardless of the memory order argument. No other atomic access has this property (I believe there should be a load_latest()
though that's off-topic). Though we also have to buy-in to the Modify and Write. Thankfully state
is an integral type and fetch_add(0)
is idempotent. So we can replace state.load(Relaxed)
with state.fetch_add(0, Acquire)
. The fence can be removed as this acquires the releasing swap in write()
.
Create a synchronisation edge between the start of validate_read()
and the end of write()
I believe this was the original intention of the code, but was thwarted by volitile reads not helping in any synchronisation effects.
The fence(Release)
in write()
and fence(Acquire)
in validate_read()
can create a synchronisation edge, if an atomic write to a location is done after write()
, and an atomic read sees that atomic write's value before validate_read()
(fence-fence synchronisation). The memory orders of the accesses do not matter for the purpose of release fence -> acquire fence synchronisation.
We can in fact replace the ptr::write()
and ptr::volatile_read()
on the shared variable with byte-wise atomic writes and reads. Note that if there were multiple writers executing during a reader's critical section, then each byte-wise atomic read may read from a different writer. The resulting value is garbage and must be discarded - but validate_read()
can detect this as the acquring fence would create synchronisation edges with all writers from whom the byte-wise atomic read has read value. The timestamp read will note that certain writer executed and fail the check - it doesn't matter which specific writer that modified the timestamp.
This will also solve the issue of unordered ptr::write()
and ptr::volatile_read()
being technically UB. SeqLock is in fact the motivating example of the proposal P1478 for adding byte-wise atomic memcpy to C++.
We can actually go one step further and ditch the fences, as the proposal said
The explicit fence in the current seqlock idiom is confusing; this will usually eliminate it.
Indeed, the byte-wise atomic write and read on the shared variable can be used for synchronisation all by themselves. Again, if there were multiple writers executing during a reader's critical section, then each byte-wise atomic read may read from a different writer. But the timestamp load in validate_read()
happens-after the timestamp modification in at least one of the writers, so it'll fail the check.
cc @RalfJung who first pointed out this might be an issue