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

Actor model example

Drogue Actor Model

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.

Bootstrap

As with any embassy application, the entry point is specified using an async function marked with #[embassy::main], and it will be passed a Spawner that is passed to each actor context mount(…​).

Packages

In some cases, it may be desirable to have two or more actors involve in a single semantic component or package. The Package trait may be implemented for any type.

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.