Why IActorRef.Tell Doesn't Return a Task

Nuances of actor-based programming in .NET.

A really, really common question Akka.NET beginners have within the first couple of hours of looking at Akka.NET: “how is the IActorRef.Tell method asynchronous if it’s a void method? Shouldn’t it return a Task I can await on?”

Today I’m going to clarify Akka.NET’s API design and explain, in terms of Akka.NET’s behavior and benefits to users, why we don’t do this.

Even if you’re a seasoned Akka.NET user, you might find some value in this post.

What Happens When I Use IActorRef.Tell ?

Consider the following example:

ActorSystem system = ActorSystem.Create("MySys");
Props helloUntypedActorProps = Props.Create(() => new HelloUntypedActor());
IActorRef helloUntypedActor = system.ActorOf(helloUntypedActorProps, "hello-untyped-actor");

helloUntypedActor.Tell("process-me"); // asynchronously send this actor a message

What’s happening when we invoke IActorRef.Tell:

  • What happens to my message?
  • What happens before .Tell returns?
  • What happens after .Tell returns? This is the key question.

As we explain in “How Akka.NET Actors Process Messages” - the IActorRef.Tell method is asynchronous because all it does is deposit the message in the actor’s mailbox; the actor will be scheduled to process messages in its mailbox (if it’s not already scheduled) too, but the processing may not happen until nanoseconds later. The difference between “now” and “later” is what makes this method asynchronous.

Actors use this mode of message processing in order to do 4 things:

  1. Reduce context-switching for better performance - busy actors are scheduled to process messages in batches, using the same principles that .NET runtime constructs like the ThreadPool and TaskScheduler leverage process bursts of related tasks all on the same thread, significantly improving performance. By making Tell asynchronous, this gives really busy actors an opportunity to accumulate multiple messages and process them all on the same thread.
  2. Simplify deferred message processing - sometimes an actor might receive a message that it can’t process right now; i.e. if I’m waiting to recover my state from the database, I can’t process your message until my recovery completes - so I’ll stash your message and then process it later. This is really common message-processing scenario, and we don’t want to get into the habit of blocking processes that message actors while the receiver actor sorts this out. So all the sender needs to know is whether the message made it safely into the actor’s mailbox, not whether or not the actor can process it right now. Make this detail the receiver’s problem, not the sender’s.
  3. Allow actor crashes and restarts to be transient - we cover the details of this in “What Happens When Akka.NET Actors Restart,” but actors never lose messages when they restart and the actor’s IActorRef remains valid and able to receive messages before / during / after the restart. This is really important for making fault tolerance simple for the senders - because of this design they don’t have to know or pay attention to whether or not the actor they’re messaging is restarting or restarts after they sent the message.

That’s why the .Tell method works asynchronously and some examples of how that works to the end-user’s benefit.

Why Not Return a Task?

Now for an API design question: why not return a Task?

There’s two things a Task could mean in the context of “sending an actor a message:”

  1. The actor has fully processed the message we’ve sent it - that would transform every Tell operation into an Ask<T> operation, which would make Akka.NET behave more like Microsoft Orleans’ RPC-centric design rather than Akka.NET’s message-centric design.
  2. The actor has accepted a message for processing in its mailbox, which is what the completion of the IActorRef.Tell call means today.

Tell is a Better Default than Ask<T>

Bartosz Sypytkowski explains this brilliantly in his seminal blog post on the subject, “Don’t Ask, Tell

As emphasized earlier, one of the actor’s attributes is synchronous message processing. It’s a great feature, since we no longer need to worry about things like race conditions, locks or blocking… except that request/response is inherently blocking communication pattern. To be clear, I’m not talking here about blocking the thread, as we’re able to define async receive methods for actors. What I’m talking about, is blocking an actor itself, which cannot pick any new messages until the response arrives and processing finishes.

Task is a blocking-by-design asynchronous flow control pattern, designed to prevent procedural programs from moving onto the next activity until the prior ones complete.

Actors are built to be non-blocking - you can fire off a bunch of messages to other actors and go do your own thing without waiting for them. If you do need to wait for them, you can use behavior-switching, Ask<T>, or await inside an actor to accomplish that.

Ask<T> is how we can return a Task<T> that completes when an actor has successfully processed a message and sent a reply back - and that’s a great tool integrating Akka.NET actors with things like ASP.NET Core Controllerinstances, because the latter already operates on a Task-based execution model.

We don’t want to make Ask<T> the default way everyone interacts with actors because this would proliferate blocking everywhere, thus creating the potential for deadlocks (groups of actors all waiting on each other to respond first) - which is a common frustration that Orleans users encounter but a relatively rare one in Akka.NET.

The second reason we don’t want to make Ask<T> the default is that it’s request-response extremely slow compared to Tell’s fire-and-forget model - on the order of 500k msg/s vs. 7-8 million msg/s.

The third and final reason we don’t want to make Ask<T> the default is that message-based programming is inherently more flexible and exposes a richer range of options than procedural programming / RPC does - if you want to send one message and then get a stream of messages back, that doesn’t require any special planning or API design with IActorRef.Tell - you simply just “do” that. In a Task-based design this requires a tremendous amount of compiler and API ceremony.

If you simply must have Task<T> returned every time you message an actor in Akka.NET, even though it’s a bad idea we’ve been discouraging for 10 years, the Ask<T> pattern is right there and available in every version.

Not to go far in the other direction - “never use Ask<T>!” - that’s not what we mean either. We are simply saying “it shouldn’t be the default.”

Why Not Return a Task to be More “Idiomatic” .NET?

We’ve addressed why Tell returns once the message is deposited in the mailbox versus returning once the message is processed (which is what Ask<T> does) - now a question we get from time to time: “why not just return a Task anyway just so this ‘feels’ idiomatic to .NET developers?”

The answer to this question is simple and should suffice on its own: because it doesn’t need to.

“Please add more moving parts, state, and overhead to the hottest path to Akka.NET so it ‘feels’ familiar” is not a persuasive or sound argument.

Even if we use transformed Tell to return a ValueTask (stack-based Task,) doing this would:

  1. Significantly degrade the performance of Akka.NET;
  2. Encourage blocking where it demonstrably doesn’t need to exist currently; and
  3. Dramatically increase the complexity of all flow control code that ever touches Akka.NET everywhere.

If you’d like to learn more about Akka.NET’s design and its fundamentals, please subscribe to @Petabridge on YouTube.

If you liked this post, you can share it with your followers or follow us on Twitter!
Written by Aaron Stannard on December 8, 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.