1. Overview

The Actor Model is a very promising concurrent programming model. One of its most successful implementations is Akka, which is the reference implementation on the JVM.

In this tutorial, we’ll discuss the actor model’s main features and how Akka implements them in its last version, the Akka Typed library.

2. Scenario

To better understand the actor model, we’re going to use a concrete example throughout this article. We are going to define a stock portfolio that allows us to buy and sell stocks.

A Stock represents the owned quantity of a stock:

case class Stock(name: String, owned: Long) {
  def buy(qty: Long): Stock = copy(name, owned + qty)
  def sell(qty: Long): Stock =
    if (qty <= owned)
      copy(name, owned - qty)
    else
      this
}

The Portfolio class is a wrapper around a Map of stocks:

case class Portfolio(stocks: Map[String, Stock]) {
  def buy(name: String, qty: Long): Portfolio = {
    // Code that adds stocks to the portfolio
  }
  def sell(name: String, qty: Long): Portfolio = {
    // Code that sells stocks from the portfolio
  }
}

The client of the portfolio could be, for example, some bots that analyze information coming from social networks and decide which operation to perform on the owned stocks.

3. Application Setup

First, we’re going to set up the required project dependencies. Here we use SBT. To use the Akka Typed library we need to import the dependency from the akka-actor-typed artifact:

libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % "2.8.0"

Whereas, to test Akka Typed actors we need to import the dependency from the akka-actor-testkit-typed artifact:

libraryDependencies += "com.typesafe.akka" %% "akka-actor-testkit-typed" % "2.8.0" % Test

4. The Main Characters of This Story: Actors

The actor model focuses on the concept of an actor, which represents a separate unit of computation in a system that interacts with the other actors using a built-in messaging mechanism.

We can describe actors as a form of reactive objects because they perform computation in response to messages.

An actor represents a separate unit from other actors. They do not share any state with each other. The only way for an actor to communicate with other actors in the system is to send them some messages and eventually to wait for responses.

Inside an actor system, an actor can only perform the following actions:

  • Send a communication to itself or other actors
  • Create new actors
  • Specify a replacement behavior

As we can see, the actor model is very easy.

The library Akka Typed defines an actor as a factory of its behaviors.

There are two different approaches to define an actor. We can use the object-oriented approach or the functional approach. In this article, we are going to use the latter.

First of all, we need a bank that manages the stock portfolios associated with its clients:

object Bank {
  final case class CreatePortfolio(client: ActorRef[PortfolioCreated])
  final case class PortfolioCreated(portfolio: ActorRef[PortfolioCommand])

  def apply(): Behavior[CreatePortfolio] =
    Behaviors.receive { (context, message) =>
      val replyTo = context.spawn(PortfolioActor(), UUID.randomUUID().toString)
      message.client ! PortfolioCreated(replyTo)
      Behaviors.same
    }
}

The object Bank represents our first typed actor.

The apply method allows us to create the business logic associated with the actor, which the actor model calls behavior.

The behavior of an actor defines the set of messages an actor can handle and react to. In this case, the Bank actor listens to messages of type CreatePortfolio. Such messages allow the actor to create new portfolios and give them back to the clients that requested them.

The Behaviors type is a factory for creating behaviors.

Every actor executes concurrently from other actors. However, no one can access its internal state. Because of state contention, no race condition can arise during an actor execution.

Every actor has an associated mailbox. The mailbox is where the messages are enqueued before being processed. Pay attention that the default mailbox associated with every actor is unbounded. For more information on Akka Mailboxes, please refer to the Akka documentation.

5. How Actors Communicate: Messages

Messages are the only way to ask an actor to execute some business logic on data. Every actor defines its protocol, that’s the set of messages that it can react to.

Since there’s no need for a message to have a mutable state, it’s best to implement it as an immutable class*.* If the protocol of an actor ranges over more than one message, it’s a good idea to provide a base sealed trait.

Our Bank actor’s protocol consists of two messages, one request, and one response:

final case class CreatePortfolio(client: ActorRef[PortfolioCreated])
final case class PortfolioCreated(portfolio: ActorRef[PortfolioCommand])

The type of messages an actor can handle is enforced by the definition of its Behavior:

