10.3 Systemnahe Programmierung
Einige Programmieraufgaben erfordern Zugriffe auf Funktionen, die das Betriebssystem bereitstellt. In Unix-Systemen werden solche Anweisungen als Systemaufrufe (System Calls) bezeichnet. In diesem Abschnitt werden einige der wichtigsten Aspekte der systemnahen Programmierung behandelt: die gleichzeitige Ausführung mehrerer Aufgaben, die sogenannte Nebenläufigkeit, durch Prozesse und Threads sowie die Kommunikation zwischen ihnen.
10.3.1 Prozesse und Pipes
Das Prinzip des Prozesses wurde bereits in Kapitel 5, »Betriebssystemgrundlagen«, angesprochen: Jedes unter einem modernen Betriebssystem laufende Programm wird als separater Prozess oder Task ausgeführt, der seinen eigenen Speicherbereich und seine eigenen Ein- und Ausgabeschnittstellen besitzt. Threads dagegen stellen eine einfache Möglichkeit zur Verfügung, innerhalb desselben Prozesses mehrere Aufgaben parallel zu erledigen. In diesem Abschnitt werden beide Verfahren kurz praktisch vorgestellt.
Das Erzeugen neuer Prozesse ist das herkömmliche Verfahren, um in Unix-Programmen mehrere parallele Verarbeitungseinheiten zu realisieren: Zwei verschiedene Aufgaben müssen nicht aufeinander warten, um ausgeführt zu werden. Dies ermöglicht beispielsweise die effektive Nutzung von Wartezeiten, die durch die verhältnismäßig langsamen I/O-Operationen entstehen können.
Das Unix-Prozessmodell erzeugt einen neuen Prozess durch den Systemaufruf fork(), der eine absolut identische Kopie des ursprünglichen Prozesses erzeugt. Jede Codezeile, die hinter einem Aufruf von fork() steht, wird in nicht vorhersagbarer Reihenfolge doppelt ausgeführt. Da der ursprüngliche Prozess (Parent-Prozess) in der Regel etwas anderes tun soll als der neu erzeugte (Child-Prozess), müssen diese beiden irgendwie voneinander unterschieden werden. In diesem Zusammenhang ist es nützlich, dass fork() im Parent-Prozess die Prozess-ID (PID) des Child-Prozesses zurückgibt und im Child-Prozess 0. Folglich sehen praktisch alle fork()-Aufrufe in C-Programmen unter Unix schematisch so aus:
int f;
...
if (f = fork()) {
...
/* Parent-Prozess:
Hier werden die Parent-Aufgaben erledigt;
f enthält PID des Childs. */
} else {
...
/* Child-Prozess:
Hier werden die Child-Aufgaben erledigt;
getppid() liefert PID des Parents. */
}
Um die Forking-Funktionalität in C-Programmen verwenden zu können, müssen Sie die Header-Datei sys/types.h einbinden:
#include <sys/types.h>
Unter Windows wird dieses Verfahren von Haus aus nicht unterstützt; die Win32-API definiert stattdessen eine Funktion namens CreateProcess(), die einen neuen, leeren Prozess erzeugt. In den Windows-Versionen von Perl wird trotzdem eine Unix-kompatible fork()-Funktion angeboten. Aufruf und Funktionalität von fork() sind in Perl übrigens mit C identisch.
Das folgende Beispiel zeigt ein kleines Perl-Programm, das im Parent- und im Child-Prozess jeweils eine Schleife von 1 bis 10.000 durchzählt und anzeigt. Im Parent-Prozess werden die Werte ein wenig eingerückt:
#!/usr/bin/perl
if (fork()) {
# Parent-Prozess
for ($i = 1; $i <= 10000; $i++) {
print " $i\n";
}
} else {
# Child-Prozess
for ($i = 1; $i <= 10000; $i++) {
print "$i\n";
}
}
Wenn Sie das Programm ausführen, werden Sie den Wechsel zwischen ein- und ausgerückten Zahlen bemerken. Sie können die Ausgabe auch genauer untersuchen, indem Sie sie mithilfe von >Dateiname in eine Datei umleiten.
Natürlich ist dieses Beispiel nicht besonders sinnvoll. Zu den bedeutendsten Aufgaben von fork() gehört die Implementierung von Netzwerkservern, die mit mehreren Clients zur gleichen Zeit kommunizieren. Beispiele finden Sie im nächsten Abschnitt.
Neben fork() gibt es einige weitere Anweisungen, die im Zusammenhang mit Prozessen nützlich sind:
- exec(), verfügbar in C und Perl, führt das Programm aus, dessen Pfad als Argument angegeben wurde, und kehrt nicht zurück – nachdem das Programm beendet ist, wird auch der Prozess beendet. Dies ist nützlich, um einen mithilfe von fork() erzeugten Prozess mit der Ausführung eines externen Programms zu beauftragen.
- system(), ebenfalls in C und Perl einsetzbar, führt das als Argument angegebene Programm aus
und kehrt anschließend zurück. Dies wird vorzugsweise für den Aufruf von Systembefehlen
genutzt. Beispielsweise können Sie sich in einem Unix-Programm folgendermaßen den
Inhalt des aktuellen Verzeichnisses anzeigen lassen:
system ("ls");
Unter Windows muss die Anweisung natürlich modifiziert werden, weil der Befehl für die Verzeichnisanzeige hier anders lautet:
system ("dir");
- Der Backtick-Operator (der Pfad eines Programms in ``) ist nur in Perl (und diversen Unix-Shells) verfügbar und liefert die Ausgabe des
aufgerufenen externen Programms als Wert zurück. Es ist ein wenig umständlich, das
Zeichen ` zu erzeugen: Da es sich um ein Akzentzeichen handelt, müssen Sie zunächst + drücken und anschließend die Leertaste.[Anm.: Wenn Sie üblicherweise keine Akzentzeichen, aber häufig Sonderzeichen wie ^ oder den
Backtick benötigen, können Sie unter Linux ein Tastaturlayout mit der Variante nodeadkeys einstellen, die das Akzentverhalten solcher Tasten abschaltet.] Backticks sind extrem nützlich, um die Ausgabe eines Befehls genau zu analysieren
– besonders in Zusammenarbeit mit Perls RegExp-Fähigkeiten.
Der folgende Code gibt auf einem Unix-System beispielsweise sämtliche Dateien des ausführlichen Inhaltsverzeichnisses zurück, in denen der Dateiname (letzter Posten in der Zeile) den Buchstaben a enthält:
$dir = `ls -l`;
@dirs = split (/\n/, $dir);
foreach $d(@dirs) {
print "$d\n" if $d =~ /a[^\s]+$/;
}
Kommunikation zwischen Prozessen durch Pipes
Mithilfe von fork() können Sie zwar beliebig viele Prozesse erzeugen, aber diese Prozesse laufen völlig beziehungslos nebeneinander. In der bisher vorgestellten Form sind sie also nicht dafür geeignet, mehrgliedrige Aufgaben kooperativ zu erledigen. Da Programme und Tasks in der Praxis jedoch auf vielfältige Weise miteinander interagieren müssen, sind Mittel zur Kommunikation zwischen den verschiedenen Prozessen erforderlich. In Kapitel 5, »Betriebssystemgrundlagen«, wurde bereits angedeutet, dass es vielerlei Möglichkeiten dafür gibt, unter anderem Shared Memory, Signale, Semaphoren oder Pipes. Als praktisches Beispiel wird hier die Verwendung von Pipes angesprochen.
Die Verwendung von Pipes zur Kommunikation wird hier am Beispiel von Perl demonstriert, da sie in diesem Fall besonders leicht und effizient vonstattengeht. Im Grunde gibt es keinen Unterschied zwischen der Anwendung einer Pipe auf der Kommandozeile und aus einem Perl-Programm heraus. In einem Perl-Programm erzeugen Sie eine Pipe zu einem anderen Programm, indem Sie mithilfe von open ein Dateihandle öffnen und statt des üblichen Dateinamens ein lauffähiges Programm angeben. Ein Pipe-Symbol vor dem Programmnamen ("|Programm") bedeutet dabei, dass Sie die Ausgabe Ihres Programms an dieses Programm weitergeben möchten, während ein dahinterstehendes Pipe-Symbol es Ihnen ermöglicht, zeilenweise aus der Ausgabe des externen Programms zu lesen.
Beispielsweise können Sie die Ausgabe Ihres Programms folgendermaßen an den Linux-Pager less weiterleiten (unter Windows könnten Sie stattdessen "|more" schreiben), um Daten bildschirmweise auszugeben:
open (AUSG, "|less");
# Ausgabebefehl:
print AUSG "Text...";
Auch die Eingabe aus einer Pipe ist unproblematisch. Sie können zum Beispiel folgendermaßen zeilenweise aus der Unix-Prozesstabelle lesen:
open (EING, "ps aux|");
while ($prozess = <EING>) {
# ... Zeile verarbeiten
}
Eine bequemere Möglichkeit, eine Pipe für die Kommunikation zwischen zwei Prozessen innerhalb Ihres Programms einzusetzen, bietet der Systemaufruf pipe(). Die gleichnamige Perl-Anweisung erzeugt zwei durch eine Pipe verbundene Dateihandles, deren Namen Sie durch Übergabe entsprechender Argumente festlegen können. Das erste Argument gibt das lesende, das zweite das schreibende Dateihandle an:
pipe (READER, WRITER);
In dem folgenden kleinen Programm erzeugt der Parent-Prozess in einer for-Schleife eine Reihe von Zahlen, die in das Schreibhandle geschrieben werden. Der Child-Prozess liest die Zahlen aus dem zugehörigen Lesehandle und berechnet jeweils deren Quadrat. Besonders sinnvoll ist diese Anwendung nicht, demonstriert aber das Prinzip:
#/usr/bin/perl -w
use strict;
pipe (READER, WRITER);
my $child = fork();
if ($child) {
# Parent-Prozess liest nur; WRITER schließen
close WRITER;
while (my $line = <READER>) {
chomp $line;
print ("Das Quadrat von $line ist ".
$line * $line. "\n");
}
} else {
# Child-Prozess schreibt nur; READER schließen
close READER;
for (my $i = 0; $i < 100; $i++) {
print "Bin bei $i\n";
print WRITER "$i\n";
}
}
Wie Sie bemerken, schließt jeder der beiden Prozesse zu Beginn eines der beiden Pipe-Handles. Es würde auch nichts nutzen, das nicht zu tun, da das Pipe-Handle nur in eine Richtung funktioniert, für die Sie sich von vornherein entscheiden müssen. Im vorliegenden Fall behält der Child-Prozess das schreibende Dateihandle offen, der Parent-Prozess das lesende. Wenn die Kommunikation in zwei Richtungen stattfinden soll, müssen Sie über einen zweiten pipe()-Aufruf ein weiteres Paar von Dateihandles erzeugen.
10.3.2 Threads
Eine ähnliche Technik wie das Erzeugen mehrerer Prozesse ist das Erzeugen mehrerer Threads innerhalb eines Prozesses. Genau wie das Betriebssystem dafür sorgt, dass die verschiedenen Prozesse abwechselnd abgearbeitet werden, findet auch die Verarbeitung der Threads im Wechsel statt; in den meisten Fällen kann wie bei Prozessen eine Priorität gewählt werden. Der Hauptunterschied zwischen Prozessen und Threads besteht darin, dass Threads im selben Speicherraum laufen und gemeinsamen Zugriff auf Ressourcen besitzen.
Inzwischen unterstützen alle modernen Betriebssysteme Threads, unter Umständen wird diese Unterstützung auch von der Bibliothek der verwendeten Programmiersprache statt vom System selbst zur Verfügung gestellt. Allerdings ist das Programmieren von Multithreading-Anwendungen nicht in allen Sprachen einfach, weil das Verfahren noch nicht in allen Sprachen standardisiert ist.
Am praktischsten lässt sich die Programmierung von Threads am Beispiel von Java erläutern, weil die Thread-Funktionalität in dieser Sprache von Anfang an verankert wurde. Sie wird im Wesentlichen durch die Klasse java.lang.Thread bereitgestellt, die einen einzelnen lauffähigen Thread repräsentiert.
Wenn eine Java-Instanz als Thread ausgeführt werden soll, muss die zugrunde liegende Klasse eine der beiden folgenden Bedingungen erfüllen:
- Sie muss von java.lang.Thread abgeleitet sein. Innerhalb dieser Klasse können Sie die Methode run() überschreiben, die die eigentlichen Anweisungen enthält, die als Thread ausgeführt werden sollen.
- Alternativ kann sie das Interface Runnable implementieren. Zu diesem Zweck muss sie ebenfalls eine Methode namens run() bereitstellen.
Wenn Sie eine Klasse von Thread ableiten, können Sie eine Instanz davon erzeugen; ein Aufruf ihrer Methode start() beginnt mit der Ausführung der Anweisungen in run(). Eine Instanz einer Klasse, die Runnable implementiert, wird dagegen als Argument an den Konstruktor der Klasse Thread übergeben. Auch dieser neue Thread wird mithilfe von start() gestartet. Letzteres ist besonders nützlich, falls Sie innerhalb des aktuellen Programms einen zweiten Thread starten möchten, ohne eine externe Instanz zu verwenden: Sie können Runnable einfach durch Ihr aktuelles Programm implementieren.
In beiden Fällen läuft das aktuelle Hauptprogramm ebenfalls als Thread weiter. Dass jedes Java-Programm automatisch ein Thread ist, können Sie besonders gut an Fehlermeldungen bemerken. Diese lauten häufig derart, dass ein Fehler im Thread main aufgetreten sei.
Das folgende Beispiel zeigt eine von Thread abgeleitete Klasse namens BGSearcher, die im Hintergrund ein als Argument übergebenes Array von int-Werten linear nach einem ebenfalls übergebenen Wert durchsucht. Die Klasse BGSearchTest ist ein Testprogramm für BGSearcher, das ein Array mit Zufallswerten füllt und an den Hintergrundsucher übergibt. Schließlich benötigen Sie noch das Interface SearchInfo, das von einer Klasse, die den BGSearcher verwenden möchte, implementiert werden muss. Tippen Sie alle drei Klassen ab, und speichern Sie sie in entsprechenden .java-Dateien im gleichen Verzeichnis. Es genügt, anschließend die Datei BGSearchTest.java zu kompilieren – die beiden anderen werden automatisch mitkompiliert.
Auch für dieses Beispiel sind wieder einige Erklärungen erforderlich:
- Die Klasse BGSearcher ist von Thread abgeleitet, damit die Methode run() in einem Thread laufen kann. Die Methode start(), die ein Programm aufrufen muss, damit eine Instanz von BGSearcher mit der Arbeit beginnt, muss hier nicht explizit definiert werden; sie wird von Thread geerbt.
- Der Konstruktor von BGSearcher erwartet drei Argumente: das zu durchsuchende int-Array, den gesuchten Wert und eine Instanz von SearchInfo. Letzteres kann eine Instanz einer beliebigen Klasse sein, die das Interface SearchInfo implementiert – in der Regel, wie im vorliegenden Fall, das Programm, das die Hintergrundsuche
einsetzt.
Die Übergabe der SearchInfo-Instanz ist erforderlich, damit BGSearcher jedes Mal eine Methode des implementierenden Programms aufrufen kann, wenn der gesuchte Wert im Array gefunden wird. Eine solche Methode, die Sie in einem eigenen Programm bereitstellen, damit sie bei Bedarf von außen aufgerufen wird, heißt Callback-Methode. Der Einsatz von Callbacks ist immer dann sinnvoll, wenn Sie ein externes, asynchron auftretendes Ereignis (hier zum Beispiel einen Sucherfolg) in Ihrem eigenen Code verarbeiten möchten.
- Die Klassendefinition des Testprogramms BGSearchTest enthält die Klausel implements SearchInfo. Mit dieser Klausel garantiert die Klasse, dass sie Implementierungen der Methoden des genannten Interface (in diesem Fall nur die eine Methode searchinfo()) bereitstellt. Dies gibt anderen Klassen die Sicherheit, ein Objekt der Klasse, die das Interface implementiert, als Instanz dieses Interface betrachten zu können. Gerade die hier gezeigte Verwendung einer Callback-Methode ist ein hervorragendes Beispiel für die Nützlichkeit eines Interface: Ein Programm kann BGSearcher einsetzen, wenn es SearchInfo implementiert, und darf ansonsten von einer beliebigen Klassenhierarchie abstammen.
- In der Methode main() erzeugt BGSearchTest ein 10.000 Elemente großes Array, das mit zufälligen int-Werten gefüllt wird. Für die Erzeugung von Zufallszahlen zwischen 1 und 10 wird die Zufallsgenerator-Methode Math.random() verwendet, die pseudozufällige Fließkommawerte zwischen 0 und 1 zurückgibt. Mithilfe von Multiplikation, Addition und Typecasting (explizite Datentypumwandlung) wird der erhaltene Wert in den richtigen Bereich umgerechnet.
- Die Anweisung in BGSearchTest, die vielleicht am merkwürdigsten erscheint, ist die Erzeugung einer Instanz der
Klasse selbst:
BGSearchTest test = new BGSearchTest();
Die erzeugte Instanz test ist erforderlich, um sie als Referenz auf das aktuelle Programm an das als Nächstes erzeugte BGSearcher-Objekt zu übergeben, damit es wiederum die Callback-Methode searchinfo() aufrufen kann.
- Nachdem das BGSearcher-Objekt erzeugt ist, muss übrigens seine Methode start() aufgerufen werden, damit es mit der eigentlichen Suche beginnt. Anschließend kann sich das Programm um seine eigenen Aufgaben kümmern, weil der Wechsel zwischen den beiden Threads automatisch vonstattengeht.
- Die Callback-Methode searchinfo() wird automatisch jedes Mal aufgerufen, wenn der gesuchte Wert im Array gefunden wird.
Sie erhält als Übergabewert die Suchposition. Im vorliegenden Beispielprogramm macht
die Methode nichts besonders Interessantes mit dem Wert, sie gibt ihn nur aus.
Bemerkenswert ist lediglich, dass die Ausgabe auf den Ausgabestrom System.err erfolgt, die Standardfehlerausgabe (in Unix und C stderr genannt). Dieser alternative Konsolen-Ausgabekanal dient speziell zum Anzeigen von Warnungen und Fehlermeldungen. Er ist besonders nützlich, wenn Sie die Standardausgabe mithilfe von >Dateiname in eine Datei umgeleitet haben: Die Ausgabe auf stderr garantiert, dass wichtige Meldungen nicht mit der normalen Ausgabe eines Programms in der Umleitung landen, sondern auf dem Bildschirm angezeigt werden.
- Das Interface SearchInfo deklariert, wie bei Interfaces üblich, lediglich den leeren Header der Methode searchinfo(). Diese Methode muss von einer Klasse bereitgestellt werden, die das Interface implementieren möchte.
In den nächsten beiden Abschnitten finden Sie weitere Beispiele für die Anwendung von Threads: einen Ruby-Netzwerkserver sowie eine flimmerfreie Animation in Java.
Ihr Kommentar
Wie hat Ihnen das <openbook> gefallen? Wir freuen uns immer über Ihre freundlichen und kritischen Rückmeldungen.