[idea] JoinSend: A Scheme for Accessing Rc Structures Across Multiple Threads Using Mutex

[idea] JoinSend: A Scheme for Accessing Rc Structures Across Multiple Threads Using Mutex

Currently, data containing Rc is neither Sync nor Send, making it nearly impossible to use them in a multithreaded context, even when synchronized with Mutex.

However, intuitively, as long as the data structure containing Rc is treated as a whole, it should be feasible to send this whole structure across multiple threads.

Therefore, if we only need to create a wrapper type and isolate operations on its internal data: by allowing only closures that are Send, along with their return values, to access the internal data, we can preserve the integrity of the internal data and enable its transfer across multiple threads.

JoinSend: Marking Types Capable of Such Operation

/// Types that can be transferred to another thread when thread has already finished.
/// Usually, there is only a close connection to the current thread itself,
/// such as on some platforms [`std::sync::MutexGuard`] is bound to the thread,
/// so it cannot be passed to another thread even if the current thread finish.
pub auto trait JoinSend {}

impl<'a, T: ?Sized> !JoinSend for std::sync::MutexGuard<'a, T> {}
impl<'a, T: ?Sized> !JoinSend for std::sync::MappedMutexGuard<'a, T> {}
impl<'a, T: ?Sized> !JoinSend for std::sync::RwLockReadGuard<'a, T> {}
impl<'a, T: ?Sized> !JoinSend for std::sync::MappedRwLockReadGuard<'a, T> {}
impl<'a, T: ?Sized> !JoinSend for std::sync::RwLockWriteGuard<'a, T> {}
impl<'a, T: ?Sized> !JoinSend for std::sync::MappedRwLockWriteGuard<'a, T> {}

JoinCell: Maintaining the Encapsulation of Data Structures

/// A wrapper type that performs operations on its data by passing closures to an imaginary thread.
#[repr(transparent)]
pub struct JoinCell<T: ?Sized> {
    value: T,
}

unsafe impl<T: JoinSend + ?Sized> Send for JoinCell<T> {}
unsafe impl<T: ?Sized> Sync for JoinCell<T> {}

impl<T> JoinCell<T> {
    /// # Safety:
    /// This value is the entire system, and no other data is synchronized with it.
    pub const unsafe fn new_unchecked(value: T) -> Self {
        Self { value }
    }

    /// We send this value to a virtual thread.
    pub const fn new(value: T) -> Self
    where
        T: Send,
    {
        Self { value }
    }

    /// Imagine we send the inner value to a virtual thread
    /// and then execute the closure on that thread
    pub fn map<F, R>(self, f: F) -> JoinCell<R>
    where
        F: FnOnce(T) -> R,
        F: Send,
    {
        JoinCell {
            value: f(self.value),
        }
    }

    pub fn new_by<F>(f: F) -> Self
    where
        F: FnOnce() -> T,
        F: Send,
    {
        JoinCell::new(f).map(|f| f())
    }

    pub fn into_inner(self) -> T {
        self.value
    }
}

impl<T: ?Sized> JoinCell<T> {
    /// We send the closure, execute it, and then send the result back.
    pub fn with<F, R>(&mut self, f: F) -> R
    where
        F: FnOnce(&mut T) -> R,
        F: Send,
        R: Send,
    {
        f(&mut self.value)
    }
}

Example

struct Foo {
    x: std::rc::Rc<i32>,
    y: Option<std::rc::Rc<i32>>,
}
let foo = std::sync::Mutex::new(JoinCell::new_by(|| Foo {
    x: std::rc::Rc::new(1),
    y: None,
}));
std::thread::scope(|s| {
    s.spawn(|| {
        let mut guard = foo.lock().unwrap();
        guard.with(|i| {
            i.y = Some(i.x.clone());
        });
    });
});
let mut guard = foo.lock().unwrap();
guard.with(|i| {
    assert_eq!(**i.y.as_ref().unwrap(), 1);
})

This seems to have the same issue as other "weaker Send" proposals, namely that thread_local allows to break the "containment".

For this reasons your safety comments are incorrect: if the closure was actually executed on that thread then the thread_local would access a different value, but that's not actually the case.

pub fn new_without_send_requirements<T: 'static>(value: T) -> JoinCell<T> {
    use std::any::Any;
    use std::cell::Cell;
    
    thread_local!(static TMP: Cell<Option<Box<dyn Any>>> = Cell::new(None));
    TMP.with(|tmp| tmp.set(Some(Box::new(value))));
    JoinCell::new_by(|| TMP.with(|tmp| *tmp.take().unwrap().downcast().unwrap()))
}

Moreover this is also unsound when combined with crates like send-cell and fragile, but in that case it's harder to argue about where the mistake is.

3 Likes

If considering only from the perspective of synchronization, libraries based on thread_id can work normally with this design. TLS (Thread Local Storage) indeed poses a potential issue where users might end up using it ineffectively, but this does not necessarily lead to robustness problems.

To take a step back, at least we can be certain that there are some types that fit this scenario. In that case, we only need to conservatively reject raw pointer implementations, just as we do with Sync.

impl<T: ?Sized> !JoinSend for *const T {}
impl<T: ?Sized> !JoinSend for *mut T {}

In this way, by requiring users to identify exactly which types can be handled in this manner, there is no need to worry about these concerns.

In fact, my previous code did not do this solely for the convenience of demonstration, and the initially designed API did not even require the JoinSend trait bound. I realized this later on.

TLS interacts unsoundly with this API, and I’m not aware of any good ways of fixing it. I’m not sure what exact claim you’re making with “does not necessarily lead to robustness problems”, because you don’t explain what you mean by “robustness problems” nor what scenario/conditions/measures you have in mind with the “not necessarily”.


I don’t see how “certain types” can fit the scenario – beyond types that are already Send – if the issue is completely independent of the contained type, but directly with the soundness of the JoinCell::map and JoinCell::new_by and JoinCell::with APIs themselves. Putting an F: Send bound on a closure – in a context where you aren’t actually sending the closure to another thread – is almost meaningless for any soundness-like properties: That’s because with TLS, closures can access[1] arbitrary !Send data that they don’t contain directly.

(normal thread-locals do at least further require some 'static lifetimes, however there are also popular crates that further lift such requirements, such as scoped-tls; or as an even further generalization scoped-tls-hkt. I believe with the latter, it would even be possible to wrap your API’s functions fully generically in a way that removes the F: Send restrictions.


  1. as long as the closure is called from the same thread where they were created and where the data was stashed into this side channel ↩︎

1 Like

... TLS (Thread Local Storage) and libraries based on thread_id can bypass the barriers constructed by Send, and the transmission of non-Send data via TLS can lead to incomplete data structures, thereby indeed causing soundness issues. :rofl:

It's really unfortunate.

1 Like