Are traits and async code a poor fit together?

Hello all,

I'm having trouble expressing various async situations using traits. In order to use async methods in traits at all, I'm using the async_trait crate.

A typical situation that I find awkward is this:

  • I've defined a TcpResponder trait. This is to be implemented by any objects that respond to incoming messages on a tcp connection.
  • The TcpResponder has two stages to its existence:
    1. It listens on a particular socket, waiting for something to connect to it
    2. When something has connected, it starts a loop of waiting for TCP messages and responding to them. Barring any exceptions, this loop continues forever.

Note that both of these stages involve some unknown amount of waiting, so lend themselves well to async code.

I could express this likeso:

trait TcpResponder {
    // Wait for a connection, return when you have one; store the tcp-related state internally
    pub async fn await_connection(&mut self, socket_to_listen_on: SocketAddr);
    // Now that you have a connection (which is stored internally), start responding
    pub async fn start_responding(&self);
}

The problem with this is that:

  • The methods do not really act as a summary as to what the trait should be doing - they have neither informative parameters nor a return type
  • The first method uses &mut self and this gives no guidance to the trait implementer as to what they should be doing behind-the-scenes with the mutable self instance
  • These two methods don't actually need to be separate, they could be elided together since they can only be called in sequence, and probably will only ever be called in direct sequence, except that having a single method makes things even less informative

There are various other ways this could be expressed - ie, fn start_responding could be a sync (rather than async) function that returns a future, and this future is then .await-ed elsewhere, but they all feel unsatisfactory.

Is my problem here:
a. That I haven't hit upon the right paradigm for expressing this
b. That traits and async code don't really fit together that well, hence async_trait being a crate rather than a native language feature
c. That I should make TcpResponder's methods sync, rather than async, and I should call these from an async block somewhere else if I want to make them async
d. Or something else entirely

Would appreciate people's opinions.

what do you mean by "as a summary as to what the trait should be doing"? it's hard to understand what's your expectation about the trait. maybe show a short example on what's in your mind the trait should look like?

what guidance could a receiver parameter give? it has to be named self in order the associated function be callable using method call syntax, and the type is limited to a set of selective types. I don't understand your intention here.

if the implementer has only one way to implement the functionality correctly, I feel you are over-specifying. why not use something as simple as:

// instead of using the `TcpResponder` trait
fn run_tcp_server(responder: impl TcpResponder) {
    //...
}
// just use a closure
fn run_tcp_server<F, Fut>(responder: F)
where
    F: FnOnce(SocketAdder) -> Fut,
    Fut: Future
{
    //...
}

they are essentially the same. an async method (annotated with the async_trait attribute) returns a boxed future.

to me, your design seems unnecessarily complicated. the way you described the problem (e.g. the start_responding() method should loop forever) sounds like it's just a single "start and forget" type use case. I think the start_responding() method is useless in this regard, and await_connection() can be simply named start_server(address). or alternatively, as I mentioned above, just use a closure that returns a Future.

on the other hand, a network server interface typically expects the implementer to implement a "handler" callback for some high level "request" type, it should not expect the implementer to implement their own server loop, or multiplexer, or scheduler, etc.

BTW, the stable rust 1.75 now supports async fn and return position impl Trait for trait associated functions.

1 Like

Thanks for the response. For the "summary as to what the trait should be doing", to my mind a trait is well-thought-through if you can infer from the methods what the implementer is going to be doing and how it's going to be interacting with objects other than itself. It's hard to say this about a trait that has a single method, fn start(&mut self, socket: SocketAddr). The function signature seems too general, and lots of existing rust objects that are not actually valid LinkResponders could probably implement that, so it's not taking full advantage of the type system to improve safety.

I agree with you (and wrote in the original question) that separating await_connection and start_responding is unnecessary, but for reasons mentioned directly above I don't think having a single-function trait is completely satisfactory.

I think breaking the system into smaller components, and having LinkResponder have some kind of request/callback system, like you say, seems the best way to go.

Many Rust traits provide only a single operation. This is good — it offers flexibility by not demanding additional operations that aren't actually necessary, nor demanding that the implementor arrange itself in a particular fashion (a particular separation of steps) that might not quite fit.

I think you should think of trait design less in terms of specifying/implying what steps the implementor must take, and more about specifying some role the implementor can play, or a way it can be manipulated. “Can be told to start a network server given an address to listen on” is a perfectly good such role.

1 Like

This is gong to sound like a joke, but I'm 100% serious. My preferred way for dealing with async fns in traits is to just have regular fns that return:

Pin<Box<dyn Future<Output = ##### >>>

As ugly as this notation is, I find it helps make everything explicit and easy to reason about.

2 Likes

And in fact that's nearly exactly what the futures crate supplies (you actually wrote LocalBoxFuture, which is not normally what you want, which is why the alias is handy)

2 Likes

Did you mean it is normally what you want?

That's actually quite helpful to know, I had my code like that for a bit and then changed it out of some hunch that what I was doing was weird and un-idiomatic, the kind of thing you come back to in a year's time and wince about.

No, I meant you normally want BoxFuture, not LocalBoxFuture.

2 Likes

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.