Yet another SeqLock implementation

The "special" feature of this seqlock implementation, compared to the seqlock crate for example, is that it wraps a Mutex but it does not need the whole mutex-protected type to be Copy. Instead, the user passes a closure with higher-kinded generic arguments to return a Copy subset of the struct.

I think this is sound, but I'd like a second opinion.

// This version uses the unstable feature `mutex_data_ptr` for simplicity,
// but the same logic can be used in stable Rust through parking_lot.
#![feature(mutex_data_ptr)]

use std::cell::UnsafeCell;
use std::marker::PhantomPinned;
use std::mem::MaybeUninit;
use std::ops::Deref;
use std::sync::{Mutex, MutexGuard};
use std::sync::atomic::{Ordering, AtomicU32};

/// A datum that can be accessed from the outside world through
/// a seqlock.  Because of this, direct writes are not possible
/// even when holding a `&mut SeqLockData<T>`; they need to go
/// through a containing [`WithSeqLock`].
// PhantomPinned avoids retag on writes
pub struct SeqLockData<T: Copy>(UnsafeCell<MaybeUninit<T>>, PhantomPinned);
unsafe impl<T: Copy> Send for SeqLockData<T> {}
unsafe impl<T: Copy> Sync for SeqLockData<T> {}

impl<T: Copy> SeqLockData<T> {
    pub const fn new(data: T) -> Self {
        Self(UnsafeCell::new(MaybeUninit::new(data)), PhantomPinned)
    }
}

impl<T: Copy> From<T> for SeqLockData<T> {
    fn from(data: T) -> Self {
        Self::new(data)
    }
}

impl<T: Copy> Deref for SeqLockData<T> {
    type Target = T;
    fn deref(&self) -> &T {
        // SAFETY: the field is always initialized by `new()`.  Furthermore,
        // writes always go through the seqlock's `write()` function and
        // they only happen while a mutable reference to the `SeqLockData`
        // is alive, therefore a concurrent write is impossible.
        unsafe { (&*self.0.get()).assume_init_ref() }
    }
}

/// A wrapper for [`Mutex`], that allows accessing one or more fields
/// through a seqlock.
///
/// The safety of `WithSeqLock` hinges on two constraints.  First, writes
/// to a `SeqLockData` need to go through this struct which in turn
/// requires a `MutexGuard`, meaning that a `SeqLockData`
/// which is not wrapped by a `WithSeqLock` is unmodifiable, and there
/// would be no races if the closure returns it.  The only exception is
/// `static mut`, but that is unsafe.
///
/// Second, the higher-kinded type in the [`WithSeqLock::read()`] and
/// [`WithSeqLock::write()`] methods prevents extracting data (for
/// example via a guard) from a `SeqLockData` that is unrelated to
/// the input argument, because the returned value would have to live
/// for `'static`. Again, the only exception are `static`s, which are
/// either unmodifiable or unsafe as in the previous paragraph.
pub struct WithSeqLock<Outer> {
    count: AtomicU32,
    outer: Mutex<Outer>,
}

impl<Outer> WithSeqLock<Outer> {
    pub const fn new(data: Outer) -> Self {
        WithSeqLock {
            count: AtomicU32::new(0),
            outer: Mutex::new(data),
        }
    }

    pub fn read<T: Copy, F: for<'a> Fn(&'a Outer) -> &'a SeqLockData<T>>(&self, f: F) -> T {
        // SAFETY: the data is wrapped with MaybeUninit<>, and it is not
        // interpreted before ascertaining that it's consistent
        let outer: &Outer = unsafe { &*self.outer.data_ptr() };
        let inner = f(outer).0.get();
        loop {
            let ver = self.read_begin();
            // SAFETY: as above
            unsafe {
                let result = std::ptr::read_volatile(inner);
                if !self.read_retry(ver) {
                    break result.assume_init()
                }
            }
        }
    }
    
    pub fn write<T: Copy, F: for<'a> FnOnce(&'a mut Outer) -> &'a mut SeqLockData<T>>(&self, g: &mut MutexGuard<Outer>, value: T, f: F) {
        let inner = f(&mut **g).0.get();
        self.write_begin();
        // SAFETY: the data is never written concurrently, thanks to `g`
        unsafe { std::ptr::write_volatile(inner as *mut T, value); }
        self.write_end();
    }
    
    fn read_begin(&self) -> u32 {
        self.count.load(Ordering::Acquire) & !1
    }
    
    fn read_retry(&self, ver: u32) -> bool {
        std::sync::atomic::fence(Ordering::Acquire);
        // ordering provided by the preceding fence
        self.count.load(Ordering::Relaxed) != ver
    }
    
    fn write_begin(&self) {
        // ordering provided by the subsequent fence
        self.count.store(self.count.load(Ordering::Relaxed) + 1, Ordering::Relaxed);
        std::sync::atomic::fence(Ordering::Release);
    }
    
    fn write_end(&self) {
        self.count.store(self.count.load(Ordering::Relaxed) + 1, Ordering::Release);
    }
}

impl<Outer> Deref for WithSeqLock<Outer> {
    type Target = Mutex<Outer>;
    fn deref(&self) -> &Mutex<Outer> {
        &self.outer
    }
}


#[cfg(test)]
mod tests {
    use super::*;

    struct S {
        datum: SeqLockData<u32>,
    }

