Send + not Send variant of async trait object without duplication

Hello! This is my first post here. I am relatively new to rust but not exactly a novice, and I have been programming for decades in languages like C and C++ and, more recently, go. I did one non-trivial project in rust about two years ago and and back in rust for a while for a new project.

I have read several posts about async traits and Send including the blog post about the stabilization of async traits in rust 1.75 (I think). I've seen lots of discussions about issues similar to this, but I think my question is slightly different. If this is a duplicate, please accept my apologies -- I really did search around before posting.

My specific use case is that I have rust API that, among other things, communicates with a server using HTTP calls. There is trait object -- let's call it Communication -- that has a method that gets text from a URL with a GET call -- call it url_get. I have an implementation of the trait that uses the reqwest crate. Now I need to create two wrappers around this rust API: a C wrapper that runs single-threaded, and a Wasm wrapper that is async but uses fetch via web-sys and js-sys. Although the Wasm code is async, it is necessarily single-threaded, and the Wasm implementation of the url_get function awaits on non-Send futures and therefore returns a non-Send future itself. (For the C wrapper, I'm using a blocking client using block_on in a tokio runtime, and that works fine, so no further need to discuss. Yes, there's a lot of code duplication, but I don't think I'm doing it wrong.)

My "object" has dispatch functions that delegate certain operations to the communications trait. As it stands right now, I have to basically implement everything twice. I have one variant of Communications declared with #[async_trait(?Send)] (I believe that, since these are trait objects, I still can't use the stabilized async trait native capability...) and another with #[async_trait], and then I have two different dispatchers. The caller has to know which one to call to avoid having anything in the function calling chain contaminate the Send-ness of the futures.

Here's the problem. Let's say I have a method in my API that does something like get a value using HTTP, do something with the value, and return a Result<String, Box<dyn Error + Sync + Send>>. I basically have to implement it twice: once that dispatches to the Send version of the trait and once that dispatches to the local version. As a relative newcomer here, I spent several hours trying to figure out ways of encapsulating the non-Send part so I could force the non-Send parts to run on the current thread, similar to tokio::LocalSet, but inside an async function that actually runs in Web Assembly, which is already single-threaded. In the end, all my solutions were akin to putting a sheet of glass over the windshield to keep the rain off -- they just pushed the problem up or down a level. At least doing this has I believe solidified my understanding of a future being non-Send if a non-Send value is captured or crosses an await point.

I've created an example below (in the playground -- I hope that I have created this post correctly) that passes and has comments explaining the issue.

Here are my questions:

  • Is my analysis about what's going on here correct? As far as I can see, there's no way to cheat on this -- I am simply forced to have both Send and non-Send versions of the trait, and at this time, I have not found a way to make the trait-variant and async-trait crates play nicely together.
  • Have I overlooked a solution? Is there something perhaps Wasm specific that would allow me to somehow grab the values inside some kind of inner guaranteed single-threaded environment so that the non-Send-ness off JsFuture et al don't "contaminate" the Send-ness of my trait implementation?
  • Is there a standard crate that can help with the duplication? I believe I can create my own custom proc macro for this, though before I go down that path, I might see whether there are other reasons to deviate from this pattern...

Finally, I have a general rule about trouble-shooting these kinds of things. People have a habit of going down a path, hitting a wall, backing up one step, and formulating a question from where they were when they get stuck. Sometimes there is an answer, but often, the problem is that they took a wrong turn six steps back. So a more general question is whether I've simply gone the wrong way here. Am I unwittingly trying to use rust in a way that doesn't fit with rust? Is my idea of encapsulating the environment-specific concern to a trait object the right way to go here? If this were go code, I'd write an interface, and it would "just work" because I'm not actually simultaneously accessing non-thread-safe data across multiple threads, but that's why I'm in rust -- I want the safety guarantees. Anyway, I feel like I'm eventually going to bump into this issue whether I'm using a trait object or not. No matter what, a specific action in my API will necessarily have to await on something non-Send somewhere in the course of doing its work in Wasm, but I will also want to be able to take full advantage of async with tokio in the regular environment.

