Is it anti-idiomatic to make instance methods async?

Context:

I am currently evaluating how to use tokio async in the context of a p2p application that spins up a listener, as well as actively tries to establish peering with other clients in order to form an overlay network.

I have a struct Client which contains, among other things, references to active connections, as well as the configuration (socket, name, id etc.) of the local node it represents.

I wish to have an async fn listener(&self, worker_tx: Sender<Task>) so that I can spawn a task that will handle the I/O of inbound connections. I was imagining that I could then have my code look like this:

enum Task {
    DoSomething,
}

#[tokio::main]
async fn main() {
     let mut client = Client::new(....);
     let (worker_tx, worker_rx) = mpsc::channel::<Task>();

    // ...
    let listener = tokio::spawn(client.listener(worker_rx));

   // ...

  listener.await;
}

Problem

As it is, the above code will not work because async will want a reference to a client with a 'static lifetime. We can solve this by either changing the signature of listener to consume the client: async fn listener(self, worker_tx: Sender<Task>) but this is undesirable in my usecase. I want to use references and spin up other tasks attached to that client. This leaves us with the option of using an Rc<T>:

impl Client {
     // ...
     async fn listener(self: Rc<Self>, worker_tx: Sender<Task>) {
         unimplemented!()
    }
}

This feels a little bit unnatural and has me wonder if I'm not over-complicating things.

Questions:

  1. Is it idiomatic/neutral/unidiomiatic to have async instance methods?
  2. What is the most effective way to communicate to the compiler that Client is dropped when main stops, and that this is equivalent to a static lifetime (right?)
  3. More generally, how would you implement this pattern of having different long-running tasks couple to a shared instance (handling the instance I/Os for example here).

Thanks a lot for reading me,

Resources:

I have a github issue which faces the same problem I am facing and offers work-arounds, some are presented in this post. It doesn't really address if that's a pattern that makes sense for Rust, and the answer there might be a little dated as well.

1 Like

I'd like to suggest, not a solution, but a slightly different perspective on the problem.

When your program contains “background tasks”, there start being questions of (1) concurrent access to state and (2) dynamic lifetimes, i.e. some data needing to exist longer than the borrow checker can track and validate. And this means that you have to choose solutions to those problems.

These are precisely the things to which self: Rc<Self> (or, likely better, self: Arc<Self> to permit threaded execution) is one possible answer. There are other answers, such as for example the “actor” pattern (instead of holding the Client directly at all, it exists within a task already spawned and you have a message channel to control it).

Another thing to keep in mind is that from an interface-design perspective it often makes sense to, not wrap your type in Arc, but have your type contain an Arc for its state; make the external type a “handle” to the implementation.

struct Client(Arc<ClientState>);

impl Client {
    async fn spawn_listener(&self, worker_tx: Sender<Task>) -> JoinHandle<()> {
        let state = self.0.clone();
        tokio::spawn(async move {
            // code here does not mention `self`, only `state`
        }).await
    }
}

This version of the Client hides the Arc inside itself and doesn't complicate the caller's interactions with it. On the other hand, this is more boilerplate unless Client is used more times than it's defined.

I'm not saying that you should take any of these alternatives — just that you should look at this as part of the program-design space, rather than a merely syntactic awkwardness. Doing so may reveal ways to better structure your program.

9 Likes

I've written an article about actors, which gives one possible pattern (in my experience, the best pattern) for having background workers like this. You can find it here.

6 Likes

If you try to use a static lifetime, the Client will not drop at the end of the main. It will be cleaned later. Static items do not call drop at the end of the program. So the response of @kpreid is the closest thing that you can do to force the compiler to drop the Client at the end of the main.

Looking quickly at your issue, it looks like wasm_bindgen doesn't like lifetimes, isn't it? However, I learned something with your last comment about future_to_promise :+1: :pray:

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.