I'm always most curious with these frameworks how they're considering supervision. That's the real superpower of OTP, especially over Rust.
To me, Rust has adequate concurrency tooling to make ad hoc actor designs roughly on par with more developed ones for many tasks, but supervision is both highly valuable and non-trivial. Briefly, I'd say Rust is top-notch for lower-level concurrency primitives but lacks architectural guidance. Supervisor trees are a great choice here for many applications.
I've tried implementing supervision a few times and the design is both subtle and easy to get wrong. Even emulating OTP, if you go that route, requires exploring lots of quiet corner cases that they handle. Reifying this all into a typed language is an additional challenge.
I've found myself tending toward one_for_all strategies. A reusable Fn that takes some kind of supervisor context and builds a set of children, potentially recursively building the supervision tree beneath, tends to be the best design for typed channels. It forces one_for_all however as it's a monolithic restart function. You can achieve limited (batch-y) rest_for_one by having the last thing you boot in your one_for_all be another one_for_all supervisor, but this feels a little hacky and painful and pushes back against more granular rest_for_one designs.
You then probably want a specialized supervisor for one_for_one, similar to Elixir's DynamicSupervisor.
> That's the real superpower of OTP, especially over Rust.
That, and preemptive scheduling. And being able to inspect / debug / modify a live system. Man, these actor frameworks just make me appreciate how cool Erlang is.
It's clear that there's some beginning of this in place. They reference it as initial in the docs there and it's missing quite a bit. I'm not a huge fan of what I'm seeing here where each actor is implicitly itself and a supervisor (i.e., ActorRef makes reference to the actor's children). I think I saw that first in Akka and while it makes sense in theory, I don't like the practice.
To me, your supervision tree should be dedicated to that purpose and forms a superstructure relating entirely to spawning, resource provisioning, restarting, shutdown. Part of what makes it nice in Erlang is that it's consistent and thoughtless, just part of designing your system instead of being behavior you have to write or worry about much.
Here with Ractor they've built a special monitoring channel and callbacks into Actor (`handle_supervisor_evt`). This implies at some point one might write a nice supervisor in their framework that hopefully has some of those properties.
At $dayjob I've taken to just implementing my own actor-like model when I need interior mutability across tasks. Something like:
struct State { counter: usize, control: mpsc::Receiver<Msg> }
struct StateActor { addr: mpsc::Sender<Msg> }
enum Msg {
Increment { reply: oneshot::Sender<()> }
}
impl StateActor {
pub async fn increment(&self) {
let (tx, rx) = oneshot::channel();
let msg = Msg::Increment { reply: tx };
self.addr.send(msg).await.unwrap();
rx.await.unwrap();
}
}
impl State {
fn start(self) {
tokio::spawn(async move {
/* ... tokio::select from self.control in a loop, handle messages, self is mutable */
/* e.g. self.counter +1 1; msg.reply.send(()) */
})
}
}
// in main
some_state_actor.increment().await // doesn't return until the message is processed
A StateActor can be cheaply cloned and used in multiple threads at once, and methods on it are sent as messages to the actual State object which loops waiting for messages. Shutdown can be sent as an in-band message or via a separate channel, etc.
To me it's simpler than bringing in an entire actor framework, and it's especially useful if you already have control loops in your program (say, for periodic work), and want an easy system for sending messages to/from them. That is to say, if I used an existing actor framework, it solves the message sending/isolation part, but if I want to do my own explicit work inside the tokio::select loop that's not strictly actor message processing, I already have a natural place to do it.
I was about to mention this, Ractor is already a name used to describe the Ruby Actor class. But I guess this is only known within the Ruby Community? It would’ve taken one search to find out about this. Either way, since both names are bounded to their languages, the context should make it clear what is referred to. And to be fair, the name Ractor makes sense for both languages.
Honest truth? I didn't Google it first lol. I just checked if there was a crate with the same name and carried on. I only found out about the Ruby ones after I posted on Reddit for the first time. By then it was too late since I had already reserved the crate name and it was being downloaded
Unlikely, the contributors don't seem to have meaningful Ruby backgrounds. Isn't it a simpler supposition that the naming intent was merely (R)ust Actors?
That looks interesting! What's the distributed story of Ractor? Would you need a central store like Redis to serve as Actor registry?
One of the promises of Elixir/Erlang is that you can call a process/Actor on different machine just like you can one on same once you put together a bunch of machines in a cluster
> Additionally ractor has a companion library, ractor_cluster which is needed for ractor to be deployed in a distributed (cluster-like) scenario. ractor_cluster shouldn’t be considered production ready, but it is relatively stable and we’d love your feedback!
Elixir/erlang/gleam also have the advantage that many libraries are written as servers and so automatically gain the benefits and resilience. Something all these actor frameworks can’t give you.
The Erlang runtime and EPMD takes care of this for you.
Every node in the cluster gets a name, so you can address it directly, and, every PID is unique across nodes on a cluster, so you can send messages to processes (actors) no matter where they are in the cluster.
I personally find actors in rust to be too much of a head ache. You might as well use Erlang (or elixir) and do your heaving lifting in rust with somethign like https://github.com/rusterlium/rustler
I personnaly don't like the single enum model for messages. I prefer the generic Handler trait model.
Also another square that I have not circled with async actor other than actix is that all of them use mutable self and they dont have a great way to have long running tasks. Sometimes you would want to call a remote server without blocking the actor, in actix this is easy to do and you unamed child actors to do that. All those newer frameworks don't have the primitives.
To me, Rust has adequate concurrency tooling to make ad hoc actor designs roughly on par with more developed ones for many tasks, but supervision is both highly valuable and non-trivial. Briefly, I'd say Rust is top-notch for lower-level concurrency primitives but lacks architectural guidance. Supervisor trees are a great choice here for many applications.
I've tried implementing supervision a few times and the design is both subtle and easy to get wrong. Even emulating OTP, if you go that route, requires exploring lots of quiet corner cases that they handle. Reifying this all into a typed language is an additional challenge.
I've found myself tending toward one_for_all strategies. A reusable Fn that takes some kind of supervisor context and builds a set of children, potentially recursively building the supervision tree beneath, tends to be the best design for typed channels. It forces one_for_all however as it's a monolithic restart function. You can achieve limited (batch-y) rest_for_one by having the last thing you boot in your one_for_all be another one_for_all supervisor, but this feels a little hacky and painful and pushes back against more granular rest_for_one designs.
You then probably want a specialized supervisor for one_for_one, similar to Elixir's DynamicSupervisor.
That, and preemptive scheduling. And being able to inspect / debug / modify a live system. Man, these actor frameworks just make me appreciate how cool Erlang is.
To me, your supervision tree should be dedicated to that purpose and forms a superstructure relating entirely to spawning, resource provisioning, restarting, shutdown. Part of what makes it nice in Erlang is that it's consistent and thoughtless, just part of designing your system instead of being behavior you have to write or worry about much.
Here with Ractor they've built a special monitoring channel and callbacks into Actor (`handle_supervisor_evt`). This implies at some point one might write a nice supervisor in their framework that hopefully has some of those properties.
To me it's simpler than bringing in an entire actor framework, and it's especially useful if you already have control loops in your program (say, for periodic work), and want an easy system for sending messages to/from them. That is to say, if I used an existing actor framework, it solves the message sending/isolation part, but if I want to do my own explicit work inside the tokio::select loop that's not strictly actor message processing, I already have a natural place to do it.
https://docs.ruby-lang.org/en/master/Ractor.html
https://docs.ruby-lang.org/en/master/ractor_md.html
Dead Comment
One of the promises of Elixir/Erlang is that you can call a process/Actor on different machine just like you can one on same once you put together a bunch of machines in a cluster
> Additionally ractor has a companion library, ractor_cluster which is needed for ractor to be deployed in a distributed (cluster-like) scenario. ractor_cluster shouldn’t be considered production ready, but it is relatively stable and we’d love your feedback!
OTP is super powerful out of the box :)
Show HN: Kameo – Fault-tolerant async actors built on Tokio - https://news.ycombinator.com/item?id=41723569 - October 2024 (58 comments)
Also another square that I have not circled with async actor other than actix is that all of them use mutable self and they dont have a great way to have long running tasks. Sometimes you would want to call a remote server without blocking the actor, in actix this is easy to do and you unamed child actors to do that. All those newer frameworks don't have the primitives.
As demonstrated in the linked tutorial, Ractor passes its handlers `&self` with `&mut State`.