Actors
An actor system is a framework that allows for isolating state within narrow contexts, making it easier to reason about system. Within a actor system, the primary component is an Actor, which represents the boundary of state usage. Each actor has exclusive access to its own state and only communicates with other actors through message-passing.
What is an actor
Actors make it convenient to write stateful concurrent systems using message passing. Actors only process one message at a time, and communicate with other actors by sending messages to their addresses. Actors compose easily due to their decoupled nature, making it easier to maintain an expanding code base.
For microcontrollers, you can use the ector
crate. Actors in ector
are async, which means that they process messages using async-await support in Rust. This does not mean you have to write async code, but you will have the option to do so. The async book is a great way to learn more about async Rust.
Read more about actors here.
Actors vs Tasks
Tasks are a good fit if:
-
You are doing 'standalone' work that doesn’t require any input from other parts of your application
-
You are not going to reuse the task functionality in multiple applications
Actors are a good fit if:
-
You want to run a process that handles messages through a channel
-
You want to reuse this process in multiple applications or for multiple purposes
-
You like the ability to compose your application and isolate the business logic in different units
Drogue Actor Model
Async
Each actor is single-threaded, able to process a single message at a time, allowing for lock-free processing of each event. As embedded processors are globally single-threaded, supporting multiple actors requires the usage of async
and .await
within the Rust ecosystem. Each actor can therefore process each message either synchronously if its logic is non-blocking or using an async
block if complex processing is required.
Each event is fully processed, in the order in which it is received, before the next event is considered.
While processing an event, an actor may send a message to another actor, which itself is an asynchronous action, allowing the system to continue to make progress with actors that are able to.
Messages
All messages are sent using async channels attached to each actor. The channel depth is configurable based on const generics. Upon starting, the actor
is given a handle to an Inbox
from which it can await messages to arrive.
Addresses
Each actor within the system has its own unique Address
which is used to communicate with the actor (through it’s FIFO).
There is an async request(msg)
method on each address to send a message asynchronously to the actor, which may only be used from another async
context, as the sender must .await
the response. There is also a sync notify(msg)
method on each address to send a message without awaiting a response.
Specifically, the Address
for a given actor may implement user provided traits to provide fluent APIs for communicating with the underlying actor. For instance, the Socket
type wraps an Address
for actors that implement the TcpSocket
trait.
Lifecycle
Each actor is wrapped in an ActorContext
object which is stored as a static
(global). When the application starts, each context is `mount(…)`ed, and provided an instance of the actor to "run".
During mount, the actor system will initialize the channels, and spawn the underlying embassy
task. Once spawned, the actors Address<…>
is made available.
Writing an Actor
An actor must implement the Actor
trait, which will allow it to be spawned by the Embassy
executor. An actor providing an atomic counter using the above driver is shown below:
#![feature(type_alias_impl_trait)]
use drogue_device::*;
pub struct AtomicCounter {
counter: MyCounter
}
pub enum CounterMessage {
Increment,
Add(u32),
}
#[actor]
impl Actor for AtomicCounter {
type Message<'m> = CounterMessage;
async fn on_mount<M>(&mut self, _: Address<CounterMessage>, mut inbox: M)
where
M: Inbox<CounterMessage>
{
loop {
if let Some(mut m) = inbox.next().await {
match *m.message() {
CounterMessage::Increment => self.counter.increment().await,
CounterMessage::Add(value) => self.counter.add(*value).await,
}
}
}
}
}
Notice the actor
attribute, which handles a lot of the boilerplate of writing actors. This actor expands the code to the following:
impl Actor for AtomicCounter {
type Message<'m> = CounterMessage;
type OnMountFuture<'m> = impl Future<Output = ()> + 'm;
fn on_mount<'m, M>(
&'m mut self,
_: Address<CounterMessage>,
inbox: &'m mut M,
) -> Self::OnMountFuture<'m, M>
where
M: Inbox<Self> + 'm
{
async move {
loop {
if let Some(mut m) = inbox.next().await {
match *m.message() {
CounterMessage::Increment => self.counter.increment().await,
CounterMessage::Add(value) => self.counter.add(*value).await,
}
}
}
}
}
}
Once Rust has native support for async traits, the attribute will no longer be necessary.
Although a very complex way to implement an atomic counter, it gives you an idea of how actors provide exclusive access to a resource.