Async / Await vs. PipeTo in Akka.NET Actors

Best Practices and Patterns for Asynchronous Programming with Akka.NET Actors

Many years ago I wrote an article entitled “How to Do Asynchronous I/O with Akka.NET Actors Using PipeTo” - back when await support was still very much a work in progress. I subsequently wrote a major update on that article in 2016 - talking mostly about the benefits of PipeTo from a structural point of view.

I recently learned that this article has many of our own users under the impression that Akka.NET actors don’t support await semantics properly and that PipeTo is the only “blessed” pathway for working with asynchronous operations inside actors. This is not the case - await has had first class support inside actors for many years now.

That’s what this post and video cover: async, await, and PipeTo - how do they work differently, when should you consider using one over the other, and what are the “gotchas” that you need to know?

If you want to see the code sample that goes along with this video and blog post, you can find that here: https://github.com/Aaronontheweb/Akka.AsyncAwait

async and await inside Akka.NET Actors

First and foremost, async and await are natively supported inside Akka.NET actors - typically via the ReceiveAsync<T> method:

public sealed class AsyncAwaitActor : ReceiveActor
{
    public AsyncAwaitActor()
    {
        Receive<Messages.Batch>(batch =>
        {
            foreach (var b in Enumerable.Range(0, batch.Size))
            {
                // preserve the ref of the original sender
                Self.Forward(new Messages.Req(b));
            }
        });
        
        ReceiveAsync<Messages.Req>(async r =>
        {
            var ack = r.Ack;
            await Task.Delay(1);
            Sender.Tell(ack);
        });
    }
}

What ReceiveAsync signifies is that the receive-handler is going to return a Task rather than simply being a void method of some kind - and the actor has infrastructure built-in for suspending processing of any further messages each time an await is reached inside the ReceiveAsync method.

Akka.NET mailbox being suspended during async / await

When an actor awaits, the following happens:

  1. The Mailbox is suspended, meaning the actor won’t be scheduled to process any additional messages until it’s resumed again;
  2. Once the await completes, an ActorTaskSchedulerMessage is sent containing the remainder of the ReceiveAsync method following the await- this is a system message so it can be processed ahead of any other messages waiting in the suspended Mailbox.
  3. The actor will process all awaits for a given message this way until the ReceiveAsync’s Task completes and the method has exited, at which point the actor will resume its mailbox and continue with normal message processing.

This is a detailed technical explanation for what’s happening with the actor internally but the bottom line is: await works the same way inside Akka.NET actors as it does everywhere else in .NET. await suspends the flow of execution without blocking a thread and allows the re-entrancy when the await-ed operation completes - the only difference here is that the actors manage the re-entrant portion through their Mailboxes, since that’s where actor thread-safety / serial message processing originates.

PipeTo inside Akka.NET Actors

Everything I wrote in “How to Do Asynchronous I/O with Akka.NET Actors Using PipeTo” about PipeTo’s mechanics is still accurate, but I’ll illustrate how the code works differently from await-ing inside an actor below:

public sealed class PipeToActor : ReceiveActor
{
    public PipeToActor()
    {
        Receive<Messages.Batch>(batch =>
        {
            foreach (var b in Enumerable.Range(0, batch.Size))
            {
                // preserve the ref of the original sender
                Self.Forward(new Messages.Req(b));
            }
        });
        
        Receive<Messages.Req>(r =>
        {
            var ack = r.Ack;
            Task.Delay(1).ContinueWith(tr => ack)
                .PipeTo(Sender, Self);
        });
    }
}

The structure is exactly the same as the AsyncAwaitActor - with one key difference: the PipeToActor doesn’t wait for the Task.Delay to complete prior to processing the next Messages.Req instance. Instead it kicks off a detatched Task and uses PipeTo to deliver the output back into its own mailbox upon completion.

Akka.NET mailbox being suspended during async / await

Comparison