I'd be eager to get any insight, even if it's just, "Go read this -- someone has already solved this (or explained how it will be solved in the future or why it's fundamentally not solvable)" or "you're going about this all wrong and making a classic newbie mistake". (Even people who have been coding for 40 years like me can make newbie mistakes in new languages!)

use async_trait::async_trait;
use std::future::Future;
use std::marker::PhantomData;

// needs_send requires a future with Send. This is like tokio::spawn.
async fn needs_send<F, T>(future: F) -> T
where
    F: Future<Output = T> + Send + 'static,
    F::Output: Send + 'static,
{
    future.await
}

struct Answer {
    a: i32,
}

// NotSend isn't Send because it contains a raw pointer.
#[derive(Default)]
struct NotSend {
    _z: PhantomData<*const u8>,
}
impl NotSend {
    async fn f(&self) -> Answer {
        Answer { a: 1 }
    }
}

// SendDemo can be sent to needs_sync, but the implementation of f()
// can't await on any futures that are not Send.
#[async_trait]
trait SendDemo {
    async fn f(&self) -> Answer;
}
struct SendStruct {}
#[async_trait]
impl SendDemo for SendStruct {
    async fn f(&self) -> Answer {
        Answer { a: 0 }
    }
}

// LocalDemo can't be sent to needs_sync because its async functions
// return futures without Send. This makes it able to await on
// non-Send futures in its implementations. We need to duplicate
// SendDemo here because `trait_variant` and `async_trait` don't work
// together.
#[async_trait(? Send)]
trait LocalDemo {
    async fn f(&self) -> Answer;
}
struct LocalStruct {}
#[async_trait(? Send)]
impl LocalDemo for LocalStruct {
    async fn f(&self) -> Answer {
        // The fact that this function awaits on a non-Send future
        // prevents the Future it returns from being Send. If we don't
        // include `(?Send)` in `async_trait`, this won't compile.
        let x: NotSend = Default::default();
        x.f().await
    }
}

// Demo must contain either a SendDemo or a LocalDemo.
enum Demo {
    Send(Box<dyn SendDemo + Sync + Send>),
    Local(Box<dyn LocalDemo + Sync + Send>),
}

struct Thing {
    x: Demo,
}
impl Thing {
    // Problem: These two functions have almost identical code. We
    // want to support an API that may sometimes run in a
    // single-threaded environment, like wasm, that awaits on futures
    // that are not Send in its implementation, and we also want to
    // support the API in normal environments that may run multiple
    // copies concurrently. How can we do that without having a pair
    // of dispatchers?
    async fn run_send(&self) -> Answer {
        let Demo::Send(x) = &self.x else {
            panic!("not send");
        };
        // This future is Send because x is a SendDemo whose async
        // functions return Futures with Send. It is possible to pass
        // the future returned by run_send to something that requires
        // Send, such as tokio::spawn.
        x.f().await
    }
    async fn run_local(&self) -> Answer {
        let Demo::Local(x) = &self.x else {
            panic!("not local");
        };
        // This future is not Send because x is a LocalDemo whose
        // async functions return Futures without Send. It is possible
        // to pass the future returned by run_local to something that
        // requires Send, such as tokio::spawn because the body of the
        // function ultimately awaits on a future that is not send
        // inside LocalDemo's implementation of f.
        x.f().await
    }
}

pub async fn f() {
    let th = Thing {
        x: Demo::Send(Box::new(SendStruct {})),
    };
    assert_eq!(th.run_send().await.a, 0);
    assert_eq!(
        needs_send(async move {
            // We can do this because th.run_send() returns a future that is Send.
            th.run_send().await
        })
        .await
        .a,
        0
    );
    let th = Thing {
        x: Demo::Local(Box::new(LocalStruct {})),
    };
    // We can do this in the current thread, but we can't do it in a
    // different thread because run_local()'s future is not Send.
    // error: needs_send(th.run_local()).await;
    assert_eq!(th.run_local().await.a, 1);
}

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

    #[tokio::test]
    async fn test_f() {
        f().await;
    }
}

