Allgemeine Grundlagen der Programmierung


Kapitel 4: Programmaufbau


Inhaltsverzeichnis

Dieses Buch ist unter einer Creative Commons-Lizenz lizensiert.


4.1 Allgemeines

Viele Wege führen nach Rom

Bisher kennen Sie lediglich funktionale und objektorientierte Programmiersprachen, die jeweils andere Programmieransätze darstellen. Für den Anwender eines Programms ist jedoch grundsätzlich nicht erkennbar, ob die Anwendung in einer funktionalen oder objektorientierten Sprache entwickelt wurde. Diese Unterscheidung bezieht sich lediglich auf den Programmieransatz, also die Art und Weise, wie der Programmierer vom Problem zur Lösung gelangt. Dabei führen beide Wege zum Ziel. Keine funktionale und objektorientierte Programmiersprache stellt grundsätzlich ein Hindernis dar, wenn es um die Entwicklung von bestimmten Computer-Anwendungen geht. Sie können sowohl in der einen als auch anderen Programmiersprache die gleichen Anwendungen entwickeln. Der Unterschied ist der, dass es in der einen Programmiersprache wesentlicher leichter und schneller gehen könnte (immer abhängig von Art und Umfang des Programms, das entwickelt werden soll).

Für den Anwender hingegen machen sich andere Dinge bemerkbar. Wenn das Programm beispielsweise eine intensive Berechnung ausführt, sollte es diese Berechnung "im Hintergrund" ausführen, damit der Anwender gleichzeitig mit anderen Funktionen des Programms weiterarbeiten kann. Dazu muss das Programm beispielsweise in der Lage sein, während die Berechnung abläuft gleichzeitig auf neue Benutzereingaben zu reagieren.

Das Ausführen paralleler Aufgaben innerhalb einer Anwendung erfordert spezielle Programmiertechniken. In den bisher umgesetzten Beispielen war zu einem bestimmten Zeitpunkt immer nur eine Anweisung aktiv. Das Programm war zu jedem Zeitpunkt mit der Ausführung eines ganz bestimmten Befehls beschäftigt. Das Ausführen paralleler Aufgaben bedeutet, dass ein Programm zu einem Zeitpunkt mehrere Anweisungen ausführen kann. Damit ist es beispielsweise möglich, eine intensive Berechnung durchzuführen und gleichzeitig auf Benutzereingaben zu reagieren.


4.2 Multithreading

Mehrere Programmfäden

Das Ausführen paralleler Aufgaben innerhalb einer Anwendung wird durch Multithreading gelöst. Verwechseln Sie Multithreading nicht mit Multitasking: Während Multithreading die parallele Ausführung von Aufgaben innerhalb eines Programms ist, ist Multitasking die parallele Ausführung von Programmen durch das Betriebssystem. Sie als Programmierer können Ihr Programm multithreading-fähig gestalten, über Multitasking entscheidet jedoch allein das Betriebssystem.

Multithreading bedeutet wortwörtlich, dass Ihr Programm mehrere Threads ausführt. Unter Threads versteht man im Deutschen Programmfäden. Während unsere Beispielprogramme bisher alle nur aus einem Programmfaden bestanden, werden im Multithreading durch den Programmierer mehrere Programmfäden gestartet. Mit den jeweiligen Programmfäden werden Funktionen verknüpft - es handelt sich hierbei also um eine Art Funktionsaufruf. Das Besondere ist jedoch nun, dass durch den Aufruf der Funktion diese gestartet wird und gleichzeitig die Funktion, aus der der Aufruf erfolgt, weiter ausgeführt wird. Ab diesem Zeitpunkt laufen also zwei Funktionen gleichzeitig, die jeweils unterschiedliche Aufgaben bearbeiten können.

Mit Multithreading können Sie Ihr Programm leistungsfähiger gestalten, weil unabhängige Aufgaben parallel ausgeführt werden können. Stellen Sie sich folgendes Beispiel vor: In Ihrem Programm müssen zwei Aufgaben durchgeführt werden. Beide Aufgaben bestehen aus insgesamt zehn Schritten, die nacheinander ausgeführt werden müssen. Während die erste Aufgabe pro Schritt 0,6 Sekunden benötigt, ist die zweite Aufgabe rechenintensiver und benötigt pro Schritt 1,0 Sekunden. Folgender Javascript-Code veranschaulicht dieses Beispiel.

