Übersicht Inhaltsverzeichnis Vorwort 1 Einführung 2 Agile und UML-basierte Methodik 3 Kompakte Übersicht zur UML/P 4 Prinzipien der Codegenerierung 5 Transformationen für die Codegenerierung 6 Grundlagen des Testens 7 Modellbasierte Tests 8 Testmuster im Einsatz 8.1 Dummies 8.2 Testbare Programme gestalten 8.3 Behandlung der Zeit 8.4 Nebenläufigkeit mit Threads 8.5 Verteilung und Kommunikation 8.6 Zusammenfassung 9 Refactoring als Modelltransformation 10 Refactoring von Modellen 11 Zusammenfassung und Ausblick Literatur |
8.4 Nebenläufigkeit mit ThreadsNebenlä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 SchedulingDeterminierte 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:
Ein Thread hat dann nach 1. in etwa die in Abbildung 8.18 dargestellte Form.
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-ModellAuf 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. 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. 8.4.3 Behandlung von ThreadsDie 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. 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 ThreadsDie Diskussion zur Behandlung von Threads lässt sich mit dem in Tabelle 8.22 dargestellten Muster zusammenfassen.
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 SequentialisierungAbschließ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:
Bei einem Scheduling, das wie vorgeschlagen, die Methoden in beliebiger Reihenfolge, aber nacheinander aufruft, wird die OCL-Bedingung
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:
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.
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||