Agile Modellierung mit
UML
Loading

8.4 Nebenläufigkeit mit Threads

Nebenläufigkeit tritt immer dann auf, wenn unabhängig voneinander agierende Systemteile gleichzeitig Aktivitäten durchführen können [Bro98]. Zum Beispiel können mehrere Threads innerhalb eines Systems verschiedene Aufgaben erledigen. Typischerweise werden externe Ereignisse, wie Nutzereingaben, TCP/IP-Kommunikation, Druck- und Ladevorgänge, in eigenständigen Threads behandelt. So findet die Bearbeitung von Events im AWT in einem eigenen Thread statt. Das erhöht zwar normalerweise die Gesamtgeschwindigkeit des Systems nicht, nutzt aber Wartezeiten, die durch die Umgebung verursacht werden, effizient aus und verbessert das Reaktionsverhalten der graphischen Nutzeroberfläche. Threads sind leichtgewichtige Prozesse, die alle im gleichen Speicherraum ablaufen. Demgegenüber gibt es schwergewichtige Prozesse, die vom Betriebssystem verwaltet werden und keinen gemeinsamen Speicher nutzen. Als drittes können Prozesse auf verschiedenen Prozessoren zum Beispiel über das Internet verteilt sein.

In allen diesen Fällen tritt Nebenläufigkeit auf, die zu nichtdeterministischen Ergebnissen und damit auch zu sporadischen Fehlern führen kann. Es ist deshalb für Konformitätstests wichtig, diese Form des Nichtdeterminismus zu unterbinden oder zumindest einzuschränken. Deshalb wird nachfolgend ein Konzept diskutiert, das es erlaubt, den durch Threads innerhalb eines Prozessraums auftretenden Nichtdeterminismus zu kontrollieren.

Das hinter diesem Ansatz zum Test verteilter Systeme stehende Prinzip lässt sich wie folgt charakterisieren. Anhand eines deterministischen Ablaufs wird zunächst die grundsätzliche Funktionalität geprüft und damit sichergestellt, dass die Methoden korrekt zusammenarbeiten können. Darin werden alle für den Test notwendigen Aktivitäten in eine deterministische und für den Test geeignete Reihenfolge gebracht. Dies ist zunächst eine Art Existenzbeweis, dass es korrekte Abläufe gibt. Dieser erlaubt natürlich keine allquantifizierte Aussage über alle Abläufe. Deshalb werden in weiteren Tests alternative Reihenfolgen geprüft. Durch dieses Interleaving auf Methodenebene entsteht weitere Sicherheit über die Korrektheit des nebenläufigen Zusammenspiels. Dass keine unerwünschten Interaktionen zwischen Threads auf gemeinsamen Daten auftreten, wird durch diese Tests nicht überprüft, sondern vor allem durch die adäquate Verwendung von Synchronisationsmechanismen sichergestellt. Diese in Java auch als „Threadsicherheit“ bekannte Technik wird zum Beispiel mit Reviews oder bereits bei der paarweisen Entwicklung gesichert. Durch die intensive Verwendung von Synchronisation ist in Hochsprachen wie Java das Problem der Nebenläufigkeit deutlich geringer als zum Beispiel in Hardware-nahen, eingebetteten Systemen, in denen Interleaving auf Maschineninstruktionsebene auftritt.

Der zum Beispiel in [LF02] vorgeschlagene Weg, nichtdeterministische Testfälle mehrfach laufen zu lassen um so möglicherweise verschiedene Varianten des Ablaufs zu testen, kann dazu als Ergänzung gesehen werden. Auch [Bin99] bietet zur Integration verteilter Systeme ein Testmuster an, das aber keine funktionale Simulation innerhalb eines Prozessraums beinhaltet und deshalb ebenfalls als Ergänzung zu sehen ist.

8.4.1 Eigenes Scheduling

