Tutorial: Einfache Simulation mit Akka und Actors

Akka ist ein schönes Toolkit, um hochparallele Anwendungen auf der Grundlage von Actors zu schreiben. Ich möchte hier ein paar Grundprinzipien vorstellen, um schnell in die Materie einsteigen zu können. Man kann Akka sowohl mit Scala als auch mit Java einsetzen, gewohnheitsmäßig habe ich mich für letzteres entschieden.

Den theoretischen Hintergrund liest man am besten hier nach, es gibt aber auch allgemeine und deutschsprachige Erläuterungen bei Wikipedia.

Schauen wir uns ein praktisches Beispiel an: Ich möchte eine kleine Simulation erstellen, in der ein Fuchs und mehrere Hasen unterwegs sind. Der Fuchs ist natürlich hungrig und frisst jeden Hasen, der ihm über den Weg läuft. Die einzige Chance der Hasen ist es, sich schnell genug zu reproduzieren, um nicht auszusterben.

Übersetzt in ein Actor Model bedeutet das: alle "Lebewesen" müssen als einzelne Actors parallel ausgeführt werden, während ein übergeordneter Simulations-Actor steuert und überwacht. Actors übergeben sich dabei unveränderliche (immutable) Nachrichten, greifen aber nicht auf innere Variablen oder Methoden anderer Actors zu.

Im Programm wird ein Simulations-Actor die nötigen Tiere erzeugen, sie in kurzen und regelmäßigen Intervallen zu Positionsänderungen veranlassen und dabei "Kollisionen" behandeln. Die ausgetauschten Nachrichten sind ausschließlich Objekte der nachfolgenden Position-Klasse und leere Strings.

Code

package de.jrthies.fuchsundhasen;
 
/**
* Position-Objekte werden als Actor-Nachrichten verwendet und müssen daher "immutable" sein.
*/
public final class Position {
 
    private final int x;
    private final int y;
 
    public Position(int x, int y) {
        this.x = x;
        this.y = y;
    }
 
    public int getX() {
        return x;
    }
 
    public int getY() {
        return y;
    }
 
    @Override
    public String toString() {
        return x + "/" + y;
    }
 
    @Override
    public int hashCode() {
        int hash = 5;
        hash = 61 * hash + this.x;
        hash = 61 * hash + this.y;
        return hash;
    }
 
    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final Position other = (Position) obj;
        if (this.x != other.x) {
            return false;
        }
        if (this.y != other.y) {
            return false;
        }
        return true;
    }
 
}

Objekte der Position-Klasse werden später auf Übereinstimmung geprüft, daher brauchen wir die Methoden equals und hashcode.

Kommen wir zum ersten Actor der kleinen Simulation. Da Fuchs und Hasen im Grunde genau das gleiche tun (nämlich herumlaufen), brauche ich nur eine allgemeine "Tier"-Klasse. Diese erweitert akka.actor.UntypedActor und muss eine Methode namens onReceive enthalten. Diese Methode reagiert auf Nachrichten, die von anderen Actors gesendet wurden. In diesem Fall können das entweder Position-Objekte zur Festlegung der aktuellen Position oder irgend ein anderes Objekt sein. Im zweiten Fall soll einfach eine zufällige Bewegung in eine der vier Hauptrichtungen stattfinden, anschließend teilt das Tier dem Absender seine neue Position mit.

Code

package de.jrthies.fuchsundhasen;
 
import akka.actor.UntypedActor;
import java.util.Random;
 
public class Tier extends UntypedActor {
 
    private Position position;
    private final Random r = new Random();
 
    /**
     * Man kann dem Tier eine Startposition zuweisen oder irgend ein anderes Objekt senden, damit das Tier sich bewegt und dem Absender
     * seine neue Position mitteilt.
     *
     * @param msg Positions-Objekt oder etwas anderes
     * @throws Exception
     */
    @Override
    public void onReceive(Object msg) throws Exception {
        if (msg instanceof Position) {
            this.position = (Position) msg;
        } else {
            move();
            getSender().tell(position, getSelf());
        }
    }
 
