-
Notifications
You must be signed in to change notification settings - Fork 7
Actors
Actors are similar to objects on the OO world, but with one notable difference. A traditional object is completely passive; it is characterised by state and behaviour, but is acted upon by an external entity (code residing in another object) and from within the execution context (thread) of the caller. An actor, on the other hand, is able to enact its own behaviour using an execution context that is distinct from that of the caller's. An actor is invoked by passing it a message, which will be queued in the actor's mailbox and processed in the order they were enqueued. This leaves the caller free to continue processing, or to wait for the actor's response, if one is warranted.
Thus, an actor appears as somewhat of a cross between an object and a thread, as it possesses traits of both. Crucially, processing within an actor always takes place within a single thread of execution (although not necessarily the same thread each time), preserving the happens-before relationship with respect to each successive message processed. So one could also say that an actor is also a reminiscent of a synchronized block.
If the above doesn't make sense yet, don't worry. Just think of an actor as being like a person. It can do something when asked and it can remember things (being the actor's state), but it will only do one task at any given time even if several people ask it of favours - these just get banked up in a backlog until the actor gets around to them. When doing things, it can ask other people to help out, delegating all or a part of the task at hand. If we need lots of things done quickly, we can break up the work and hand it off to several similar actors.
Note: The human metaphor is quite important as it highlights the robustness of the actor model. When did you last see a deadlock or a race condition occur within a group of people?
Indigo provides two functionally equivalent but stylistically different means of defining actors. One approach is through classical interface inheritance, the other - using Java 8 lambdas.
The first approach is through interface inheritance, by implementing the com.obsidiandynamics.indigo.Actor interface. This interface (shown below) has one mandatory method act() that must be implemented.
@FunctionalInterface
public interface Actor {
default void activated(Activation a) {}
default void passivated(Activation a) {}
void act(Activation a, Message m);
}For the time, ignore the activated() and passivated() methods. We'll come to them later, when discussing activations and actor life-cycles.
For the remainder of this chapter we'll focus on a single contrived example - a simple concurrent adder. The adder keeps a running sum of all numbers given to it and provides a simple query mechanism for retrieving the sum. All of the code for this chapter is in the indigo/examples module, within the package com.obsidiandynamics.indigo.adder.
Note: This is among the most basic examples of asynchronous parallelism - collecting aggregate metrics across a number of process, such as the total number of requests processed by a server. Ideally, one would want to measure events by instrumenting the underlying processes in the least intrusive manner - without blocking these processes or otherwise imposing any sort of performance penalty.
The first step in defining an actor is to plan out its public API - the means by which other actors or external entities will issue commands and send queries to our actor. In the case of an adder the matter is simple; we need one command to add a number to the current sum and a query to retrieve the current sum. We'll define the public message contract separately to the actor, in a class called AdderContract.
Note: Defining a message contract separately to the actor implementation is akin to separating an interface from the implementation, and has a positive effect on coupling. While this is not strictly necessary, doing so will allow you to vary the actor's implementation without impacting the consumer, providing contract compatibility is retained.
/**
* The public message contract for the adder actor.
*/
public final class AdderContract {
public static final String ROLE = "adder";
/** This class is just a container for others; it doesn't require instantiation. */
private AdderContract() {}
/** Sent to the actor when adding a value. */
public static final class Add {
private int value;
public Add(int value) {
this.value = value;
}
int getValue() {
return value;
}
}
/** Sent to the actor when requesting the current sum. */
public static final class Get {}
/** Response sent by the actor containing the current sum. */
public static final class GetResponse {
private int sum;
GetResponse(int sum) {
this.sum = sum;
}
public int getSum() {
return sum;
}
}
}Let's digest the above, starting with the ROLE constant. Each actor must have a role, which is a free-form string. The string itself is unimportant, only as long as it's unique and the same role that is used to define the actor is also used when invoking it. Placing the role into the public contract is nothing but a useful means of canonicalising it.
Next, we'll need a message to add a number to the sum - AdderContract.Add - a simple POJO that encapsulates an addend, being the sole parameter to the sum function.
We'll need a way of getting the sum - AdderContract.Get - a blank POJO that signifies a parameterless query. The response will be carried in AdderContract.GetResponse - a POJO encapsulating a snapshot of the sum.
Note: Because the message contract isn't coupled to the actor's implementation, we'll reuse this contract later - when building actors with lambda functions.
With the contract 'formalities' out of the way, it's time to define our AdderActor.
public final class AdderActor implements Actor {
/** The current sum. */
private int sum;
@Override
public void act(Activation a, Message m) {
m.select()
.when(Add.class).then(b -> sum += b.getValue())
.when(Get.class).then(b -> a.reply(m).tell(new GetResponse(sum)))
.otherwise(a::messageFault);
}
}That's it. The entire actor implementation (sans the package declaration and imports) is less than a dozen lines. Let's dissect it, not that there is a lot to it.
An adder is stateful - it has to keep track of the current sum - hence the sum attribute.
The act() method is where the actor's behaviour is defined. It takes two parameters - an Activation object that links the actor with the rest of the actor system and a Message instance. A Message has a body, a sender and a recipient. Actors don't have multiple methods like ordinary classes; instead, they behave differently depending on the type of message passed to them. To be exact, when we say the 'type of message', we are actually referring to the type of the underlying body.
The easiest way to switch on the body type in Indigo is to use Message.select(). Use when(Class<B>) create a branch for a class type of interest and then(Consumer<B>) to handle a message body of the complying type. The when() method is like an if or an else if block, making the otherwise() method equivalent to an else.
So effectively, our actor is saying:
- If I get an
Addmessage, I'll take the body of the messagebof typeAddand increment the sum by the value given to me within thebobject; - If I get a
Getmessage, I'll respond to the originator with the current sum, packing it into aGetResponseobject; - Otherwise, if I don't understand the request, I'll raise a fault.
The last point, while not strictly mandatory, is a messaging best-practice. As message type safety isn't enforced by the compiler, there is no way of being certain that a message will get handled. If a strange message type was received, the actor should assume that this isn't the intended behaviour as actors shouldn't be throwing random messages at one another to see what sticks. So raising a fault is almost always the sensible thing to do.
Note: We didn't use the
volatilekeyword or anAtomicIntegerto model our state, or guard access to the state insynchronizedblock or aLockimplementation. In fact, there are no traces of the standard Java concurrency control primitives in our actor code. Yet it copes with multiple concurrent consumers and scales effortlessly.
Neither of the above approaches to defining actors superior to the other, but is probably more applicable depending on the use case. Consider using the Actor interface when -
- The actor definition is quite complex, such that it likely warrants its own class. And while one could still place pure functions into a dedicated class as static methods, and reference these from the lambda actor, a class with instance methods and attributes is arguably a more natural fit.
- The actor is subject to multiple reuse within, or export outside of the project where it was defined. Packaging the actor up as class, preferably with a factory method, is more convenient than sharing multiple functions.
- Inheritance may be a requirement, i.e. being able to define a base actor with one or more subclasses for more specialised use cases.
- Porting from a different actor framework, where the actor code has already been implemented in a similar way (e.g. Akka uses class inheritance).
- You are, for whatever reason, constrained from using lambdas in your project. However, bear in mind that the Indigo
ActivationAPI still heavily promotes the use of lambdas; using anonymous inner classes for things like callbacks would be a step backward.
Conversely, use lambdas when -
- Writing short and succinct actor implementations.
- Where it is useful to see multiple actor definitions together in the same class. This typically happens when two or more actors collectively solve a specific problem, such that the logic is split among each of the actors' definitions. Perhaps this used to be a larger actor that was subsequently partitioned to extract more fine-grained parallelism.
- When you require the power and flexibility of lambdas, such as functional composition, or in any other case where there may be a strong preference towards functional programming.
To touch on the last point, the lambda actor APIs don't preclude one from packaging the lambdas in a dedicated class, along with a state object (in the stateful scenario). If taking this path, it's best to include a factory method for assembling the actor, delegating to StatefulLambdaActor.Builder or StatelessLambdaActor.Builder as appropriate.
m.switchBody() .when(Add.class).then(b -> sum += b.getValue()) .when(Get.class).then(b -> a.reply(m).tell(new GetResponse(sum))) .elseFault();