Determinierte Testergebnisse lassen sich meist nur dadurch erreichen, dass die Nebenläufigkeit vollständig durch den Testtreiber kontrolliert wird. Dazu muss ein Testtreiber entweder in den Scheduler der Java Virtual Machine eingreifen können oder dessen Scheduling aufheben. Da sowohl der Produktionscode als auch der Testcode auf mehreren Plattformen oder zumindest mit verschiedenen Versionen von Java-Implementierungen funktionieren sollen, ist eine Adaption der Java Virtual Machine nur bedingt sinnvoll.

Eine relativ elegante und stabile Lösung ist es aber, das Scheduling in einfacher Form im Testtreiber zu modellieren. Da ein Testtreiber nur für einen Testlauf formuliert wird, ist er unter folgenden Annahmen relativ einfach zu beschreiben:

  1. Jeder Thread besteht aus einer oder mehreren regelmäßig wiederkehrenden Tätigkeiten, die jede für sich relativ wenig Zeit erfordert.
  2. Die parallele Ausführung einzelner Tätigkeiten hat keine Interferenzen, die für die Programmausführung erforderlich sind.4
  3. Threadsicherheit und Freiheit von Deadlocks werden mit anderen Test- und Inspektionsverfahren geprüft.

Ein Thread hat dann nach 1. in etwa die in Abbildung 8.18 dargestellte Form.

       Java   
 class OwnThread extends Thread {
  protected Workingclass client;
  public OwnThread(Workingclass client) {
    this.client = client;
    // Starten des Threads mit start() erfolgt nicht eigenständig
    // bereits bei dessen Erzeugung sondern danach durch den Erzeuger!
  }
  public void run() {
    // Ständige Wiederholung:
    while (true) {
      client.workingmethod(arguments);
      try {
        sleep(sleepPeriod);
      } catch (SomeException e) { }
    }
  }
}
Abbildung 8.18: Typisches Aussehen eines testbaren Threads

Auch die Verwendung anderer Mechanismen, wie etwa dem ab Java 1.3 zur Verfügung stehenden TimerTask und der damit verbundenen Möglichkeit für Methodenaufrufe ein explizites Scheduling festzulegen, verändert dieses Prinzip nicht. Im Gegenteil fördert die Verwendung dieses Mechanismus sogar die gewollte Trennung von Thread-Management und Applikationsfunktionalität. Falls notwendig muss der Produktionscode einem geeigneten Refactoring unterzogen werden, indem zum Beispiel die in der Methode run verborgene Funktionalität in eine eigene Methode ausgelagert wird. Ist die Berechnung einer Abbruchbedingung für die while-Schleife oder der sleepPeriod komplexer, so werden auch diese beiden Berechnungen in eigene Methoden ausgelagert. Dadurch werden diese einzelnen Funktionalitäten testbar, während die Grundfunktionalität des Thread einfach und damit überschaubar wird.

Auch wenn der eigentliche Thread in einer fremden Komponente oder im Framework verborgen ist und nur Callbacks, also Aufrufe aus dem Framework heraus auf den selbst entwickelten Code stattfinden, ist dieses Prinzip erreicht. Unter Umständen sind aber die dabei übergebenen Argumente in Adaptern zu verpacken, um keine Abhängigkeiten zwischen Applikationscode und Framework zu erhalten, die ein Einbetten in ein Testsystem verhindern. Dies ist anhand des Beispiels der Request-Klassen aus JSP bereits in Abschnitt 8.2 demonstriert worden.

Eine weitere Problemklasse besteht darin, dass es Threads gibt, die zwar in die in Abbildung 8.18 beschriebene Struktur gebracht werden können, die regelmäßig aufgerufene Methode aber selbst blockierende Aufrufe durchführen muss. Zum Beispiel ist die Kommunikation über Socket-Klassen in Java 1.3 dadurch geregelt, dass ankommende Daten mittels einem blockierenden read()-Aufruf abzuholen sind. Um diese Blockade zu umgehen, kann ein Socket-Dummy eingesetzt werden, das bereits einen Satz von Eingabedaten besitzt.

