How to check Send at runtime, similar to how RefCell checks borrowing at runtime?

is there a good way to use a dyn Send API with non-Send data? thread locals are great but they're unfortunately not perfectly suitable for everything that needs this. so how else can one do this?

No, because if you could do that, then Rust's type system would be useless.

1 Like

You can already do it with thread locals LocalKey in std::thread - Rust

It's not breaking the type system, just moving things to runtime. It is to Send as RefCell is to & and &mut.

1 Like

One should not need to spawn a thread to check Send-ness at runtime.

Depends on what's this "dyn Send API" you're talking about. It might be (probably is?) impossible from what we currently know.

LocalKey doesn't work by sending non-Send Ts to other threads, it merely provides each thread with a different T.

LocalKey only lets you get the non-Send T in the same thread it's from.

It lets your closure/etc be Send and workarounds the Send requirement for various things, and your code fails (with a panic) if you did it wrong and accidentally actually Sended the thing.

1 Like

The documentation of LocalKey::try_with and LocalKey::with doesn't mention any of this. It says "This will lazily initialize the value if this thread has not referenced this key yet", so I take it as creating different Ts, one for each thread. The only mention to panics is if the initializer function panics, if it's used while the value is being dropped or after being dropped.

if you have a LocalKey<RefCell<Option<T>>> or w/e, the unwrap would panic. anyway arguably LocalKey really isn't the proper API for checking Send at runtime even if it does enable doing so in many cases.

1 Like

It depends. If it's not your own type, you cannot make it Send in another way. If you have a !Send type, the other solution is to wrap it in another type that is Send, that can be unwrapped on another thread and the unwrapping takes care of fixing things up to prevent UB or logic errors, that'd usually arise from sending the type to another thread.

For example if the type mutated a thread-local and it has to access it for other operations, then wrapping the type will save the thread-local state to the wrapper. Then the wrapper is sent to another thread and unwrapping it will copy the state over to the new thread-local.

struct RunSend<T> {
  thread_id: ThreadId,
  value: ManuallyDrop<T>,



You'll have to elaborate how your type is going to permit !Send-types to be sent across threads. It's unclear to me from the struct definition alone.

I'll dive a bit deeper into an example, for now. Let's analyze Rc and find a solution to make it Send.

Why is Rc !Send? The way Rc implements shared ownership is by runtime-counting the owners with an internal number. Rc::clone increments the counter and dropping the Rc decrements the counter. Once the counter reaches zero, the wrapped value is dropped. The problematic part is, that incrementing and decrementing the counter is done using non-atomic operations, which might cause a data race when done in parallel, which causes UB.

If we wanna prevent UB from happening, we'll have to block the use of Rc's methods, that mutate the ownership counter.

use std::mem::ManuallyDrop;
use std::ops::Deref;
use std::rc::Rc;

pub struct SendRc<T> {
    inner: ManuallyDrop<Rc<T>>,
    origin: std::thread::ThreadId,

impl<T> SendRc<T> {
    pub fn new(value: Rc<T>) -> Self {
        Self {
            inner: ManuallyDrop::new(value),
            origin: std::thread::current().id(),

    pub fn into_inner(mut this: Self) -> Rc<T> {
            this.origin == std::thread::current().id(),
            "SendRc must be unwrapped in the original thread"

        unsafe { ManuallyDrop::take(&mut this.inner) }

unsafe impl<T: Sync> Send for SendRc<T> {}

impl<T> Drop for SendRc<T> {
    fn drop(&mut self) {
        panic!("Dropping `SendRc` triggers a memory leak. You must unwrap the inner `Rc` on its original thread to prevent the memory leak from happening.");

impl<T> Deref for SendRc<T> {
    type Target = T;

    fn deref(&self) -> &<Self as Deref>::Target {

I think this should work. :thinking:

This is definitely a crate that could use some safety review... (and maybe some ManuallyDrop...)

I just reviewed the code. It's essentially doing what I did in my example, just more generic, but it's also weird, that the value is boxed and the box is turned into a raw pointer. I just used ManuallyDrop. take should've been called into_inner, instead, because it's taking self, not &mut self. The advantage of the generic wrapper over my specialized example wrapper is, that it's always Send (and Sync) whereas mine is only Send if T is Sync, because I allow dereferencing of T on another thread. It wouldn't be hard to change it to a generic wrapper, tho.

An interesting design decision of the generic wrapper is to implement Deref and DerefMut. I guess, it might come in handy, when you don't wanna destroy the wrapper on use, because you'll send the wrapper over the thread again, afterwards. Fetching the current thread id is a bit expensive, after all. Minimizing the fetches seems like a legitimate reason to me. Care should be taken to not call deref(_mut) several times, tho, otherwise you lose out on the optimization and it would've been better to destroy the wrapper and create a new one, instead.

Overall, it's a decent solution, but the internal boxing is suboptimal. If minimizing size is an explicit design goal, the thread id should've also been moved into the box, as well. There's also no need to turn the box into a raw pointer. Just wrap the Box with ManuallyDrop.


Yeah, the (unnecessary) Boxing is a bit surprising; if I had to guess, I'd say it's because the original design of that crate predates ManuallyDrop? I'd also have featured a .try_mut() yielder rather than the very implicit DerefMut, and would have allowed a transparent "unchecked" Deref impl by virtue of not providing that unconditional Sync: it seems weird to have Sync be provided by something that claims Send-ness :woman_shrugging:.


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.