<html>
  <head>
    <title>Allgemeine Grundlagen der Programmierung</title>
    <script type="text/javascript">
      var Aufgabe1, Aufgabe2; 
      var Schritt1 = 0, Schritt2 = 0; 

      function aufgabe_1() { 
        var text = "Aufgabe 1: " + ++Schritt1 + ". Schritt<br>"; 
        document.getElementsByTagName("body")[0].innerHTML += text; 
        if (Schritt1 > 9) { 
          window.clearInterval(Aufgabe1); 
          Aufgabe2 = window.setInterval("aufgabe_2()", 1000); 
        } 
      } 

      function aufgabe_2() { 
        var text = "Aufgabe 2: " + ++Schritt2 + ". Schritt<br>"; 
        document.getElementsByTagName("body")[0].innerHTML += text; 
        if (Schritt2 > 9) { 
          window.clearInterval(Aufgabe2); 
        } 
      } 

      Aufgabe1 = window.setInterval("aufgabe_1()", 600); 
    </script>
  </head>
  <body>
  </body>
</html>

Der Javascript-Code simuliert die Ausführung der Aufgaben, indem automatisch nach einem fest vorgegebenen Zeitinterval eine bestimme Funktion gestartet wird, die jeweils eine Variable inkrementiert und dann den Wert dieser Variablen auf den Bildschirm ausgibt. Die erste Funktion wird hierbei alle 0,6 Sekunden aufgerufen, die zweite Funktion - die die rechenintensivere Aufgabe simuliert - alle 1,0 Sekunden. Wenn der zehnte Schritt der ersten Aufgabe erreicht und die erste Aufgabe somit komplett ist, wird die zweite Aufgabe gestartet. Das heißt, die Aufgaben werden hintereinander ausgeführt - genau wie die Anweisungen in all den bisher kennengelernten Javascript-Programmen, die auch jeweils eine nach der anderen ausgeführt wurden.

Wenn Sie die Zeit stoppen, die zur Ausführung der beiden Aufgaben benötigt wird, erhalten Sie 16 Sekunden (aufgrund der verwendeten Funktionen in Javascript benötigt das Programm zur Ausführung insgesamt 17,6 Sekunden, was aber nicht von entscheidender Bedeutung ist).

Zur Optimierung des Programms wird es umgeschrieben. Die beiden unabhängigen Aufgaben werden nun in zwei Threads ausgeführt. Nachdem Javascript das Erzeugen und Ausführen echter Threads nicht ermöglicht, werden die Threads simuliert. Das heißt, das Ergebnis des Programms ähnelt dem eines Programms mit echtem Multithreading.

<html>
  <head>
    <title>Allgemeine Grundlagen der Programmierung</title>
    <script type="text/javascript">
      var Aufgabe1, Aufgabe2; 
      var Schritt1 = 0, Schritt2 = 0; 

      function aufgabe_1() { 
        var text = "Aufgabe 1: " + ++Schritt1 + ". Schritt<br>"; 
        document.getElementsByTagName("body")[0].innerHTML += text; 
        if (Schritt1 > 9) { 
          window.clearInterval(Aufgabe1); 
        } 
      } 

      function aufgabe_2() { 
        var text = "Aufgabe 2: " + ++Schritt2 + ". Schritt<br>"; 
        document.getElementsByTagName("body")[0].innerHTML += text; 
        if (Schritt2 > 9) { 
          window.clearInterval(Aufgabe2); 
        } 
      } 

      Aufgabe1 = window.setInterval("aufgabe_1()", 600); 
      Aufgabe2 = window.setInterval("aufgabe_2()", 1000); 
    </script>
  </head>
  <body>
  </body>
</html>

Der Javascript-Code unterscheidet sich kaum vom vorherigen. Der Aufruf von window.setInterval(), um die Funktion aufgabe_2() als Thread zu starten, erfolgt nun nicht mehr innerhalb von aufgabe_1(), sondern außerhalb. Der restliche Code ist identisch.

Das Programm löst nun die Aufgaben parallel in zwei Threads. Stoppen Sie die Zeit, die das Programm zum Durchlaufen aller zehn Schritte der zwei Aufgaben benötigt, und Sie sehen, dass nach 10 Sekunden (genau 11 Sekunden) die Aufgaben beide komplett ausgeführt wurden. Das Erkennen paralleler Aufgaben und das Entwickeln von Threads kann also die Ausführungsdauer extrem verkürzen - in diesem Fall um knapp 40 Prozent.

Kurz zur Erklärung der Javascript-Beispiele: Durch den Aufruf von window.setInterval() werden die Funktionen, die als erster Parameter angegeben sind, wiederholt neu gestartet. In welchen Zeitabständen die jeweiligen Funktionsaufrufe stattfinden legt der zweite Parameter fest, der eine Zeit in Millisekunden angibt. Das heißt also, die entsprechenden Funktionen werden einfach automatisch immer wieder von Zeit zu Zeit aufgerufen und ausgeführt.

