Kreisfluß – Rückgekoppelte Systeme mit Flow-Design

Möbius Band unter einer filigranen Konstruktion
Fita de Möbius – Copyright Flavio Serafini, published under CC here
Jede abgrenzbare Software ist ein technisches System. Wikipedia definiert ein technische System als Abbild (Modell) eines in der Regel komplexen technischen Produktes. Dabei werden die Wechselwirkungen zwischen den Komponenten des Systems und zwischen dem System und seiner Umgebung abgebildet und untersucht.

Auf Software bezogen erfolgt diese Wechselwirkung durch Informationsflüsse: Eine Eingabe wird vom Software-System in eine Ausgabe transformiert. Das lässt sich bei jedem simplen GUI-basierten Programm verfolgen, welches eine menschliche Eingabe durch Maus oder Tastatur in eine Ausgabe auf dem Bildschirm umsetzt. Dieses System folgt einer linearen Kausalkette: das Bewegen der Maus nach links, lässt den Mauszeiger auf dem Bildschirm nach links wandern; wir steuern den Mauszeiger.

Solche Systeme lassen sich auch als gesteuerte Systeme klassifizieren.

Eine besondere Klasse von gesteuerten Systemen sind solche, die keiner linearen Kausalkette unterliegen, sondern neben der Eingabe aus der Umwelt die eigenen Ausgaben als Eingaben verarbeiten. Das System steht sozusagen mit sich selbst in Wechselwirkung.


Regelsysteme

Solche Systeme bezeichnet man gewöhnlich als rückgekoppelte Systeme. Jedes Regelsystem ist ein rückgekoppeltes System. Wir kennen schon lange solche Regelsysteme in der Technik. Eines der bekanntesten ist der Fliehkraftregler mit dem automatisch die Drehzahl einer Maschinen geregelt wird.

Jeder kennt aus seinem Umfeld andere Regelsysteme, die meistens unbeachtet vor sich hin regeln – Thermostate zum Beispiel. Allen diesen Systemen ist gemeinsam, dass es sich um physikalische Regelsysteme handelt, keine informationellen – es findet keine Informationsverarbeitung statt. Diese Systeme regeln meistens genau eine physikalische Größe auf einen festen, meistens einstellbaren Wert.

Wenn die zu regelnde Größe abstrakter wird – soll zum Beispiel das Gleichgewicht oder ein Kurs gehalten werden –, dann kommen unweigerlich informationelle Regelsysteme ins Spiel, die meistens, aufgrund ihrer Komplexität, als Software-Systeme realisiert sind. Bei TED gab es vor kurzem ein beeindruckendes Video, das einen Quadrokopter zeigt, der einen Stab im Gleichgewicht hält – ein überzeugendes Beispiel für ein informationelles Regelsystem.

Interessant ist hier, dass wir Menschen solchen beweglichen autonomen Systeme unweigerlich eine Wesenhaftigkeit zusprechen, wir begreifen sie als etwas Lebendiges. So wird im oben genannten Video vom Zauber, der diese Maschinen zum Leben erweckt, gesprochen. Objektiv betrachtet ist das jedoch ein kognitiver Trugschluss – dadurch, dass uns gewöhnlich autonome System nur als Lebewesen wie Tiere oder Menschen über den Weg laufen, übertragen wir einfach deren Eigenschaften und Klassifizierungen auf sich ähnlich verhaltene Dinge.

Wer philosophisch tiefer in diese Materie eindringen will, dem sei die Kybernetik als Wissenschaft mit Ihrem Begründer Norbert Wiener und deren Diskussion aus dialektischer Sicht durch Georg Klaus nahegelegt. Die Kybernetik beschränkt sich dabei nicht nur auf autonome Systeme, sondern betrachtet auch Rückkopplungssysteme größeren Maßstabs, wie z.B. Ökosysteme.

Rückkopplung mit Flow-Design

Warum dieser Ausflug zu den rückkoppelnden Systemen? Weil ich denke, dass Flow-Design ein allgemeingültiges Mittel ist und sein sollte, um informationsverarbeitende Systeme zu modellieren. Das sollte für sich linear-kausal verhaltene Applikationen, um die es bei Flow-Design bis jetzt hauptsächlich ging, ebenso gelten, wie für Modelle der realen Welt, die rückkoppelnde Systeme wie den Stab-balancierenden Quadrocopter nachbilden.

Im Flow-Design wird Rückkopplung durch „zurück fließende“ Daten realisiert – der Ausfluss einer Funktionseinheit wird in eine Funktionseinheit eingeleitet, die dieser im Funktionsfluss vorangeht. Es ergibt sich ein Zyklus im Fluss.