def apply(): Behavior[CreatePortfolio] = 
  Behaviors.receive { (context, message) =>

To interact with an actor, we need to have a reference to it. The type used by the library to share actors’ references is ActorRef[-T]. The type variable T defines the messages the actor can handle.

If we want to implement a request-response communication model between two actors, we need to provide within the message the actor’s reference to reply to.

In our example, the Bank must respond to the client that asks to create a new portfolio. Indeed, the CreatePortfolio message contains the reference to the caller.

Every actor has access to its own actor reference through the context.self object. For example, a client of the bank sends itself within the message that asks to create a new portfolio instance:

Behaviors.receive { (context, message) =>
  bank ! CreatePortfolio(context.self) 
  // More behavior logic
}

There are many possible interaction models between actors. The two that are more well known are the tell pattern and the ask pattern.

5.1. The Tell Pattern

It’s often said that we must tell an actor and not ask something. The reason for this is that the tell pattern represents the fully asynchronous way for two actors to communicate. Once we have an actor reference, we can use the “*!”* operator to send a message asynchronously.

In our example, the Bank, once it has created a new portfolio, returns it to the client that requested it:

val replyTo = context.spawn(PortfolioActor(), UUID.randomUUID().toString)
message.client ! PortfolioCreated(replyTo)

The tell pattern is entirely asynchronous. After the message is sent, it’s impossible to know if the message was received or if the process succeeded or failed.

5.2. The Ask Pattern

The ask pattern implements a more natural way to use the request-response communication model. We can handle it when we have a 1:1 relation between the request and the response.

It implements a style of programming that uses callbacks to handle asynchronicity. We can implement the client of the bank using the ask pattern:

object BankClient {
  def apply(bank: ActorRef[CreatePortfolio]): Behavior[Unit] =
    Behaviors.setup { context =>
      implicit val timeout: Timeout = 3.seconds
      context.ask(bank, CreatePortfolio) {
        case Success(message) =>
          context.log.info("Portfolio received")
          message.portfolio ! Buy("APPL", 100L)
        case Failure(_) => context.log.info("Portfolio received")
      }
      Behaviors.ignore[Unit]
    }
}

The use of the ask pattern requires some steps. First, we need a timeout. Since we are waiting for a response, we cannot wait for it forever; otherwise, it will block the actor execution.

To send the message we use the context object, calling the ask method.

The first parameter for ask is the ActorRef to the actor we want to send the message.

The second parameter is a function of the type ActorRef => Message. Since the case class CreatePortfolio has an apply function, we’ll use that. It’s the same as passing the function ref => CreatePortfolio(ref). The ref function parameter represents the actor that will handle the response.

Finally, we provide a partial function that handles the success or failure of the request. The return type of this function must be equal to the type of Behavior the client can handle.

6. Spread the Message: Actor Creation

As we have seen in many examples above, Akka Typed uses method factories to create actors’ instances. Those factories are listed in the Behaviors type. Every factory returns an object of type Behavior[-T], which defines precisely what kind of messages an actor handles.

The most common factory method is Behaviors.receive:

Behaviors.receive { (context, message) =>
  val replyTo = context.spawn(PortfolioActor(), UUID.randomUUID().toString)
  message.client ! PortfolioCreated(replyTo)
  Behaviors.same
}

The method takes as input a function that allows the actor access to the context and to the received message. The context object lets the actor access the actor system utilities.

The function must return a Behavior because, as we said, actors can change it after the execution of the business logic associated with a message. If there is no need to change the actual behavior, we can use the factory method Behaviors.same.

If we don’t need any context object, we can use the Behaviors.receiveMessage method. For example, the handling of the messages by a Portfolio actor doesn’t need access to the context:

Behaviors.receiveMessage { message =>
  message match {
    case Buy(stock, qty) =>
      portfolio(stocks.buy(stock, qty))
    case Sell(stock, qty) =>
      portfolio(stocks.sell(stock, qty))
  }
}

Sometimes, we need not just to react to external messages, but we need an actor to perform some actions during the start-up. The factory method Behaviors.setup implements this scenario.

For example, every time a new client kicks into the application, it needs to tell the bank to create a new portfolio.

object BankClientUsingTheTellPattern {
  def apply(bank: ActorRef[CreatePortfolio]): Behavior[PortfolioCreated] =
    Behaviors.setup { context =>
      bank ! CreatePortfolio(context.self)

      Behaviors.receiveMessage {
        case PortfolioCreated(portfolio) =>
          // Do something with the new portfolio
      }
    }
}

The Behaviors.setup factory method allows the client to send the bank the proper message without the need to react to some message coming from the outside.

7. Actor’s Behavior and How to Change It

Why is the library called Akka Typed? Well, because the Behavior[-T] generic type defines the messages an actor can receive using the T type variable, forcing the compiler to check the correctness of a message sending.

In our main example, we define an actor for each portfolio created in the application. The protocol used by the actor consists of two messages, Buy and Sell. Each message extends the trait PortfolioCommand.

sealed trait PortfolioCommand 
final case class Buy(stock: String, quantity: Long) extends PortfolioCommand 
final case class Sell(stock: String, quantity: Long) extends PortfolioCommand

The definition of the actor using a Behavior[PortfolioCommand] forces the compiler to check if the actor handles the right messages that extend the base trait.

Behaviors.receiveMessage { 
  case Buy(stock, qty) => 
    // Buy some stocks
  case Sell(stock, qty) => 
    // Sell some stocks
}

Actors react to the arrival of new messages, executing some business logic, and selecting which behavior to use to respond to the next message. Using the factory methods contained in the Behaviors type, it’s possible to change the behavior of an actor after the process of each message.

We also said that actors do not share any state. However, it’s very likely that we need to maintain some state during the execution of an actor. How can we relate the change of behavior and the change of the state of an actor?

The answer to the question is straightforward: The state is managed by changing behavior rather than using any variables. In our main example, we define an actor for each portfolio created in the application:

object PortfolioActor {
  
  def apply(): Behavior[PortfolioCommand] = {
    portfolio(Portfolio(Map.empty))
  }

  private def portfolio(stocks: Portfolio): Behavior[PortfolioCommand] = {
    Behaviors.receiveMessage {
      case Buy(stock, qty) =>
        portfolio(stocks.buy(stock, qty))
      case Sell(stock, qty) =>
        portfolio(stocks.sell(stock, qty))
    }
  }
}

Each PortfolioActor must maintain a reference to a Portfolio object. On this object, the actor calls the methods that implement the business logic to buy and sell some stocks.

Instead of mutating a local variable, we pass the current Portfolio instance to the method that defines the behavior. This approach completely eliminates the use of mutable variables.

Pure, immutable typed actors! That’s great!

8. The Actor System: Where All Begins

The structure allowing actors to execute and to send messages is called the ActorSystem. It is a heavyweight object that allocates the threads needed to execute the actors. Typically, we have one ActorSystem per JVM process.

Actors form a hierarchy. When an actor spawns another actor, they are said to be the parent and the child respectively. The lifecycle of a child actor is tied to its parent. An actor can stop itself or it can be stopped by its parent.

The BankMain actor creates the Bank and the BankClientUsingTheTellPattern, becoming their parent:

object BankMain {
  final case class Start(clientName: String)

  def apply(): Behavior[Start] =
    Behaviors.setup { context =>
      context.log.info("Creation of the bank")
      val bank = context.spawn(Bank(), "bank")
      Behaviors.receiveMessage { message =>
        context.log.info("Start a new client")
        context.spawn(BankClientUsingTheTellPattern(bank), message.clientName)
        Behaviors.same
      }
    }
}

There is always a root Actor at the top of the hierarchy, also called the guardian actor. It’s the actor that kicks in first by the actor system.

In our example, BankMain is the guardian actor.

def main(args: Array[String]): Unit = {
  val system: ActorSystem[BankMain.Start] = ActorSystem(BankMain(), "main")
  system ! Start("Alice")
  system ! Start("Bob ")
}

The guardian actor handles all the messages sent to the actor system. When the guardian actor stops, it will stop also the whole actor system.

An actor can stop itself returning the Behavior.stopped as the next behavior. Moreover, parent actors can stop their children actors calling the method context.stop and passing the proper child actor reference.

The actor system is also responsible for resolving the actors’ references, routing all the messages sent both using the tell or the ask pattern to the receiver actors.

9. Conclusion

In this article, we introduced the main concepts of the actor model. We then focused on the Akka Typed implementation in detail. We saw what an actor is, what it can do, and how to implement it in Akka Typed.

Obviously, there are many other features, such as supervision, a more fine-grained treatment of actors’ lifecycles, and so on. Please check out the official documentation for more details.

As always code of this tutorial is available over on GitHub.


« 上一篇: Scala 中的类和对象
» 下一篇: Scala 运算符简介