8.4.2 Sequenzdiagramm als Scheduling-Modell

Auf Basis der nach Abbildung 8.18 strukturierten Threads kann mit Sequenzdiagrammen ein Scheduling einfach modelliert werden. Abbildung 8.19 beschreibt einen Testtreiber, der mehrere Methoden ausführt, deren Aufrufe im Auktionssystem in verschiedenen Threads stattfinden.


Abbildung 8.19: Scheduling auf der Ebene einzelner Funktionen

Die drei Objekte der Klassen ClockDisplay, WebBidding und BiddingPanel sind drei unterschiedlichen Threads zugeordnet. Das erste ist für die Aktualisierung der Zeitanzeige im Applet verantwortlich, das zweite für die regelmäßige Nachfrage nach neuen Informationen zu den aktuell beobachteten Auktionen beim Server und das dritte ist eines von einer Reihe von Elementen der graphischen Oberfläche, das auf Aktionen des Nutzers wartet. Das Objekt :BiddingPanel wird also durch Callbacks aus dem AWT-Framework heraus angesprochen. Das Beispiel testet einen Großteil des Client-Systems, da es nicht nur den Applikationskern, sondern auch die Bearbeitung der graphischen Oberfläche und die Kommunikation mit dem Server einbezieht. Dadurch sind an mehreren Stellen Dummies notwendig, um die Effekte der benutzten Umgebung zu simulieren. Insbesondere ist die Verwendung von AWT-Klassen zur Übergabe von Events aus den in Abschnitt 8.2 beschriebenen Gründen kritisch. Deshalb empfiehlt es sich, eine Schicht tiefer zu testen oder eine Adapter-Schicht zwischen AWT und Applikationscode zu legen. Im Beispiel wird das Drücken der Return-Taste im Eingabefeld für Gebote als Gebotsabgabe interpretiert und führt zum Aufruf von bid(String eingabetext). Diese Methode kann auch direkt und damit unabhängig vom AWT-Framework aufgerufen werden. Abbildung 8.20 zeigt eines von mehreren im Auktionssystem genutzten Szenarien zum Test des Applikationskerns im Applet, wobei wieder auf die vollständige Definition der zugrunde liegenden Objektstruktur verzichtet wird.


Abbildung 8.20: Scheduling nur im Applikationskern

8.4.3 Behandlung von Threads

Die bisherige Modellierung von Nebenläufigkeit geht davon aus, dass die jeweiligen Threads bereits existieren und unabhängig voneinander agieren.

Es gibt jedoch mehrere Aspekte, bei denen sich Threads gegenseitig beeinflussen. Beispielsweise kann jederzeit ein neuer Thread erzeugt werden. Da das Scheduling der Threads komplett vom Testtreiber zu übernehmen ist, muss durch Anwendung einer Factory zur Erzeugung neuer Objekte und einem Dummy-Thread, der nicht zum echten Start eines Threads führt, die Erzeugung eines neuen Threads simuliert werden. Die Simulation einer erzwungenen Beendigung oder Unterbrechung eines anderen Threads ist dann jedoch unproblematisch. In einer Dummy-Klasse für Threads können daher Methoden wie start(), interrupt() oder destroy() durch leere Methoden ersetzt werden.

Wird die Methode join() verwendet, so ist allerdings das verwendete Schedulingverfahren zu erweitern, indem etwa durch das join() solange Methoden anderer Threads aufgerufen werden, bis die dortigen Threads als beendet gelten können. Da neben anderen Methoden auch die Methode join() in der Klasse Thread nicht redefiniert werden kann, ist in diesem Fall ein Adapter notwendig. Eine Klasse OwnThread kann entsprechend dem Muster in Abschnitt 8.2.4 gebildet werden. ThreadDummy ist davon die für den Test verwendete Unterklasse.


Abbildung 8.21: Thread-Dummy übernimmt Scheduling-Aufgaben