Sieht man sich die verschiedenen technischen Flow-Design-Realisierungen an, so stellt man fest, dass nicht jede für die Modellierung rückkoppelnder Systeme geeignet ist; zumindest nicht, ohne sich etwas mehr Gedanken darüber zu machen. Als einfaches Beispiel sollen zwei Funktionseinheiten sich gegenseitig Integer-Werte zusenden, die sie vor Versendung inkrementieren. Dadurch entsteht eine Art Ping-Pong-Spiel, wobei die Anzahl der Schläge gezählt werden. Theoretisch läuft dieses Spiel ewig sobald es angestoßen wird, denn es gibt kein Abbruchkriterium.


Zirkulärer Fluss mit EBC

Der Code für eine entsprechende EBC-Funktionseinheit in .Net ist schnell geschrieben:

using System;
namespace de.grammmarcraft.ebc.recursion
{
  public class CyclicEBC
  {
    public void ping(int recursion) 
    {
      if (recursion % 1000 == 0)
        Console.WriteLine ("recursion: {0}", recursion);
        pong(recursion+1);
    }
	
    public event Action<int> pong;
		
  }
}

Die Verknüpfung der zwei instanziierten Funktionseinheiten ebenso:

public static void Main (string[] args)
{
  CyclicEBC ebc1 = new CyclicEBC();
  CyclicEBC ebc2 = new CyclicEBC();
	
  ebc1.pong += ebc2.ping;
  ebc2.pong += ebc1.ping;
  
  ebc1.ping (1);
}

Lässt man diesen Code laufen, so wird früher oder später unweigerlich eine StackOverflowException geworfen. Vergegenwärtigt man sich die Umsetzung von Events in .NET wird klar warum: Es ist eine komprimierte Notation für das Observer-Pattern und basiert auf Methodenaufrufen. Jede Versendung einer Nachricht von einer Funktionseinheit zur nächsten ist am Ende ein Methodenaufruf, der den Call-Stack wachsen lässt. Enthält ein Flow-Design einen Zyklus im Nachrichtenfluss, so kommt es in EBC-Implementierungen zu Pufferüberläufen, wenn dem in einer der am Zyklus beteiligten Funktionseinheiten nicht explizit durch asynchrone oder parallel Nachrichtenverarbeitung vorgebaut wird.

Dies zu erkennen ist regelmäßig Aufgabe des Architekten des Flow-Designs. Je komplexer die Systeme und je abstrakter auch die wiederverwendbaren Elemente werden, um so schwerer wird es dem Architekten fallen, Zyklen zu erkennen, insbesondere, wenn er externe Funktionseinheiten verwendet. Übersieht er den Zyklus im Nachrichtenfluss, wird die EBC-Implementierung nicht funktionieren.

EBC ist eben noch zu nah an der konkreten Implementierung in C# dran. Es ist schwer den Nachrichtenfluss über nachrichtenverarbeitende Funktionseinheiten von der konkreten Implementierung zu abstrahieren. Die Flow-Runtime NPantharei geht da einen ganzen Schritt weiter und stellt eine Notation zur Beschreibung des Flow-Designs bereit, in der die Funktionseinheiten paarweise miteinander verbunden werden und zu abstrakteren Funktionseinheiten zusammengefasst werden können. Die Flow-Runtime gewährleistet, dass eine Nachrichten, die eine Funktionseinheit verlässt, gemäß der durch die Notation festgelegten Verbindungen zu den nächsten Funktionseinheiten weitergeleitet wird.

Zirkulärer Fluss mit der Flow-Runtime

Wie geht die Flow-Runtime mit zirkulären Nachrichtenflüssen um? Ein minimales Modell mit zirkulärem Fluss wäre das folgende.

Dies entspricht in der NPantaRhei-Notation folgender Spezifikation:

  /
  .in, cyclic.in
  cyclic.out0, cyclic.in
  cyclic.out1, .stopped