(Playground)

Output:


running 1 test
test tests::test_f ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


Errors:

   Compiling playground v0.0.1 (/playground)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.38s
     Running unittests src/lib.rs (target/debug/deps/playground-e8628c74cdc4bf1a)
   Doc-tests playground

Since you are concerned with Wasm in particular[1], you can use send_wrapper. This allows you to wrap your !Send futures to be Send, at the price of panicking if they are sent to another thread (which is no problem when there in fact will not be any other threads).

Is my analysis about what's going on here correct?

In the end, if you want to be able to hand out Send futures, be able to use !Send ones, and use type erasure, then you're going to have to have two versions of some things — but there are possibilities for not having to use two traits and duplicate all your code, by adding genericism in the right places.

The async_trait macro works by returning boxed futures. Now-stable native async trait syntax works by using hidden associated types, which means they aren't object-safe (yet). We can get the best of both worlds, at the price of awkward syntax, by using explicit associated types and boxed futures.

// replace the middle of your example code with this

use futures::future::{BoxFuture, LocalBoxFuture};

trait Demo {
    type Future: Future<Output = Answer>;
    fn f(&self) -> Self::Future;
}

struct SendStruct {}
impl Demo for SendStruct {
    type Future = BoxFuture<'static, Answer>;
    fn f(&self) -> Self::Future {
        Box::pin(async move { Answer { a: 0 } })
    }
}

struct LocalStruct {}
impl Demo for LocalStruct {
    type Future = LocalBoxFuture<'static, Answer>;
    fn f(&self) -> Self::Future {
        Box::pin(async move {
            let x: NotSend = Default::default();
            x.f().await
        })
    }
}

