Akka

Akka is een toolkit die het eenvoudiger maakt om schaalbare applicaties te bouwen. De Actor is één van de belangrijkste ‘tools’ in deze toolkit. In dit artikel beschrijven we het Actor Model, wat een Akka Actor is en hoe het gebruik van Actors kan bijdragen tot het bouwen van zeer schaalbare applicaties.

Akka is een open-source project gebouwd door Typesafe; het bedrijf dat ook achter Scala en Play zit. Akka is onderdeel van het Typesafe reactive platform1. Akka is geschreven in Scala en heeft zowel een Scala als een Java API. Concurrency, schaalbaarheid, parallellisatie en gedistribueerde systemen zijn per definitie zeer complexe onderwerpen en het is een onmogelijke taak om aan al deze onderwerpen recht te doen in één artikel. Toch is het belangrijk om eerst wat theorie en definities te behandelen om de voor- en nadelen van Actors goed te kunnen belichten.

Schaalbaarheid
Schaalbaarheid is de mate waarin een systeem flexibel om kan gaan met een toename (of afname) van benodigde resources zonder dat de performance van het systeem wordt beïnvloed. In dit artikel kijken we naar schaalbaarheid van processen bij een toename van gelijktijdige gebruikers.

Scale Up is het opschalen van een systeem door de resources (bijvoorbeeld de CPU's) van één server te verbeteren, Scale Out is het opschalen van een systeem door meer servers toe te voegen. In beide gevallen is parallellisatie het uitgangspunt. De mate waarin processen gelijktijdig uitgevoerd kunnen worden, bepaalt de schaalbaarheid in grote mate. Het efficiënt gebruik van resources is een andere belangrijke factor. Het optimaal benutten van hardware resources is niet eenvoudig. Voor de omzetting van sequentiële code naar parallel uitgevoerde instructies komen we bij de volgende definitie; concurrency.

Concurrency
Waar het bij parallellisatie gaat om het gelijktijdig uitvoeren van processen, gaat het bij concurrency om het definiëren van processen die gelijktijdig of overlappend kunnen functioneren. Een concurrent systeem is dus niet per definitie parallel. Concurrent processen kunnen bijvoorbeeld gezamenlijk op één CPU-core draaien (d.m.v. time-slicing). Een concurrent programmeermodel drukt een systeem uit in communicerende processen.

Objecten, Threads & Shared State
Java heeft een standaard concurrent programmeermodel, waarbij processen uitgedrukt zijn in objects en methods, die uitgevoerd worden op threads. Objecten delen informatie via shared (mutable) state. Het programmeren van concurrent processen met threads en de java.util.concurrent package is erg low-level en complex. Daarnaast zijn threads, objecten en shared state alleen te gebruiken voor Scale Up en niet voor Scale Out.

Objecten: Scale out?
Er zijn genoeg libraries die toegang tot remote objecten geven, alsof de remote objecten lokaal aanwezig zijn en hun methoden gewoon gebruikt kunnen worden in een RPC (Remote Procedure Call) communicatiestijl. 'A note on distributed computing'2 argumenteert dat dit model niet te handhaven is voor grote gedistribueerde systemen. De verschillen zijn te groot om een gedistribueerd systeem te programmeren alsof het lokaal is. Het simpele model van objecten waaraan we zo gewend zijn is hiervoor niet geschikt. Het tegenovergestelde – lokale systemen bouwen met een gedistribueerd programmeermodel – is wel mogelijk. Het Actor Model is zo'n model.

Actor Model: 4 operations
In het Actor Model zijn actors de primaire bouwsteen. Een actor is een asynchroon lichtgewicht proces met vier operaties:

  • Send: Een actor communiceert alleen via berichten. Een actor ontvangt berichten, voert hierop logica uit en stuurt mogelijk nieuwe berichten naar andere actors. Berichten worden fire-and-forget verstuurd. Actors delen geen muteerbare state met elkaar. Het is niet mogelijk om interne actor state van buitenaf te muteren.
  • Create: Een actor kan actors creëren. Een actor systeem is een hiërarchie van actors.
  • Become: Een actor kan een andere functie aannemen door van functie te wisselen.
  • Supervise: Een actor kan andere actors monitoren en bepalen wat er met de actor moet gebeuren als deze faalt.

Alle actor operaties zijn asynchroon. Doordat niet gegarandeerd kan worden dat een actor binnen een bepaalde tijd berichten verwerkt of op dezelfde JVM aanwezig is, moet de actor gezien worden als een component dat mogelijk faalt of mogelijk op een andere server aanwezig is. Het Actor Model is dus vanuit beginsel een gedistribueerd programmeermodel, zelfs als je het alleen lokaal gebruikt. Dit zorgt voor extra complexiteit in vergelijking met standaard objecten. Omdat actors geen typechecked interface hebben, kan het soms lastig te volgen zijn hoe berichten van de ene naar de andere actor verstuurd worden. In vergelijking echter met met low-level threads is dit nog steeds veel eenvoudiger te overzien. Tijd om naar de Actor API te kijken die bij de 4 core operations horen.

1. Create
Actors zijn altijd onderdeel van een ActorSystem. Onderstaande Scala code laat zien hoe je een actor system maakt:


val system = ActorSystem(„MySystem")

 

De Java API is erg vergelijkbaar.


final ActorSystem system = ActorSystem.create(„MySystem");

Een actor system is de 'actor runtime'. Actors kunnen alleen gemaakt worden door een actor system of andere actors. Onderstaande Scala code laat zien hoe een Actor wordt gecreëerd door een actor system:


val actorRef = system.actorOf(Props[MyActor], "myactor")

Met de Java API ziet dit er zo uit:


final ActorRef myActor = system.actorOf(Props.create(MyActor.class), „myactor");

 

Het actor system geeft een referentie (ActorRef) terug naar de actor, dus niet de actor zelf. Actors die via een actor systeem gemaakt worden zijn top level actors.
Een Props object beschrijft hoe de actor gecreëerd moet worden, in dit geval met de default constructor van de MyActor class. Het is ook mogelijk om constructor parameters mee te geven. Een actor heeft ook altijd een naam. Deze naam wordt gebruikt om een pad naar de actor op te bouwen, een zogenaamde ActorPath. Een ActorPath is conceptueel vergelijkbaar met een pad dat naar een bestand verwijst.

Een actor kan op zijn beurt child actors creëeren. Elke Actor heeft een ActorContext, die onder andere dit mogelijk maakt. Onderstaande scala code laat zien hoe MyActor een child actor maakt:


val childRef = context.actorOf(Props[MyChild], „mychild")

2. Send & Receive
Je kan alleen via een ActorRef communiceren met een Actor. Een Actor kan niet met 'new' gecreëerd worden om te voorkomen dat de ActorRef omzeild wordt. Akka zorgt ervoor dat een bericht uiteindelijk bij de bijbehorende actor aankomt. Tijdelijk staan berichten intern in een mailbox, die door een message dispatcher, door de actor heen worden geduwd. De message dispatcher abstraheert het gebruik van threads en kan in allerlei manieren geconfigureerd worden. Een bericht sturen naar een actor ziet er als volgt uit in Scala:


myActor ! „Hey"

In Java ziet de API voor het versturen van een bericht er zo uit:


myActor.tell("Hey", null);

 

In dit geval is het bericht een simpele String. Berichten moeten immutable zijn om onbedoelde state sharing tegen te gaan. In Scala worden hier vaak case classes voor gebruikt. In Java is het noodzakelijk om een Immutable pattern te gebruiken. Akka geeft de garantie dat het verwerken van een bericht in een actor plaatsvindt voordat het volgende bericht in dezelfde actor verwerkt wordt. Voor zover hebben we nog niet gekeken naar de definitie van een Actor class. Onderstaande code laat een simpele actor zien, die alle ontvangen Strings logt.

 

 


class Logger extends Actor with ActorLogging {
def receive = {
case msg: String ⇒ log.info("received string {}.", msg)
}
}

De receive definieert welke berichten de Logger actor accepteert door middel van een Partial Function. Dit is een functie die alleen uitgevoerd wordt bij bepaalde input. In het bovenstaande geval accepteert de Actor alleen Strings en logt deze via de ActorLogging trait. Als een ander bericht naar de Logger wordt gestuurd dan wordt deze naar een speciale ActorRef gestuurd, de actor system deadletter. De receive maakt gebruik van Scala Pattern Matching. Java heeft geen pattern matching zoals Scala, maar er is nu wel een experimentele Java 8 Lambda support met een simpele matching API. De Logger actor ziet er dan als volgt uit:


public class LambdaLogger extends AbstractActor {
private final LoggingAdapter log = Logging.getLogger(context().system(), this);

public Logger() {
receive(ReceiveBuilder.
match(String.class, s -> {
log.info("Received String message: {}", s);
}).build()
);
}
}

De ReceiveBuilder maakt het mogelijk om vanuit Java een Partial Function samen te stellen. De Lambda versie van de API lijkt, zoals je kunt zien, meer op de Scala API. Een actor kan een bericht terug sturen naar de actor, die het bericht opgestuurd heeft. Elke actor heeft een ActorContext die per bericht de sender bevat van het bericht. Een simpele Echo Actor stuurt elk ontvangen bericht terug naar de verzender van het bericht:


class Echo extends Actor {
def receive = {
case msg ⇒ context.sender() ! msg
}
}

De sender() method geeft de ActorRef terug van de Actor, die het bericht verstuurd heeft. Deze ActorRef kan dan weer gebruikt worden om een bericht te versturen.

3. Become
Een actor kan een andere receive partial function registreren en bij volgende berichten dus een ander gedrag aannemen. Dit is de basis voor een simpele state machine. Onderstaand voorbeeld laat een actor zien die van gedrag kan veranderen:


 
class Forwarder(actorRef: ActorRef) extends Actor {
def receive = closed

def closed: Receive = {
case Open ⇒ become(open)
case msg ⇒ // discarding
}

def open: Receive = {
case Close ⇒ become(closed)
case msg ⇒ actorRef.forward(msg)
}
}

 

De bovenstaande Forwarder actor gooit in de Closed state alle berichten weg, totdat een Open bericht ontvangen wordt. In de Open state stuurt de actor alle berichten door naar een actorRef die via de constructor meegegeven is. Met forward wordt de verzender van het bericht ook doorgegeven, zodat de actorRef in bovenstaand voorbeeld berichten terug kan sturen naar de originele verzender van het bericht.

4. Supervise & watch
In plaats van elke exception af te vangen, bepaalt een actor een Supervision Strategy voor wat er moet gebeuren als een child actor crashed. De actor heeft 4 keuzes; Restart, Stop, Escalate en Resume. Bij een Restart wordt een nieuwe actor instance gemaakt vanuit de Props voor die specifieke actor. Actors die een ActorRef hebben naar de gecrashte actor merken niets van de crash, volgende berichten gaan automatisch naar de nieuwe actor instance. Daarnaast kan een actor andere actors monitoren (DeathWatch). Dit mechanisme werkt lokaal en remote exact hetzelfde. Een actor die context.watch(actorRef) gebruikt, wordt via een Terminated bericht genotificeerd als de actor gestopt is of niet meer bereikbaar is in het netwerk.

Gedistribueerde actors
Exact dezelfde code kan gebruikt worden om op een andere server een actor te maken, berichten naar de actor te sturen en de actor remote te monitoren. Remote lookup en deployment kan via de API of via configuratie. Akka gebruikt de Typesafe configuratie library3, die ook erg handig is voor custom configuratie van je eigen code. Onderstaande snippet laat zien hoe een actor geconfigureerd kan worden voor remote deployment:

 


actor {
provider = "akka.remote.RemoteActorRefProvider"

deployment {
/myactor {
remote = "akka.tcp://MySystem@10.0.0.5:2552"
}
}

De actor met het pad /myactor wordt in bovenstaand voorbeeld op een remote server opgestart. De remote module van Akka zorgt ervoor dat berichten die gestuurd worden naar de lokale ActorRef via het netwerk bij de remote actor aankomen. De Cluster module maakt het mogelijk om nog een stap verder te zetten. Met de cluster module kan gecommuniceerd worden met actors in een dynamisch cluster van servers, in plaats van een specifieke server.

Testen
Actors kunnen lokaal en gedistribueerd getest worden met de Akka-testkit en multi-node-testkit. De testkit heeft onder andere een testActor, die gebruikt kan worden als verzender en ontvanger van berichten. Deze berichten kunnen dan eenvoudig geïnspecteerd worden:


class EchoSpec extends TestKit(ActorSystem("testsystem"))
with WordSpec
with MustMatchers
with ImplicitSender
"Echo" should {
"Reply with the same message it receives" in {
val echo = system.actorOf(Props[Echo], "echo")
echo ! "some message"
expectMsg("some message")
}
}
}

De testActor in de TestKit wordt via de ImplicitSender trait automatisch de verzender van elk bericht in de test en expectMsg verifieert berichten die bij de testActor aankomen.

Conclusie
We hebben de tip van de 'actor ijsberg' misschien net aan kunnen raken in dit artikel. Schaalbaarheid valt en staat bij een goed concurrent programming model. Akka Actors maken het mogelijk om zowel Up en Out te schalen met één programmeermodel, waardoor schaalbare applicaties eenvoudiger te bouwen zijn.

 
 Referenties

http://typesafe.com/platform [Left arrow with hook] http://www.eecs.harvard.edu/~waldo/Readings/waldo-94.pdf (1994 Jim Waldo, Geoff Wyant, Ann Wollrath, and Sam Kendall) [Left arrow with hook] https://github.com/typesafehub/config [Left arrow with hook]