Der Code in den Funktionsrümpfen bedeutet: Über den Zugriff auf die Eigenschaft innerHTML des Objekts mit dem HTML-Tag <body> wird dem Dokumentinhalt - also der Webseite selber - Text hinzugefügt. Dies geschieht unter Verwendung des kombinierten Zuweisungs-Operators +=. Dieser Operator funktioniert so, dass der Code a += b äquivalent ist zum Code a = a + b. Der Text, der hinzugefügt wird, besteht aus der Ausgabe, welche Aufgabe gerade ausgeführt wird, dem Wert einer Variablen und einem Zeilenumbruch. Der Wert der Variablen wird über den Operator ++ vor seiner Ausgabe jeweils um 1 erhöht. Das heißt, der Code ++a ist äquivalent zum Code a = a + 1.

Die Ausgabe von Text in das Dokument erfolgt jedoch nur, solange der Wert der Variablen kleiner als 10 ist. Ist dies nicht der Fall, weil die Variable bereits zehnmal um den Wert 1 erhöht wurde, wird die Funktion window.clearInterval() aufgerufen. Diese Funktion bekommt jeweils die Variable übergeben, die den Rückgabewert der Funktion window.setInterval() entgegennahm. Der Wert der Variable ist hierbei unerheblich. Wichtig ist, dass es auf diese Weise möglich ist, eine entsprechende Funktion zu identifizieren. Über window.clearInterval() kann dann mit Hilfe der Variablen die automatisch in regelmäßigen Abständen aufgerufene Funktion gestoppt werden und das Intervall unterbrochen werden. Wenn also der Wert 10 in der Variablen erreicht ist, verhindert die Funktion, auch weiterhin in Zukunft aufgerufen zu werden, indem sie die entsprechende Variable der Funktion window.clearInterval() übergibt.

Echtes Multithreading liegt dann vor, wenn eine Funktion als Thread aufgerufen wird und der Thread dann endet, wenn auch die Funktion beendet ist. Im Gegensatz hierzu muss in Javascript die entsprechende Funktion immer wieder regelmäßig neu gestartet werden, um jedesmal neu Anweisungen auszuführen, die ansonsten in einem Multithreading-Programm alle gemeinsam in die Funktion gepackt werden würden. Mit echtem Multithreading würde also beispielsweise eine while-Schleife in der Funktion von 0 bis 10 hochzählen und in jedem Schleifendurchgang eine Meldung auf den Bildschirm ausgeben.


4.3 Polling

Wiederholtes Überprüfen

Ein gutes Programm soll, während es eine rechenintensive Operation ausführt, auch noch auf Benutzereingaben reagieren. Beispielsweise sollte das Programm eine Schaltfläche Abbrechen zur Verfügung stellen, über die der Anwender jederzeit die rechenintensive Operation beenden kann. Auch in diesem Fall muss das Programm also zwei Aufgaben gleichzeitig ausführen: Berechnen und auf Benutzereingaben überprüfen. Andernfalls würde zuerst die Berechnung zu Ende geführt werden und danach auf die Benutzereingabe überprüft werden. In diesem Fall wäre die Abbrechen-Schaltfläche sinnlos, nachdem die Überprüfung erst nach der kompletten Berechnung stattfindet.

Als Programmierer könnte man nun auf folgenden Trick zurückgreifen. Während die langwierige Rechenoperation ausgeführt wird, könnte ja zwischendurch immer mal wieder überprüft werden, ob der Anwender nun auf Abbrechen geklickt hat. Wenn beispielsweise die Rechenoperation aus einer Schleife besteht, die 10.000mal ausgeführt wird, könnte mit einer if-Anweisung innerhalb dieser Schleife ebenfalls ständig auf Benutzereingaben überprüft werden.

Dieses Modell ist als Polling bekannt - und so ungefähr das Schlimmste, was man als Programmierer machen kann. Polling bedeutet, dass man ständig innerhalb seines Programms überprüft, ob ein bestimmtes Ereignis bereits eingetreten ist. Das Problem hierbei: Entweder führt man die Überprüfung in zu großen Zeitabständen aus, so dass das Programm erst verzögert auf Ereignisse reagiert, oder aber man fährt die Überprüfung in so kurzen Zeitabständen aus, dass man Rechenressourcen und Prozessorzeit verschwendet. Polling ist in jedem Fall Ressourcen-Verschwendung, weil man wiederholt auf Ereignisse überprüft, die eventuell erst zu einem späten Zeitpunkt eintreten oder sogar gar nicht - der Anwender klickt beispielsweise nicht auf die Abbrechen-Schaltfläche, sondern wartet die Berechnung bis zum Ende ab.


4.4 Multiplexing

Hallo, ich hab da mal ein Ergebnis