    #[test]
    fn test_seqlock() {
        let s = S { datum: 42.into() };
        let seqlock = WithSeqLock::new(s);

        // read outside lock
        assert_eq!(seqlock.read(|s| &s.datum), 42);

        {
            let mut g = seqlock.lock().unwrap();

            // read transparently under lock
            assert_eq!(*g.datum, 42);
            seqlock.write(&mut g, 123, |s| &mut s.datum);
        }

        // check that change took effect
        assert_eq!(seqlock.read(|s| &s.datum), 123);
    }

    #[test]
    fn test_seqlock_race() {
        let s = S { datum: 42.into() };
        let seqlock = WithSeqLock::new(s);

        let ver = seqlock.read_begin();
        assert!(!seqlock.read_retry(ver));
        seqlock.write_begin();
        assert!(seqlock.read_retry(ver));
        seqlock.write_end();
        assert!(seqlock.read_retry(ver));
    }

    #[test]
    fn test_seqlock_race2() {
        let s = S { datum: 42.into() };
        let seqlock = WithSeqLock::new(s);

        seqlock.write_begin();
        let ver = seqlock.read_begin();
        assert!(seqlock.read_retry(ver));
        seqlock.write_end();
        assert!(seqlock.read_retry(ver));
    }

    #[test]
    #[allow(static_mut_refs)]
    fn test_seqlock_wtf_but_sound() {
        static mut SOMETHING_ELSE: SeqLockData<u32> = SeqLockData::new(77);
        let s = S { datum: 42.into() };
        let seqlock = WithSeqLock::new(s);

        assert_eq!(seqlock.read(|_| unsafe { &SOMETHING_ELSE }), 77);
    }
}

You cannot test synchronization primitives with single-threaded code.

And to answer the question: no, as per Rust's memory model, this still causes data-races and is unsound (run with cargo miri test):

    use std::thread;

    #[test]
    fn test_parallel() {
        let seq_lock = WithSeqLock::new(SeqLockData::new(0));
        thread::scope(|s| {
            for _ in 0..1000 {
                s.spawn(|| {
                    for _ in 0..1000 {
                        let mut lock = seq_lock.lock().unwrap();
                        seq_lock.write(&mut lock, 88, |d| d);
                    }
                });
                s.spawn(|| {
                    for _ in 0..1000 {
                        seq_lock.read(|d| d);
                    }
                });
            }
        });
    }
1 Like

Right, I should have been more precise. Data races stemming from read_volatile are a known issue with the seqlock crate too:

    use seqlock::SeqLock;
    use std::thread;
    fn main() {
        let seq_lock = SeqLock::new(0);
        thread::scope(|s| {
            for _ in 0..1000 {
                s.spawn(|| {
                    for _ in 0..1000 {
                        let mut guard = seq_lock.lock_write();
                        *guard = 88;
                    }
                });
                s.spawn(|| {
                    for _ in 0..1000 {
                        seq_lock.read();
                    }
                });
            }
        });
    }

And for my code I assumed that Amanieu knew what he was doing, since using his crate would have the same issue.

My question is more about reading inconsistent data by passing incorrect closures to read and write, which can be tested with single-threaded code.

The status quo is that your code is not sound for the same reasons that Amanieu's code is not sound. Volatile might be likely to work in practice, but it's definitely not sound today.

Ignoring that, if I have two SeqLockData fields I can mem::swap them without having to go through the seqlock at all, so there are other problems too.

2 Likes

This is unsound, you need at least T: Sync here.

These are unsound especially because of the closures. They take respectively a shared reference and a mutable reference to the Outer and nothing is done to ensure they are not called concurrently. If that happens you create a &Outer and a &mut Outer at the same time for the same data, and that's UB.

In comparison seqlock never creates a reference to the inner data, it only ever writes or reads it.

4 Likes

Right, because of the issue with references. Other than that, it shouldn't be necessary because the value is copied.

Makes sense, for the same reason that Cell does not let you borrow the data. Back to the drawing board; I'll probably replace the closures with an unsafe trait.

Regarding this quote from Amanieu from the issue linked by @bonzini above (thanks for the link!), I'd appreciate to hear a second opinion:

It is extremely unlikely that LLVM would break existing code that relies on these semantics.

As far as I'm aware, the issue is not LLVM, but what can happen before lowering to LLVM.

LLVM explicitly allows race between ONE writer and readers, as long as the readers can detect the race and discard the value (which is exactly the use-case of SeqLocks).

Source from LLVM docs, last sentence in "Optimization outside atomic" section:

Note that speculative loads are allowed; a load which is part of a race returns undef, but does not have undefined behavior.

So, to me it looks like the issue is that SeqLocks are UB in Rust's memory model. Which means UB can happen (and semantics get mangled) before lowering to LLVM bytecode.

At least as written in Amanieu's crate they are; which is why Miri, which checks for all data race possibilities, fails. In the end I didn't worry about that because I knew I most likely screwed up the basics (narrator: he did).

If one wants to avoid those volatile reads the possibilities range from using only atomic types for the contents, to using a union with an array of AtomicU32 or AtomicUsize (which also doubles as MaybeUninit; in fact a perhaps bigger issue with seqlock::SeqLock is that it doesn't even use MaybeUninit so using it with pointers is even more sketchy), to using a procedural macro that takes field types and creates a compound load/store using atomic types.

vdrn, thanks for the very early review! I am happy that my bad attempt at least triggers some useful thoughts and discussions.

By the way, the easiest possible cop out for my code above should be to use raw pointers and make read/write unsafe. :person_shrugging:

1 Like

Isn't, really. It would be impossible to correctly concurrently work with lock's contents, so you might expose std::hint::unreachable_unchecked just as well)))

Are you referring to the usage of volatile or to something else? The volatile issue is fixable, it's the easy part.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.