enum DemoBox {
    Send(Box<dyn Demo<Future = BoxFuture<'static, Answer>> + Sync + Send>),
    Local(Box<dyn Demo<Future = LocalBoxFuture<'static, Answer>> + Sync + Send>),
}

Now, you can use the single Demo trait generically and let Send propagate as it normally does, while also using it as a trait object. The trait object still comes in two flavors, as it must — the required thread-safety information must be propagated statically and not dynamically — but you can then delegate to a generic function.

(I did have to add a 'static bound on the boxed futures, though.)

struct Thing {
    x: DemoBox,
}
impl Thing {
    async fn run_send(&self) -> Answer {
        let DemoBox::Send(x) = &self.x else {
            panic!("not send");
        };
        self.common(&**x).await
    }
    async fn run_local(&self) -> Answer {
        match &self.x {
            // Because the trait is the same except for associated types,
            // we can delegate to shared code.
            DemoBox::Send(x) => self.common(&**x).await,
            DemoBox::Local(x) => self.common(&**x).await,
        }
    }
    
    async fn common<D: Demo + ?Sized>(&self, x: &D) -> Answer {
        x.f().await
    }
}

Playground

Someday, we may be able to avoid writing the explicit associated type, through a combination of automatically type-erasing Future return types in trait objects, and return type notation to write bounds on them.


Finally, consider whether you can organize your program so as to avoid requiring a trait object. None of these problems arise when you write code that is generic instead of using trait objects.


  1. as opposed to the harder problem of working on arbitrary no_std platforms, or ones where there are threads but you need to use !Send underlying components ↩︎

This was a very helpful answer. Thanks! This pointed me to some more documentation including the async rust book, which I had previously overlooked. After reading about Pin, your solution made sense. I understand how this removes the need to create two traits and makes it easier to put the common code behind a generic but doesn't (of course) remove the need to distinguish the Send/Local code paths. Also, I hadn't thought of this before, but I understand that the 'static bound works because an explicit async block that appears in the code is static and therefore returns a static future. (Please correct me if I'm wrong.)

Your suggestion to use generics rather than trait objects goes straight to my point about looking for a better solution rather than trying to take one step back from the point where I bumped into the wall. It hadn't been that clear to me where a generic would best fit, but I have a new solution that I think is quite a bit cleaner. Is this more along the lines of what you had in mind? My generic solution makes the trait-implementing type a generic parameter to Thing. By doing that, I no longer need trait objects and therefore can use associated functions, which means I can use stabilized async functions in traits. Here's a revised implementation. Is this more or less what you were thinking when you suggested using generics? Also, thanks for the tip about send_wrapper -- I was trying to figure out how to do something along those lines, and I believe it will work nicely for my specific use case, though I'm glad to have a better understanding here and will likely move away from trait objects here if I can regardless.

use std::future::Future;
use std::marker::PhantomData;

// needs_send requires a future with Send. This is like tokio::spawn.
async fn needs_send<F, T>(future: F) -> T
where
    F: Future<Output = T> + Send + 'static,
    F::Output: Send + 'static,
{
    future.await
}

struct Answer {
    a: i32,
}

// NotSend isn't Send because it contains a raw pointer.
#[derive(Default)]
struct NotSend {
    _z: PhantomData<*const u8>,
}
impl NotSend {
    async fn f(&self) -> Answer {
        Answer { a: 1 }
    }
}

trait Demo {
    async fn f() -> Answer;
}

#[derive(Default)]
struct SendStruct {}
impl Demo for SendStruct {
    async fn f() -> Answer {
        Answer { a: 0 }
    }
}

#[derive(Default)]
struct LocalStruct {}
impl Demo for LocalStruct {
    async fn f() -> Answer {
        let x: NotSend = Default::default();
        x.f().await
    }
}

#[derive(Default)]
struct Thing<T: Demo> {
    _x: PhantomData<T>
}
impl<T: Demo> Thing<T> {
    async fn run(&self) -> Answer {
        T::f().await
    }
}

pub async fn f() {
    let th: Thing<SendStruct> = Default::default();
    assert_eq!(th.run().await.a, 0);
    assert_eq!(
        needs_send(async move {
            // We can do this because th.run_send() returns a future that is Send.
            th.run().await
        })
        .await
        .a,
        0
    );
    let th: Thing<LocalStruct> = Default::default();
    // We can do this in the current thread, but we can't do it in a
    // different thread because run_local()'s future is not Send.
    assert_eq!(th.run().await.a, 1);
}

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

    #[tokio::test]
    async fn test_f() {
        f().await;
    }
}

playground

No, async blocks are free to borrow things and not be 'static. The 'static bound could be removed from the trait:

trait Demo {
    type Future<'a>: Future<Output = Answer>;
    fn f<'a>(&'a self) -> Self::Future<'a>;
}

This would allow the returned future type to borrow from self. Bt then it would no longer be possible to use this as a trait object; you'd have to write something like

Box<dyn Demo<for<'a> Future<'a> = BoxFuture<'a, Answer>> + Sync + Send>

but that's not a currently supported kind of bound.

Yes, except that you also switched to using PhantomData, and there's no reason to do that in this case. It constrains things unnecessarily by prohibiting Demo implementations from carrying any actual data (such as configuration). And there is no performance advantage, because when there isn't any such data, T and PhantomData<T> are equally zero-sized.

No, async blocks are free to borrow things and not be 'static.

Oh, right. Makes sense.

you also switched to using PhantomData, and there's no reason to do that in this case. It constrains things unnecessarily by prohibiting Demo implementations from carrying any actual data (such as configuration).

Good point about PhantomData. I tweaked my example locally to have Thing contain a Box and to have a mixture of associated functions and methods, and it works as expected. I just used PhantomData since I didn't have any methods and needed something so T would be used, but I see what you're saying that storing a T that is an empty data type would be the same.

Thanks again for your help.

actually, I realize it's fine to just have demo: T rather than demo: Box to get the zero size thing.

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.