Is an empty Vec<T> Send even when T is not Send?

The following snippet looks sound to me but I would really appreciate a review of this usage of unsafe:

pub struct EmptyVec<T>(Vec<T>);

impl<T> EmptyVec<T> {
    pub fn new(inner: Vec<T>) -> Self {
        assert!(inner.is_empty());
        Self(inner)
    }

    pub fn take(self) -> Vec<T> {
        self.0
    }
}

// SAFETY: we have asserted the Vec is empty
unsafe impl<T> Send for EmptyVec<T> {}

use std::rc::Rc; // not Send

pub fn main() {
    let container: Vec<Rc<u32>> = Vec::with_capacity(10);
    let send_me = EmptyVec::new(container);

    let handle = std::thread::spawn(move || {
        let inner = send_me.take();
        assert_eq!(inner.capacity(), 10);
    });

    handle.join().unwrap();
}

Context:

I'm building a pure rust implementation of the Web Audio API and one of the cornerstones of audio processing is to avoid allocations on the real-time audio render thread (because the allocator can have unpredictable timings).
A strategy to avoid allocations is to allocate up front in the control thread and ship the containers to the audio render thread. The audio render thread is single-threaded by design and uses Rc heavily in my design. Hence my question if it is safe to Send an empty Vec of non-Sendable types.

Thanks in advance!

2 Likes

Looks sound to me :slight_smile:

1 Like

An informal proof that this is sound is fairly simple:

You can effectively transmute[1] from Vec<T> to Vec<U> where it is valid to transmute from T to U by going through into_raw_parts and from_raw_parts.

Given your input T, let U be the same layout as T, but without any behavior, which is thus Send and Sync. In order to send Vec<T>, we first transmute our type to Vec<U>, send it using the proof that Vec<U>: Send, and then transmute back to Vec<T>.

The important parts of the proof are:

  • No T is sent, so the send/sync of T needn't come in to play;
  • Vec<U> is Send/Sync, thus Vec<_> itself is Send/Sync; and
  • The documentation guarantees that Vec<_> is no more than a pointer/length/capacity triple, so there's no magic specialization going on for !Send types that would make the non-T part !Send[2].

TL;DR this is true for Vec but not necessarily other types that do more interesting things and/or make fewer guarantees about their implementation[3].


  1. An actual transmute is not valid; you must go through the into/from_raw dance. This is because the layout of a #[repr(C)] is unspecified, and may vary even between two instantiations of the same generic type with different arguments with the guaranteed same layout. ↩︎

  2. This is a tricky bit. I think actually using specialization for this is unsound, but it's possible to use associated types to change the layout of a generic type beyond just holding T or U, which could further impact the Send-ness of the type. ↩︎

  3. Silly example: imagine that there is an allocation API that gives out memory into thread-local memory, and accessing said memory from a different thread is unsound. It would be theoretically possible to expose a Vec-like API which, depending on whether the held type is Sync or not, decided to use the global heap or the thread local heap. Such a type would not be valid to send between threads, even when empty.

    We know this cannot be the case for Vec, though, because we can into_raw/from_raw to change the contained type with no restriction (beyond its Layout) while reusing the same allocation. ↩︎

8 Likes

The only thing I'd add is you should put the struct behind a privacy barrier (a module) so the field is not manipulatable.

6 Likes

This is sound, albeit if you don't want to depend on Vec being implemented in a sane way, you can implement your own empty vector that doesn't base on standard library vector type.

use std::mem::{self, ManuallyDrop};
use std::ptr::NonNull;

pub struct EmptyVec<T> {
    ptr: NonNull<T>,
    capacity: usize,
}

impl<T> EmptyVec<T> {
    pub fn new(inner: Vec<T>) -> Self {
        assert!(inner.is_empty());
        let mut inner = ManuallyDrop::new(inner);
        Self {
            // Compiler knows that this cannot be a null pointer, so it optimizes
            // this expect out.
            ptr: NonNull::new(inner.as_mut_ptr()).expect("a non-null pointer"),
            capacity: inner.capacity(),
        }
    }

    pub fn take(self) -> Vec<T> {
        // SAFETY: ptr was originally allocated via Vec<T>, T has the same size and
        // alignment as T due to being the same type, length is guaranteed to be less
        // than or equal to capacity as 0 is the smallest possible usize, capacity
        // comes from original vector. This transfers the ownership of a vector,
        // so we need to ensure that nothing else uses this pointer, which we do
        // by using ManuallyDrop::new to prevent Vec destructor from being
        // called in EmptyVec::new and calling mem::forget in this function.
        let v = unsafe { Vec::from_raw_parts(self.ptr.as_ptr(), 0, self.capacity) };
        mem::forget(self);
        v
    }
}

impl<T> Drop for EmptyVec<T> {
    fn drop(&mut self) {
        // SAFETY: The same requirements as EmptyVec::take, except we don't need to
        // ensure that nothing else uses this pointer because are already in a destructor.
        unsafe {
            Vec::from_raw_parts(self.ptr.as_ptr(), 0, self.capacity);
        }
    }
}

// SAFETY: The pointer doesn't actually store values of type T.
unsafe impl<T> Send for EmptyVec<T> {}

I don’t see how this would depend on Vec being implemented in a sane way significantly less than the original version. I suppose, perhaps, you rely less on is_empty being correct because you pass in the 0 size upon reconstruction? But either way it doesn’t matter much because relying on Vec being implemented in a sane way is okay. The main advantage in your code is probably that you save the need to store the size information in the EmptyVec, making the struct smaller.

Yeah, I should have clarified. My version doesn't depend on the fact that an empty Vec<T> can be safely sent (technically there is nothing in documentation saying that). Like, I don't see how Vec would violate that (weird specialization nonsense?), but still.

It does still rely on the fact that the heap data can be safely sent, however.

Silly example: imagine that there is an allocation API that gives out memory into thread-local memory, and accessing said memory from a different thread is unsound. It would be theoretically possible to expose a Vec-like API which, depending on whether the held type is Sync or not, decided to use the global heap or the thread local heap. Such a type would not be valid to send between threads, even when empty.

We know this cannot be the case for Vec, though, because we can into_raw/from_raw to change the contained type with no restriction (beyond its Layout) while reusing the same allocation. This means that the heap data is shared between the Send and !Send versions.

The "most" correct version would probably be one that turned Vec<T> into Box<[MaybeUninit<[u8; size_of<T>]>]> (plus mental alignment note) to send it, and then back into Vec<T> on the other side. There we're doing doing defined type punning only and sending a type for which std actually provides a Send guarantee.

But this is just academic, really; std does provide enough guarantees that an empty Vec is safe to send between threads.

3 Likes

But your version depend on the fact that an empty vector can be safely sent between threads using raw pointer and Vec::from_raw_parts(). I don't think it's a real difference. For example sending Rc<T> between threads using Rc::{into_raw,from_raw} doesn't make it more safe.

6 Likes

Well, this is a lot more information than I was expecting! You are all great.

Thanks for the details CAD97, very easy to understand the proof.

And definitely quinedot, this will be snugly encapsulated in a separate module.

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.