Mit einem geeigneten Codegenerator lässt sich auch aus dem Sequenzdiagramm in Abbildung 8.21 ein Testfall generieren, der die Funktionalität des Testtreibers teilweise in die Klasse ThreadDummy integriert.

Weitere Aufmerksamkeit ist für Methoden wie sleep() notwendig, die im Thread-Dummy höchstens die simulierte Zeit redefinieren, aber nicht zu tatsächlicher Verzögerung führen.

8.4.4 Muster für die Behandlung von Threads

Die Diskussion zur Behandlung von Threads lässt sich mit dem in Tabelle 8.22 dargestellten Muster zusammenfassen.



Muster: Behandlung von Threads


Intention

Threads können grundsätzlich nichtdeterministische Abläufe bedingen und sind deshalb für Tests geeignet anzupassen.


Motivation

Die Bearbeitung von Threads und damit von Nebenläufigkeit innerhalb eines Prozesses ist durch einen eigenen Scheduler zu simulieren, der zu determinierten Testergebnissen führt.


Anwendung

Eine Anwendung dieses Musters ist sinnvoll, wenn

  • mehrere Threads parallel innerhalb eines Prozesses laufen sollen,

  • Interaktionen zwischen den Threads bestehen, die getestet werden sollen, und

  • die Threads nach der in Abbildung 8.18 beschriebenen Struktur definiert sind oder entsprechend umgebaut werden können. Das beinhaltet, dass ein Thread regelmäßig wiederkehrende, relativ kurz dauernde Tätigkeiten durchführt.


Struktur

Das Muster besteht aus zwei Teilen. Ein ThreadDummy wird verwendet, wenn im Testling explizit Threads kontrolliert werden sollen. ThreadDummy ist die Unterklasse eines Adapters für die Klasse Thread. Im Produktionssystem wird der Adapter eingesetzt. Der Test von parallel ablaufenden Threads, von denen angenommen wird, dass sie bereits initialisiert sind, erfolgt durch einen für den Testablauf formulierten Scheduler. Dieser Scheduler ruft die einzelnen Tätigkeiten der verschiedenen Threads der Reihe nach auf und lässt jede dieser Tätigkeiten vollständig abarbeiten, bevor die nächste Tätigkeit ausgeführt wird. Es wird also eine Art kooperatives Multitasking umgesetzt.


Umsetzung

Ein solcher Scheduler kann durch ein Sequenzdiagramm wie dem in Abbildung 8.19 modelliert werden. Wird dieses Muster in Kombination mit der Simulation von Zeit aus Tabelle 8.17 verwendet, so kann das Merkmal {time} verwendet werden, um die voranschreitende Zeit zu modellieren.


Beachtenswert

Verwendet eine durch das Scheduling aufgerufene Methode spezielle Funktionen, wie yield(), sleep(), etc., so sind diese im ThreadDummy geeignet zu redefinieren.



Tabelle 8.22 : Muster: Behandlung von Threads

Dieses Muster beschreibt einen zu generierenden und einen strukturellen Anteil. Der letztere gehört zur Laufzeitumgebung der UML/P, die standardmäßig den Adapter OwnThread und die Unterklasse ThreadDummy zur Verfügung stellt, die wie beschrieben alle Methodenaufrufe ignoriert. Von dieser Klasse können eigene Unterklassen definiert werden, die zum Beispiel das in Abbildung 8.21 beschriebene Scheduling durchführen.

Ein für das Thread-Scheduling geeigneter Generator kann nicht nur Testtreiber aus Sequenzdiagrammen generieren, die das Scheduling übernehmen, sondern auch diese Unterklassen von ThreadDummy erzeugen.

8.4.5 Probleme der erzwungenen Sequentialisierung

