How Akka.NET Actors Process Messages

The fundamentals of Akka.NET actor construction and message processing.

It’s been a little while since we covered Akka.NET 101 on our blog, so we decided it was time to visit one of the most fundamental and significant matters users need to understand about Akka.NET in order to leverage it properly: how actor message processing actually works.

You can watch our “How Akka.NET Actors Process Messages” video on it below or read on:

Actor Fundamentals & Construction

When most developers think of an Akka.NET actor, they think something like this:

public class HelloUntypedActor : UntypedActor
{
    private readonly ILoggingAdapter _log = Context.GetLogger();
    private int _helloCounter = 0;
    protected override void OnReceive(object message)
    {
        switch (message)
        {
            case string str:
                _log.Info("{0} {1}", str, _helloCounter++);
                break;
            default:
                Unhandled(message);
                break;
        }
    }
}

A simple UntypedActor or a ReceiveActor - however, there’s much more to actors than just this class implementation.

There’s a reason why we have to write code like this in order to start actors, rather than simply new-ing them up:

.WithActors((system, registry, _) =>
{
    // <StartingActor>
    Props helloUntypedActorProps = Props.Create(() => new HelloUntypedActor());
    IActorRef helloUntypedActor = system.ActorOf(helloUntypedActorProps, "hello-untyped-actor");
    // </StartingActor>
    registry.Register<HelloUntypedActor>(helloUntypedActor);
})

This method, which uses Akka.Hosting, takes a Props for our HelloUntypedActor and passes it into the ActorOf method, for which we get an IActorRef in exchange. What’s actually going on here?

Akka.NET internal actor constructor

The truth of the matter is that your actor class is only one small part of what makes an actor an actor. Every user-defined Akka.NET actor consists of the following parts:

  • ActorCell – the real guts of the actor; doesn’t get touched during restarts.
  • IActorRef – serializable pointer to ActorCell.
  • Mailbox – queue of all unprocessed messages.
  • Dispatcher – scheduler responsible for executing actor message processing. By default this is the .NET ThreadPool.
  • Props – the formula for starting this actor. We have to retain a copy of Props inside the ActorCell in order to support actor restarts.
  • Actoryour actual code; message processing instructions.

What the ActorOf method does, fundamentally, is create all of this infrastructure so your actors can “just work” by the time you receive the IActorRef!

How Actors Process Messages

Now that we’re familiar with the components of actors, we can get into some details on how actor message processing actually works.

Message Queueing

Actor message queuing

  1. When you call IActorRef.Tell, this actually points to the ActorCell and not your user-defined actor. This is why restarts don’t affect actor’s ability to continue to process messages.
  2. The ActorCell enqueues the messages inside the Mailbox for processing in the very near future - so when IActorRef.Tell exits, it only means that the message has been successfully queued in-memory. Processing will occur asynchronously.
  3. The Mailbox tells the Dispatcher that this actor has messages that need processing - the Dispatcher will schedule us to execute in some point in the future.

An important detail here - is that actors can queue multiple messages before they get scheduled to run by the dispatcher. This is actually one of the ways in which Akka.NET actors achieve extremely high rates of throughput: each time an actor gets scheduled for message processing it gets a chance to process bursts of up to 30 messages by default. This means we can process all of these messages without any context-switching, which will improve memory access speeds / L1 caching / etc.

If want to learn more about the impact of context-switching on processing throughput, please see our video “Akka.NET v1.5 New Features and Upgrade Guide” and look at the section on “.NET 6 Dual Targeting” starting at the 4:13 mark.

So messages queue inside the actor’s mailbox until the actor gets scheduled for execution - this is what allows actors to execute asynchronously and what allows them to be fast during execution.

Message Ordering and Processing

But what about message ordering?

The Mailbox data structure is essentially a ConcurrentQueue<T> with some additional tooling for integrating with the dispatcher and supporting system messages (which always get processed ahead of user messages in Akka.NET.) This means that our Mailbox will behave just like any other queue data structure - it works via First-In, First-Out (FIFO) ordering!

Actor message processing

Actors only process a single message at a time - we don’t pull message 2 out of the Mailbox until message 1 has been fully processed. This is an intentional and crucial design choice, because this is what guarantees thread-safe access to actor state. The entire reason you don’t have to use locks, semaphores, or critical regions when working with in-memory state with actors is because that state can only be accessed or modified in a single thread of execution - enforced by our “one message at a time” guarantee.

Actors always process messages in the order in which they were received (FIFO) by default - you can change this using a custom Mailbox implementation, such as a PriorityMailbox, but that’s pretty rare in practice. Akka.NET guarantees FIFO execution order, which means that if you need operations to be carried out in a specific, prescribed order all you have to do is send the messages in the order you want them executed. Akka.NET also guarantees this message order over the network via Akka.Remote by default.

Every actor gets fair-scheduled - if an actor has more than 30 messages to process, it will have to re-schedule itself on the dispatcher once its allotted time is up. This is an important design in order to ensure that super busy actors don’t starve out less frequently messaged actors.

Await-ing During Message Processing

Akka.NET actors also support await during message processing as well, via code such as:

public sealed class AsyncAwaitActor : ReceiveActor
{
    public AsyncAwaitActor()
    {
        ReceiveAsync<Messages.Req>(async r =>
        {
            var ack = r.Ack;
            await Task.Delay(1);
            Sender.Tell(ack);
        });
    }
}

This is totally normal and supported, but there is one interesting performance implication here: normally actors get to process bursts of up to 30 messages at at time each time they’re scheduled - but when you await during message processing your actor gives up its turn on the dispatcher and won’t be rescheduled until the await completes, thus you lose some of the thread-affinity / no-context-switching benefits.

If you are interested in learning more about integrating Akka.NET actors with the .NET Task and Parallelism Library (TPL), you should read “Async / Await vs. PipeTo in Akka.NET Actors.”

Conclusion

Actor message processing operates by making and keeping simple guarantees:

  • All messages will be processed eventually;
  • All messages will be processed in FIFO order;
  • All messages are processed one at a time; and
  • All actors are fair-scheduled.

It’s very important to understand these and the benefits that these provide to you as an end-user of Akka.NET.

If you liked this post, you can share it with your followers or follow us on Twitter!
Written by Aaron Stannard on June 5, 2024

 

 

Observe and Monitor Your Akka.NET Applications with Phobos

Did you know that Phobos can automatically instrument your Akka.NET applications with OpenTelemetry?

Click here to learn more.