Modelling actors as futures: how to write a future factory?

Hi everyone,

not sure whether this is the right place for the question, please let me know if it should go elsewhere.

I’m currently trying to distill the essence of the actor model in a Rust trait with the goal of decoupling the formulation of the actor’s logic from the underlying execution engine, much like it was done for the Future trait. The most difficult issue I encounter on this path is that an actor can spawn other actors, which requires a factory method to be provided by the execution context. My naïve (and obviously non-working) formulation is the following:

pub struct ActorRef<M>(Box<dyn Fn(M) + Send + 'static>);

pub trait Context<M: Send + 'static> {
    fn me(&self) -> ActorRef<M>;
    fn receive(&mut self) -> (impl Future<Output = M> + Send + '_);
    fn spawn<N, A>(&mut self, actor: A) -> (ActorRef<N>, oneshot::Receiver<()>)
    where
        N: Send + 'static,
        A: for<'a> FnOnce(&'a mut impl Context<N>) -> (impl Future<Output = ()> + Send + 'a);
}

(The universal quantification within A is necessary to allow the Context implementation to choose the lifetime of the passed-in context. Due to FnOnce syntax constraints it is not possible to express this with an additional F: Future ... type parameter.)

This has several problems:

  • it requires associated type constructors (see Niko’s blog)
  • it requires impl Trait in several places where it is currently not acceptable
  • it makes my head spin trying to understand how the compiler could possibly piece together the correct opcodes for factories and drop glue etc. (which is probably the reason for the other two points above).

With some boxing and restructuring and in general more clunky usage it is possible to implement something that works. My question is: could the above reasonably be salvaged? May it become implementable given the current roadmap of Rust features? Or is there an entirely different approach vector to this problem that I have overlooked?

Thanks in advance,

Roland

I have written a blog post on writing actors in Rust, which you can find here. Though I did not make use of any traits in that post.

Thanks, I’ll add that to my list of concrete implementations, even though it is not a complete demonstration of the Actor Model: it is a crucial ability that one actor can create other actors in response to a message. With your approach the programmer will have to tokio::spawn new actors, which works of course, but it doesn’t answer my question above.

Yesterday evening I thought a bit more about the nature of my problem, and it seems that Rust is really good at expressing concrete solutions, but its capabilities are limited when it comes to abstracting over solutions to some part of the problem while concretely implementing other parts. In some cases it is possible, but in some others it’s awkward at best.

Take for example the very low-level hand-rolled RawWaker implementation any Future execution library needs to write. When it comes to spawning new actors — which is a much more generic problem (pun intended) — it gets even more messy.

I mean, all of the futures can already be done now if you are ok with boxing them. The GAT feature would let you avoid boxing. The primary difficulty is actually the argument to A in spawn because although you could do this, I don't think it is what you want.

fn spawn<N, C, A>(&mut self, actor: A) -> (ActorRef<N>, oneshot::Receiver<()>)
where
    N: Send + 'static,
    C: Context<N>,
    A: for<'a> FnOnce(&'a mut C) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;

The reason I don't think that this is what you want is that with the above, the caller of spawn decides which type of context C is, and the implementation must work no matter the choice of C. You could try to get around this with your own fn trait:

pub trait Context<M: Send + 'static> {
    ...
    fn spawn<N, A>(&mut self, actor: A) -> (ActorRef<N>, oneshot::Receiver<()>)
    where
        N: Send + 'static,
        A: SpawnFn<N>;
}

pub trait SpawnFn<N: Send + 'static> {
    fn call<'a, C>(ctx: &'a mut C) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
    where
        C: Context<N>;
}

But a closure wont be able to implement SpawnFn, so any actor would need to implement the SpawnFn trait manually.

Yes, these are exactly the choices that are needed to “bring it into the box”, as shown in the link to the working implementation. Unfortunately it leads to quite a lot of ceremony when spawning other actors, but I guess that is how it is — macros can probably take away some part of the pain. And the boxing forces the actor description API to depend on alloc, even though it should be perfectly possible to implement an execution framework that does not require alloc.

So unless someone else has a brilliant idea, my conclusion is that for now Rust doesn’t support what I want to do.

In general, I think actors are best done without trait as Rust is now. This is why I did not use any traits in my blog post as well.

Just to be clear: it’s not that I want to use traits — if there is another means available to formulate such an SPI then I’ll gladly use it.

What is SPI?

I know it as Service Provider Interface, i.e. an API that is targeted at library authors rather than end users. The RawWaker and its vtable is an example for an SPI in the Rust standard library.

I mean, there are several actor frameworks out there such as actix. I don't think any of them are great though, precisely because of the issues you ran into.

What is wrong with just calling tokio::spawn?

What is wrong with just calling tokio::spawn ?

Then you tie the caller of your actor-using code to Tokio, which is one of those compiler-invisible API contracts that are easy to break, also dynamically (i.e. it may potentially fail in production). In a way, I’m running into the very same problem that causes the grievous absence of a common executor interface and much pain and suffering due to libraries being tied to Tokio, async_std, etc.

So if there was an API, presumably on std::task::Context, for saying “spawn this given Future on the current executor, whatever that may be”, then I’d have a solution for my problem as well.