Abschließend soll noch einmal daran erinnert werden, dass das hier beschriebene Verfahren zum Scheduling von Tests nicht die Nebenläufigkeit testet, sondern im Gegenteil diese explizit ausschließt, um Konformitätstests durchzuführen. Die Ergebnisse der Tests lassen sich daher nur begrenzt auf das echt nebenläufige Produktionssystem übertragen. Der im Test durchgeführte Ablauf entspricht nur einem von mehreren tatsächlich möglichen Abläufen. Wird allerdings, wie in Java-Codierungsstandards gefordert, jede kritische Methode synchronisiert, so kann zumindest feingranulare Nebenläufigkeit verhindert werden. Dadurch wird das System besser testbar und die Konformitätstests aussagekräftiger.

Das folgende Codestück gehört zu einem schlecht testbaren Programm, das „Race Conditions“ erlaubt:

       Java   
 class X {
  int a = 0;
  int loopa() {
    for(int i=0; i<100000; i++) a = (a+1) % 99;
  }
  int setAndReada() {
    a = 0;
    return a;
  }
}

Bei einem Scheduling, das wie vorgeschlagen, die Methoden in beliebiger Reihenfolge, aber nacheinander aufruft, wird die OCL-Bedingung

       OCL  
 context X.setAndReada()
pre:                true
post SetAndReadA:   result==0

in Tests immer erfolgreich geprüft werden, da setAndReada() unter Ausschluss paralleler Funktionen abläuft. Wenn im Produktionssystem allerdings echte Nebenläufigkeit auftritt, so ist das Ergebnis von setAndReada() nicht determiniert. Es gibt zwei Möglichkeiten dies zu adressieren: (1) Die Bedingung SetAndReadA ist zu eng definiert. Es ist stattdessen 0<=result && result<99 zu verwenden. (2) Die Methoden sind falsch implementiert. Sie sollten durch Synchronisation vor derartigen Überraschungen geschützt werden.

Eine Simulation der ungeschützten Funktionen mit dem vorgeschlagenen Verfahren macht eine Reorganisation der parallelen Funktionen notwendig, um ein feineres Scheduling zu erlauben. Beispielsweise kann die Methode setAndReada() in zwei Teile (eine modifizierende Methode und eine Query) geteilt und der Rumpf von loopa() in eine eigene Methode ausgelagert werden. Als Ergebnis entstehen so die Methoden, die mit einem Testtreiber der Form seta();loopaBody();reada(); sofort eine erkennbare Verletzung der Invariante hervorrufen:

       Java   
 class X {
  int a = 0;
  int loopa() {
    for(int i=0; i<100000; i++) loopaBody();
  }
  int  loopaBody() { a = (a+1) % 99; }
  void seta()      { a = 0; }
  int  reada()     { return a; }
}

Ein alternativer Ansatz könnte ein Scheduling nutzen, in der sich laufende Funktionen mit einem Methodenaufruf selbst unterbrechen.5

Als größtes Problem aber bleibt bestehen, dass die Anzahl der quasi-parallelen Abläufe mit feingranularem Scheduling stark ansteigt. Entsprechend müssen die potentiellen Gefahrenquellen durch die Entwickler antizipiert werden, um durch konkrete Tests realisiert zu werden. Andererseits können unklare Situationen, von denen Entwickler vermuten, sie könnten zu Fehlern führen, auf diese Weise geprüft werden.

In der Literatur zum Thema Testen werden verschiedene Vorschläge gemacht, nebenläufige Programme zu testen. Neben der generellen Richtlinie, Programme „threadsicher“ zu machen und eine Art „Pseudodeterminismus“ [LF02] zu etablieren, indem nebenläufige Programmteile möglichst unabhängig gestaltet werden, wird vorgeschlagen, möglichst viele automatisierte Testabläufe mit den echten Threads durchzuführen, um damit zumindest sporadisch auftretende Fehler zu erhalten [LF02]. Das Zutrauen in die Robustheit des Systems bleibt aber auch mit diesem Verfahren beschränkt, insbesondere wenn das Produktionssystem sich vom Testsystem in Hardware, Betriebssystem, Compiler-Version, Systemlast oder Ähnlichem unterscheidet.


Bernhard Rumpe. Agile Modellierung mit UML. Springer 2012