    /**
     * Zufällige Bewegung auf dem Spielfeld.
     */
    private void move() {
        switch (r.nextInt(4)) {
            case 0:
                this.position = new Position(
                        position.getX(),
                        position.getY() > 0 ? position.getY() - 1 : 0);
                break;
            case 1:
                this.position = new Position(
                        position.getX() < Simulation.FELDBREITE ? position.getX() + 1 : Simulation.FELDBREITE,
                        position.getY());
                break;
            case 2:
                this.position = new Position(
                        position.getX(),
                        position.getY() < Simulation.FELDBREITE ? position.getY() + 1 : Simulation.FELDBREITE);
                break;
            case 3:
                this.position = new Position(
                        position.getX() > 0 ? position.getX() - 1 : 0,
                        position.getY());
                break;
            default:
                break;
        }
    }
 
}

Die Konstante Simulation.FELDBREITE finden wir in der folgenden Klasse Simulation.

Code

package de.jrthies.fuchsundhasen;
 
import akka.actor.*;
import java.util.*;
import java.util.concurrent.*;
import scala.concurrent.duration.FiniteDuration;
 
/**
* Hier wird ein Spielfeld simuliert, auf dem sich ein Fuchs und mehrere Hasen bewegen. Der Fuchs frisst jeden Hasen, der sich auf der
* gleichen Position wie er selbst befindet. Treffen Hasen aufeinander, vermehren sie sich. Die Simulation endet, wenn keine Hasen mehr
* übrig sind.
*/
public class Simulation extends UntypedActor {
 
    public static final int FELDBREITE = 5;
    public static final String TRIGGER = "";
    private final FiniteDuration FUCHS_FREQ = new FiniteDuration(100, TimeUnit.MILLISECONDS);
    private final FiniteDuration HASE_FREQ = new FiniteDuration(200, TimeUnit.MILLISECONDS);
    private final FiniteDuration DELAY = new FiniteDuration(10, TimeUnit.MILLISECONDS);
    private final int MAX_ANZAHL_HASEN = 6;
 
    private final ActorSystem actorSystem = getContext().system();
    private final Random r = new Random();
 
    private final Map<ActorRef, Position> hasenPositionen = new ConcurrentHashMap<ActorRef, Position>();
    private Position fuchsPosition;
 
    /**
     * Beim Programmstart wird das ActorSystem mit der Simulation als Actor gestartet.
     *
     * @param args
     */
    public static void main(String[] args) {
        ActorSystem.create().actorOf(Props.create(Simulation.class));
    }
 
    /**
     * Im Konstruktor werden die beteiligten Tiere der Simulation erzeugt.
     */
    public Simulation() {
        //Der Fuchs beginnt in einer der Ecken des Spielfelds, die drei Hasen in den drei anderen Ecken.
        createFox(new Position(FELDBREITE, FELDBREITE));
        createRabbit(new Position(0, 0));
        createRabbit(new Position(FELDBREITE, 0));
        createRabbit(new Position(0, FELDBREITE));
    }
 
    /**
     * Die Simulation ist selbst ein Actor und empfängt permanent die neuen Positionsangaben aller Tiere. Die Hasen-Positionen werden
     * getrennt von der Fuchs-Position gespeichert.
     *
     * @param message Positions-Objekt
     * @throws Exception
     */
    @Override
    public void onReceive(Object message) throws Exception {
        if (message instanceof Position) {
            Position neuePosition = (Position) message;
            if (getSender().path().name().equals("Fuchs")) {
                fuchsPosition = neuePosition;
            } else {
                hasenPositionen.put(getSender(), neuePosition);
            }
            printPositions();
            checkCollisions();
        }
    }
 
    /**
     * Der Fuchs-Actor erhält einen Namen, und seine Position muss getrennt gespeichert werden. Durch den Namen kann verhindert werden, dass
     * mehrere Füchse gleichzeitig existieren.
     *
     * @param pos Position zum Start
     */
    private void createFox(Position pos) {
        ActorRef fuchs = actorSystem.actorOf(Props.create(Tier.class), "Fuchs");
        fuchs.tell(pos, null);
        fuchsPosition = pos;
        actorSystem.scheduler().schedule(DELAY, FUCHS_FREQ, fuchs, TRIGGER, actorSystem.dispatcher(), getSelf());
        System.out.println(fuchs.path().name() + " auf " + pos);
    }
 
