tellus, episode 1: actors for Rust, on solid ground
Nine years ago I wrote about the actor model in Akka and Akka Typed. Since then Rust has become my language of choice, but the actor model has not become any less useful. So I built tellus, an actor framework for Rust on top of Tokio, which has just been published in version 0.1.0. This is the first of a series of posts about it.
A quick recap, because the model is what everything else follows from: an actor is an independent unit of state which processes one message at a time. It has an address, and anybody knowing that address can send it a message without being blocked. When handling a message, an actor can create new actors, send messages to actors it knows and designate how the next message is handled. That is all. This fits Rust particularly well. Sharing mutable state across tasks normally means putting it behind an Arc<Mutex<...>>: everybody holds a reference to the same data and has to take a lock before touching it. An actor does not guard the sharing, it removes it. The state has a single owner, and because the actor handles one message at a time, that owner is the only thing ever touching it. Other tasks do not reach into the state, they send a message and carry on. What you write inside an actor is therefore ordinary sequential code over owned data, with no lock to take and none to forget.
Here is the smallest possible tellus program, an actor which greets for the one message it receives and then stops:
1 |
|
An actor is defined by implementing the Actor trait with three associated types: the Message it accepts, the State it carries and the Error it can fail with. init creates the initial state and receive handles one incoming message or signal.
Why another one?
Rust already has actor libraries, kameo and ractor among them, and they are good. So the question is fair, and there are three answers.
Message handling is not async. Actor::receive is a plain synchronous function, as the example above shows. There is no async fn in the trait, no future allocated and polled per message, no Box<dyn Future> and no handler future which must be Send, a bound which otherwise ripples into every local held across an await. That is the smaller half of the benefit though. The larger half is what cannot happen: actor code cannot await, so it cannot hold state across an await point, and there is no way to accidentally suspend in the middle of applying a message. One message at a time stops being a rule you have to respect and becomes a property of the type signature. This also removes an ambiguity that async handlers always carry, namely what the mailbox is doing while a handler awaits, a question a reader of your code should never have to ask.
What you give up is real and worth stating: an actor cannot be stopped while receive is running, so a receive which never returns keeps all of its ancestors from terminating. For long running or blocking work you spawn a Tokio task and send the result back to the actor as an ordinary message. That is a little more ceremony, and in exchange nothing can interrupt receive: there is no point inside it at which the actor can be suspended or cancelled, so handling a message is one uninterrupted step.
Actors are state machines, not mutable objects. receive takes the current state by value and returns the state for the next message, wrapped in Control::Continue, or Control::Stop to stop the actor. No &mut self, no interior mutability. Beyond reading nicely, this pays off under failure: a receive which panics or returns an error cannot leave a half-updated state behind, because the next state only exists once receive has returned it. Failures are values, so inside receive you use ? to escalate a failure to supervision and an explicit match to handle it as part of your domain.
Supervision trees and death watch, taken from Akka rather than bolted on. Actors form a tree: an ActorSystem spawns the root actor and every actor spawns children via ActorContext::spawn. Stopping an actor stops its subtree, children first, and only once all descendants have terminated does it terminate itself, which is what makes ActorSystem::terminated mean what you want it to mean. Failures, errors and panics alike, are handled by the actor’s supervision strategy, either stopping it or restarting it, which rebuilds the state but keeps the mailbox, limited and paced by exponential backoff.
Death watch comes with an ordering guarantee that I consider the most valuable thing in the library: ActorContext::watch delivers a terminated signal which is ordered behind every message the terminated actor has delivered to the watcher. Receiving the signal therefore proves that the watcher has seen every message from that actor it will ever see. That turns “am I done collecting results?” from a heuristic into a fact, and it is what the scatter_gather example is built on. How that guarantee is implemented is a good story on its own, and it will be the subject of a later episode.
Telling and asking
Sending a message is fire-and-forget: ActorRef::tell never blocks and delivers at most once. A message which cannot be delivered, whether because the actor has terminated or because its bounded mailbox is full, is dropped and logged as a dead letter. Sometimes you do want an answer though, and from outside the actor tree ActorRef::ask gives you one:
1 | let system = ActorSystem::new(Counter); |
ask takes a timeout and a function turning a ReplyTo into a message, which is why the enum variant constructor Message::Get can be passed directly. On the actor side a request is nothing special; the counter handles all its messages in the same receive:
1 | match message { |
The mailbox is FIFO, so the reply reflects every increment told before the ask. And no future shows up in the actor: awaiting happens on the calling side only. Between actors you do not await at all; ActorContext::reply_to creates a ReplyTo which delivers the reply into the asking actor’s own mailbox, where it arrives through receive like any other message.
What is next
tellus is at 0.1.0; it is under active development and its API is still settling. Two extensions are in the works, both feature gated so that tellus stays purely local and free of their dependencies by default: persistence, in the shape of event sourced actors, and remoting, where serializable actor refs let actors on different nodes message and watch each other through the very same API.
The repository has six examples, from the greeter above to a version of Akka’s IoT device manager, and docs/actors.md explains the core top-down with links into the implementation. In the next episode we will take a closer look at the actor as a state machine.
Disclosure: the prose of this post was drafted with Claude Code and revised by me.