In the video accompanying this blog post, “Akka.NET Actors: Async / Await vs PipeTo” you can see me run some live Petabridge.Cmd commands against our code sample for this blog post.

I’m going to use some Jaeger tracing graphs produced by Phobos to help illustrate the difference between await and PipeTo from a flow-control perspective.

await Scenario

pbm Command: pbm scenario await -b 10

Output

Starting async / await batch of [10]
Received Ack(SeqNo=0)               
Received Ack(SeqNo=1)               
Received Ack(SeqNo=2)               
Received Ack(SeqNo=3)               
Received Ack(SeqNo=4)               
Received Ack(SeqNo=5)               
Received Ack(SeqNo=6)               
Received Ack(SeqNo=7)               
Received Ack(SeqNo=8)               
Received Ack(SeqNo=9)               
batch complete                     

All messages were completed in the original order in which they were sent, which you can see on the Jaeger graph below as well.

async / await implementation of Akka.NET actors Click for a full-sized version of this image

This scenario took roughly 200ms to run.

PipeTo Scenario

pbm Command: pbm scenario pipeto -b 10

Output

Starting PipeTo batch of [10]
Received Ack(SeqNo=1)        
Received Ack(SeqNo=9)        
Received Ack(SeqNo=0)        
Received Ack(SeqNo=8)        
Received Ack(SeqNo=3)        
Received Ack(SeqNo=7)        
Received Ack(SeqNo=5)        
Received Ack(SeqNo=4)        
Received Ack(SeqNo=2)        
Received Ack(SeqNo=6)        
batch complete                               

In this case, the messages completed out of order - this because all of the Tasks started by the PipeToActor ran in parallel, as you can see on the Jaeger graph for this scenario.

PipeTo implementation of Akka.NET actors Click for a full-sized version of this image

This scenario took roughly 11ms to run - nearly 20 times faster than the await scenario.

Best Practices

So why the big difference in performance between await and PipeTo? Simply stated, it comes down to flow control: await blocks the flow of execution and PipeTo does not.

In my original guidance in “How to Do Asynchronous I/O with Akka.NET Actors Using PipeTo” I heavily emphasized PipeTo largely because of my own recent experience building heavily loaded real-time analytics and marketing automation systems with Akka.NET - we used PipeTo quite heavily there prior to await being fully-supported in Akka.NET (back in 2014.) That left users under the impression that await isn’t properly supported in Akka.NET.

await has had first class support since mid-2015 and that’s not expected to change any time soon. Plus, over the years of working with hundreds of Akka.NET users my opinions on what should be used when have evolved quite a bit.

I’ll give you the short version:

You Should Use await When

  1. You have a sequence of Tasks that need to be completed in a specific order; i.e. fetch data from web service –> persist to database; it’s much easier to reason about this with await than with multiple Receive handlers and PipeTo.
  2. You need non-trivial error handling around async operations; again, await makes this a lot easier than writing custom ContinueWith statements to do this along with PipeTo.
  3. Flow control and order of operations is, generally, more contextually important than throughput.

You Should Use PipeTo When

  1. Your Tasks are simple and ordering doesn’t matter;
  2. When you want to still receive user-defined messages mid-Task; this one often gets overlooked. How can you stop a long-running operation running in another actor if it can’t process any messages? Either you have to Context.Stop that actor (sends a system message, which can still be processed) or hold onto a shared CancellationTokenSource reference between actors - which breaks encapsulation.

A Note on Performance

As you can see from the Phobos graphs I included above, the PipeTo sample is significantly faster than the await sample - but this is only in the context of judging the throughput of a single actor. If you wanted to achieve similar throughput numbers with await the answer is simple - scale out and run more actors in parallel!

At the end of the day, the decision around await vs. PipeTo really comes down to flow control trade-offs. await is generally a better solution for managing more complex flows, whereas PipeTo is a more responsive solution that works best on relatively simple solutions.

I use a combination of both in my own code - and we do the same inside Akka.NET’s internals itself. Your mileage may vary!

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

 

 

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.