    /**
     * Hasen-Actors werden ohne Namen erzeugt, es kann beliebig viele davon geben. Die Positionen aller Hasen werden in einer Map
     * gespeichert. Jeder Hase erhält über einen Zeitgeber in regelmäßigen Intervallen Trigger-Nachrichten, die Bewegungen auslösen.
     *
     * @param pos Position zum Start
     */
    private void createRabbit(Position pos) {
        ActorRef hase = actorSystem.actorOf(Props.create(Tier.class));
        hase.tell(pos, getSelf());
        hasenPositionen.put(hase, pos);
        actorSystem.scheduler().schedule(DELAY, HASE_FREQ, hase, TRIGGER, actorSystem.dispatcher(), getSelf());
        System.out.println("Neuer Hase " + hase.path().name() + " auf " + pos);
    }
 
    /**
     * Ausgabe der Positionen von Fuchs und allen Hasen.
     */
    private void printPositions() {
        StringBuilder poslist = new StringBuilder();
        for (Position position : hasenPositionen.values()) {
            poslist.append(position.toString()).append("  ");
        }
        System.out.println("Fuchs auf Position: " + fuchsPosition + ", Hasen auf Positionen: " + poslist.toString());
    }
 
    /**
     * Positionsvergleich zwischen Fuchs und sämtlichen Hasen. Das Programm endet, wenn keine Hasen mehr übrig sind.
     */
    private void checkCollisions() {
        Set<ActorRef> actorRefs = hasenPositionen.keySet();
        for (ActorRef rabbitRef : actorRefs) {
            if (hasenPositionen.get(rabbitRef).equals(fuchsPosition)) {
                System.out.println("Hase gefressen!");
                rabbitRef.tell(PoisonPill.getInstance(), getSelf());
                hasenPositionen.remove(rabbitRef);
            }
        }
        if (hasenPositionen.isEmpty()) {
            System.out.println("Keine mehr da!");
            actorSystem.shutdown();
        } else {
            //Wenn die Anzahl der unterschiedlichen Hasen-Positionen kleiner ist als die Anzahl der Hasen,
            //hat eine Hasen-Kollision stattgefunden.
            Set<Position> verschiedenePositionen = new HashSet<Position>();
            verschiedenePositionen.addAll(hasenPositionen.values());
            int anzahlHasen = hasenPositionen.size();
            if (verschiedenePositionen.size() < anzahlHasen &&amp, anzahlHasen < MAX_ANZAHL_HASEN) {
                //Treffen sich Hasen, vermehren sie sich.
                createRabbit(new Position(r.nextInt(FELDBREITE), r.nextInt(FELDBREITE)));
            }
        }
    }
 
}

Die main-Methode startet das Programm und mit ihm den Simulation-Actor. Solange dieser sich nicht explizit selbst beendet, wird das Programm weiterlaufen.

Die Simulation läuft auf einem quadratischen Spielfeld. Der Fuchs beginnt in einer Ecke, drei Hasen in den drei anderen Ecken. Fuchs und Hasen haben verschiedene Aktualisierungs-Intervalle. Bei der Erzeugung der Actors werden diese Intervalle über einen Scheduler vorgegeben.
Immer, wenn eines der Tiere sich bewegt, meldet es seine Position an den Simulator-Actor zurück. Dieser gibt dann die aktuellen Positionen aus und prüft mögliche Kollisionen.
Nimmt der Fuchs die gleiche Position ein wie ein Hase, wird dieser aus der Simulation entfernt. Dazu haben sich die Akka-Entwickler eine "PoisonPill" ausgedacht.
Treffen Hasen aufeinander, entsteht spontan und an einer zufälligen Stelle ein neuer Hase. Damit das nicht exponentiell ansteigt, habe ich eine Obergrenze für die Zahl der Hasen gesetzt.

Mit den hier gewählten Einstellungen dauert die Simulation eine ganze Weile, aber der Fuchs dürfte irgendwann alleine übrig bleiben.

Mehr zum Thema z.B. hier:
Actors in Java: Introduction

Noch kein Feedback