Probeweise angewendet, ergibt dies nachfolgenden Testfall für die Flow-Runtime:

  [Test]
  public void test_cyclic_flow_with_sync_action_fails ()
  {
    Console.WriteLine(
      "test started for {0} flow cycles...", maxCycles);
    var config = new FlowRuntimeConfiguration()
         .AddStreamsFrom(@"
                           /
                           .in, cyclic.in
                           cyclic.out0, cyclic.in
                           cyclic.out1, .stopped
                          ")
         .AddAction<int, int, int>("cyclic", 
            (counter, continueCycling, stopOn) =>
            {
              if (counter < maxCycles)
              {
                Console.WriteLine("cycle {0}", counter);
                continueCycling(counter+1);
              }
              else {
                stopOn(maxCycles);
              }
            }
          );
    using(var fr = new FlowRuntime(config))
    {
      fr.UnhandledException += Console.WriteLine;
      fr.Process(".in", 1);
      Assert.IsFalse(fr.WaitForResult(5000), 
        "StackOverflow exception expected but not thrown");
    }
  }

Ab einer bestimmten Größe der Konstante maxCycle wird bei diesem Test jede CLR mit einer StackOverflowException beendet. Bei meinem mono-System passiert dies bei ca. 5000 Zyklen. In der .NET CLR scheint man diese Ausnahme noch programmatisch behandeln zu können; aber die Mono-CLR wird einfach klanglos beendet – es ist einfach kein Platz mehr auf dem Call-Stack.

Aber die NPantaRhei bietet Abhilfe! Dazu muss beim Registrieren die Aktion als asynchron deklariert werden (Testfall test_cyclic_flow_with_async_action):

.AddAction<int, int, int>("cyclic", 
  (counter, continueCycling, stopOn) =>
  {
    if (counter < maxCycles)
    {
      Console.WriteLine("cycle {0}", counter);
      continueCycling(counter+1);
    }
    else {
      stopOn(maxCycles);
    }
  }
).MakeAsync();

In diesem Fall wird die Aktion auf einem Hintergrund-Thread ausgeführt. Der Transport der Nachrichten vom fluss-verwaltenden Haupt-Thread der Runtime zum abarbeitenden Thread, der die Aktion ablaufen lässt, erfolgt über eine Nachrichten-Queue, wie sie von Aktor-Systemen bekannt ist. Dadurch werden Sender und Empfänger voneinander entkoppelt und es findet kein direkter Methodenaufruf und damit keine Methodenaufruf-Kaskade statt.

In gewissem Sinne ist die Flow-Runtime damit auch ein Aktor-System, zumindest kann es deklarativ zu einem solchen gemacht werden. Auch die dabei auftretenden Herausforderungen sind ähnliche, wenn man z.B. Ralf Westphals Analyse zum Verarbeitungsfortschritt von Nachrichten in der Flow-Runtime und das Konzeptpapier der Aktor-Implementierung von Scala vergleicht. Die Flow-Runtime NPantaRhei überlässt es dem Architekten des Flow-Designs, deklarativ zu bestimmen, wann ein neuer Thread erzeugt wird, um eine Nachricht abzuarbeiten. In Scalas Aktor-Implementierung erfolgt dies auf Basis einer Heuristik, wie auf Seite 13 des referenzierten Papiers nachzulesen ist. Ein Heuristik ist notwendig, da sich in einer JVM nicht bestimmen lässt, ob alle Threads blockiert sind und ein neuer Thread zu starten ist. Es wäre denkbar, dass dort aufgezeigte Konzept auf NPantaRhei zu übertragen. Vielleicht kommt man sogar ohne Heuristik aus, wenn man sich den Enumerierungstyp Thread.ThreadState des .NET-Frameworks so ansieht.

Zirkulärer Fluss mit ScalaFlow

Aber zurück zur JVM und Scala! Einen äquivalenten zirkulären Fluss wie oben für die Flow-Runtime NPantaRhei gezeigt, würde man in Flow-Design a lá Scala wie folgt implementieren:

  println("instantiate flow units...")
  val cyclic = new Cyclic
  
  println("bind them...")
  cyclic.continue -> cyclic.run
  cyclic.stopped -> (
    cyclicCounter => {
      println(cyclic + " stopped on " + cyclicCounter)
    }
  )
  
  println("run them...")
  cyclic.run(1)
  println("finished.")

Wobei die Funktionseinheit Cyclic mit einem eingehendem Pin run und zwei ausgehenden Pins continue und stopped definiert ist. Flow-Design in Scala erlaubt wie EBC die Benennung der Pins, weshalb sie von den in der Flow-Runtime generisch benannten Pins out1 und out2 abweichen.

final class Cyclic extends FunctionUnit("Cyclic") 
   with InputPort[Integer]
   with OutputPort1[Integer]
   with OutputPort2[Integer]
   with ErrorPort[String]
{
  // for meaningful names on binding to context and msg forwarding
  val run = input _ // run pin 

  val continue = output1 // continue pin
  val continueWith = forwardOutput1 _ // alias for implementation below

  val stopped = output2 // stopped pin
  val stopWith = forwardOutput2 _ // alias for implementation below

  // function unit semantic
  override protected def processInput(currentCycle: Integer) {
    try {
      if (currentCycle < 1000)
        continueWith(currentCycle + 1)
      else
        stopWith(currentCycle)
    } catch {
      case t: Throwable => forwardError(
          "error on Cyclic.processInput caught on cycle " + 
          currentCycle +
          " : " + t.toString())
    }
  }
}

Wie schon zu vermuten war, bricht auch diese Implementierung mit einem Pufferüberlauf ab:

instantiate flow units...
bind them...
run them...
error received: error on Cyclic.processInput caught on cycle 471 : java.lang.StackOverflowError
finished.

Kein Wunder, ist sie doch einer EBC-Implementierung äquivalent, denn ScalaFlow basiert wie EBC auf Methodenaufrufen zur Nachrichtenweiterleitung.

Zirkulärer Fluss mit Aktoren

Dass es in Scala prinzipiell auch anders geht, zeigt eine aktoren-basierte Implementierung eines zirkulären Flusses. Hierfür wird ein Aktor implementiert, der der obigen Semantik der ScalaFlow-Funktionseinheit sehr ähnlich sieht.

class CyclicActor(max: Integer) extends Actor {
  def act() {
    react {
      case (cycle: Integer, actor: Actor) =>
        if (cycle > max) 
     	  println("exit on " + max)
      	else {
      	  actor ! (cycle+1, this)
      	  act()
      	}
      case msg =>
      	println("Unhandled message: "+ msg)
      	act()
    }
  } 
}

Jeder Actor muss eine Methode act implementieren, die die funktionelle Semantik des Aktors enthält. Die Methode react entnimmt der Mailbox des Aktors die nächste Nachricht, und wendet auf diese die definierte Funktion an, die in den ihr nachfolgenden geschweiften Klammern spezifiziert ist. Typischerweise ist dies eine Fallunterscheidung über die möglichen Nachrichtentypen und Anweisungen zu deren Verarbeitung. Das Versenden einer Nachricht an einen anderen Aktor wird durch den Ausrufungszeichen-Operator „!“ angewiesen. Auch typisch ist der rekursive Aufruf der Funktion act am Ende von Kontrollflüssen, die keine Abbruchbedingung darstellen, um somit die Verarbeitung der nächsten Nachricht einzuleiten.

Da die Nachrichtenverarbeitung bei Scala-Aktoren Mailbox-basiert ist, findet, wie schon bei den asynchron deklarierten Aktionen der Flow-Runtime NPantaRhei, eine Entkoppelung der Methodenaufrufe zur Nachrichtenweiterleitung statt. Somit werden Call-Stack-Überläufe verhindert und die Nachrichten zirkulieren potentiell unendlich.

Fazit

Die obige Analyse möge nicht als generelle Kritik des Flow-Design-Ansatzes verstanden sein. Vielmehr geht es darum, die Möglichkeiten und Grenzen seiner verwendeten Werkzeuge zu kennen.

Wer zirkulären Abhängigkeiten im Nachrichtenfluss modelliert, muss sich dessen bewusst sein. Er muss Vorkehrungen treffen, um seine Architektur dafür zu rüsten. Die Flow-Runtime machte es einem einfach, dies deklarativ zu tun. Bei EBC und ScalaFlow muss man richtig Hand anlegen und Nebenläufigkeit implementieren, inklusive all ihrer Fallstricke.

Mit einer externen DSL, wie sie z.B. die Flow-Runtime NPantaRhei verwendet, ließen sich zirkuläre Nachrichtenflüsse auch automatisch erkennen. Aber wohin mit der Warnung? Dazu wäre ein Prüfinstanz wie ein Compiler notwendig, oder wenigstens ein entsprechende IDE. Deswegen denke ich, dass externe DSLs durchaus ihre Berechtigung haben, selbst bei solch kleinen Sprachen, wie der der Flow-Runtime. Es lassen sich eben Konsistenzprüfungen auf der Domäne des Sprachabstraktion implementieren. Damit kann dann dem Designer entsprechende Unterstützung gegeben werden – wer will schon erst zur Laufzeit seine Fehler finden?

Für ScalaFlow wäre es auch denkbar, dieses Problem generell über eine aktor-basierten Implementierung zu lösen. Mal sehen, wie dies zu bewerkstelligen wäre…
 

Leave a Reply

Your email address will not be published.