Multiplexing ist kein Konkurrenzmodell zu Multithreading. Während beim Multithreading Aufgaben parallel ausgeführt werden (und somit die einzelnen Threads im Vordergrund stehen), wird beim Multiplexing die Anwendung zentral um einen Kern herum aufgebaut. Dieser Kern - normalerweise eine ganz bestimmte Funktion des Betriebssystems - überwacht verschiedene Ein- und Ausgabegeräte. Tritt ein bestimmtes Ereignis ein, auf das die Anwendung wartet - also beispielsweise auf eine Eingabe über die Tastatur - dann gibt der Kern eine entsprechende Meldung an die Anwendung weiter, die nun das Ereignis entsprechend behandelt. Ist dies erledigt, kehrt die Programmausführung zum Kern zurück, wo auf das nächste Ereignis gewartet wird oder bereits ein neues Ereignis vorliegt.

Multiplexing kann man als Gegenteil von Polling bezeichnen: Während beim Polling das Programm ständig nachprüfen muss, ob ein Ereignis eingetreten ist, wird es beim Multiplexing automatisch informiert. Im Gegensatz zum Multithreading kann beim Multiplexing mit einer Funktion auf mehrere Ereignisse gewartetet werden, anstatt in mehreren Threads jeweils auf ein Ereignis zu warten. Multiplexing hat also den Vorteil, dass man auf mehrere Ereignisse gleichzeitig warten kann, ohne Threads verwenden zu müssen. Das ist insofern interessant, als dass der Einsatz von Threads in der Praxis der Software-Entwicklung meist recht kompliziert ist, so dass Multiplexing vorgezogen werden sollte. Da Multiplexing keinen Wechsel zwischen Threads benötigt, ist diese Technik außerdem ressourcen-schonender und schneller als Multithreading. Multiplexing stellt jedoch keine Lösung dar, wenn es um rechenintensive Aufgaben geht, die parallel ausgeführt werden müssen. In diesem Fall würde die rechenintensive Behandlung eines Ereignisses bei Multiplexing verhindern, dass die Anwendung auf neue Ereignisse reagieren kann.

Durch Kombination von Multiplexing und Multithreading können Anwendungen erstellt werden, die informiert werden, wenn ein bestimmtes Ereignis eintritt, und die zur Behandlung von Ereignissen rechenintensive Operationen in Threads ausführen können.


4.5 Asynchrone Ein- und Ausgabe

Heute bestellt, morgen geliefert

Als Konkurrenzmodell zu Multiplexing bietet sich die asynchrone Ein- und Ausgabe an. Diese Technik ist so neu, dass sie kaum standardisiert ist und noch weniger in der Praxis der Software-Entwicklung angewandt werden kann.

Asynchrone Ein- und Ausgabe liegt dann vor, wenn Ihr Programm beispielsweise einen Wert von der Tastatur einlesen will (also eine Taste, die der Anwender gedrückt hat), beim entsprechenden Lesevorgang im Programm jedoch angezeigt wird, dass der Anwender noch gar keine Taste gedrückt hat. Daraufhin bearbeitet Ihr Programm einfach eine andere Aufgabe. Wenn der Anwender dann die Taste drückt, wird das Programm sofort informiert, und eine entsprechende Ereignisbehandlung kann ausgeführt werden.

Der Unterschied zu Multiplexing ist, dass das Programm nicht um einen Kern herum aufgebaut sein muss und auf Meldungen aus dem Kern warten muss. Stattdessen kann das Programm machen, was es will - also auch jederzeit rechenintensive Operationen ausführen - und wenn dann ein entsprechendes Ereignis eintritt, werden die laufenden Anweisungen unterbrochen und eine Ereignisbehandlung ausgeführt. Asynchrone Ein- und Ausgabe vereint die Vorteile von Multiplexing und Multithreading. Threads sind lediglich dann noch interessant, wenn mehrere rechenintensive Aufgaben ausgeführt werden sollen und die Ausführung durch Threads parallelisiert werden soll.

Das Modell ist asynchron, weil beim Abfragen eines Ereignisses dieses noch nicht eingetreten zu sein braucht, bei Eintreten die entsprechende Information aber später automatisch nachgereicht wird. Eine Abwandlung vom rein asynchronen Ein- und Ausgabemodell stellt die signal-gesteuerte Ein- und Ausgabe dar, die nicht ganz so effizient arbeitet (jedoch auch heute schon in der Praxis der Software-Entwicklung angewandt werden kann).


4.6 Aufgaben

Übung macht den Meister

Sie können die Lösungen zu allen Aufgaben in diesem Buch als ZIP-Datei erwerben.

  1. Für Experten: Entwickeln Sie eine Javascript-Lösung für Aufgabe 4 aus Kapitel 2 (Umrechnung einer Dezimalzahl in eine Hexadezimalzahl) unter Verwendung eines Threads.

    Anstatt die Division- und Modulo-Operation jeweils in einem Schleifendurchgang neu durchzuführen, müssen diese Schritte durch den wiederholten Aufruf einer Funktion ausgeführt werden.