Vector of async traits

Hello,

I am having a kind of a hard time trying to solve what I think is a common paradigm.

What I would like?

use async_trait::async_trait;


enum WorkflowResult {
    Completed,
    InformationRequired(Value),
}

pub struct SomeParam {
    key: String,
    next_key: String,
}

#[async_trait()]
trait Step {
    async fn execute(&self, param: &SomeParam) -> WorkflowResult;
}

struct FirstStep {}

#[async_trait()]
impl Step for FirstStep {
    async fn execute(&self, param: &SomeParam) -> WorkflowResult {
        // make some possible calls.await
        println!("First step");
        WorkflowResult::Completed
    }
}

struct SecondStep {}

#[async_trait()]
impl Step for SecondStep {
    async fn execute(&self, param: &SomeParam) -> WorkflowResult {
        println!("Second step");
        WorkflowResult::Completed
    }
}

pub async fn execute_workflow(param: &SomeParam) {
    let workflow: Vec<Box<Step>> = vec![Box::new(FirstStep {}), Box::new(SecondStep {})];
    for s in workflow {
        s.execute(&param).await;
    }
}

This code does not compile, because

the trait `Send` is not implemented for `dyn workflow::Step`

As far as I can understand, the Send trait is related to multithreading. However, what I have here is a vector of steps that should be executed one after each other (not in paralel). Further more, trying to replace a Box by an Arc (presumably thread safe) posed a very similar result.

In c++, I would just create classes which derive from a class with a pure virtual function and use the base pointer as the type of the collection anyways (destructors are also virtual, so no problems here). The sync trait with a Box works, as far as I can understand, the same. But when we put async together the problems start to arise. But I think this is so common that we should have it documented somewhere and I can assure you I have looked into so, so many places and have found nothing.

If I use the Vec with sync traits, things just work, but each step needs to call http code and the framework is Tokio anyways, so I would like to keep things as async as possible.

This is the very simplest workflow I would write, otherwize my code will become a mess. A more sophisticated one would include another function on the trait which takes a Step as a parameter and set for each Step its next step, so that the executor would need to call only execute on the first step, but if someone could help me to make even this simple workflow here work it would be great.

Thank you,
Marlon

1 Like

In the future, please make sure to always post the full error. It makes it a lot easier to help you.

It doesn't matter. Tokio may move your execute_workflow task from one thread to another at any .await, so the contents of your box must be thread-safe.

The purpose of an Arc is to let you take a value that is already thread-safe and help you share it between several threads. If the value is not thread safe in the first place, it wont help you.


The reason that your value is determined to not be thread-safe is that the type Box<dyn Step> uses the special trait-object type dyn Step, which may contain any value that implements the trait Step. Since it is possible for a value to both implement Step and not be Send, the Box<dyn Step> type is not Send either. That the types you are actually using in practice are thread-safe doesn't matter — the type could contain a non-thread-safe type, so you get the error.

The easiest way to fix this is to require that implementers of Step are thread-safe like this:

#[async_trait]
trait Step: Send + Sync {
    async fn execute(&self, param: &SomeParam) -> WorkflowResult;
}

You could also have used the type Box<dyn Step + Send + Sync> to opt-in on a per-box basis, but that doesn't seem useful here.

Note: I am using the dyn keyword here in front of Step everywhere it is used as a type. You should have gotten a warning about not doing this, and it will become a hard error in the future.

5 Likes

Thank you so much.

I think the hardest point I always get myself into when learning rust is the fact that I hardly can know:

  1. What is the compiler worried about?
  2. What is the compiler able or not to infer from my code.
  3. Up until where it can make inferences (aka when it points an error is it because it knows something is wrong or because it fears something can possibly be wrong or because it doesn't have enough information so that in doubt it will consider it as an error)?

This was the case here: the error was because the code could be thread unsafe, although it wasn't. As compiler weren't told that the code is thread-safe then it assumed it wasn't.

anyways thank you again.