|
Parallelität und SignaleParallelitätEin Server wird im allgemeinen in einer Multitasking-Umgebung gestartet werden. Er soll schließlich mehrere Anfragen parallel abarbeiten können(concurrent server). Unter UNIX gibt es dazu den fork-Mechanismus.New processes are created by other processes, just like new humans. New humans are created by other humans, of course, not by processes. (Unix System Administration Handbook) Die Systemaufrufe fork(), exec() und wait() haben mit der Generierung von Kindprozessen zu tun und erlauben die Synchronisation zwischen Eltern- und Kindprozessen. An dieser Stelle wird nur soweit darauf eingegangen, wie es zum Verständnis der folgenden Abschnitte nötig ist. - fork() erzeugt einen Kindprozeß,der ein vollständiges Abbild des Elternprozesses ist und der beimgleichen Stand des Befehlszählers fortgesetzt wird. Eltern- und Kindprozeßwird jedoch die Möglichkeit geboten, festzustellen, ob es sich umEltern- oder Kindprozeß handelt: Der Kindprozeß bekommt alsRückgabewert 0, der Elternprozeß die PID (Prozeß-ID) des Kindprozesses.
- Etliche Systemprozesse schließen stdin, stdout und stderr, tretenalso in den Hintergrund. Solche Prozesse nennt man Daemonprozesse.
- Terminiert der Elternprozeß vor dem Kindprozeß, wird dieserzum 'Waisenkind'. Normalerweise wird er dann vom Init-Prozeß 'adoptiert'.
- Hat der Kindprozeß dann auch noch den Kontakt zum
Terminal (Standardausgabe und -eingabe) verloren wird er zum 'Zombie'.
- wait() ermöglicht dem Elternprozeß das Warten auf die
Beendigung des/der Kindprozess(e). Der Elternprozeß wird verdrängt und
erst durch das Ende eines Kindprozesses wieder "aufgeweckt". Zur Unterscheidung
mehrerer Kindprozesse liefert die Funktion wait() die PID des "gestorbenen"
Kindprozesses zurück. Gibt es keinen Kindprozeß, ist das Ergebnis -1.
- Bei exec() wird der ursprüngliche Prozeß
durch einen neuen Prozeß ersetzt (eine Rückkehr zum aufrufenden
Prozeß ist daher logischerweise nicht möglich). exec() ist der
komplizierteste Aufruf, da der komplette Prozeßadreßraum ersetzt
werden muß.
Dazu ein erstes Beispiel in Perl:
#!/usr/bin/perl -w
# Kindprozess starten
$chld_pid=fork();
if ($chld_pid < 0)
{
die "Fork fehlgeschlagen: $!\n";
}
if($chld_pid == 0) # I am the child.
{
print "CHILD: Here I am.\n";
sleep 1;
print "CHILD: terminating.\n";
exit(1); # Exit-Status 1
}
else # I am the parent
{
print "PARENT: Kind wurde erzeugt. Warte...\n";
waitpid($chld_pid,0);
print "PARENT: Kind terminiert. Exit status: $?\n";
}
waitpid wartet darauf, daß der Kind-Prozeß mit der
angegebenen Prozeß-ID terminiert und nimmt den Rückgabewert
entgegen (in Perl in der Variablen $?). Die Null ist ein
Flag-Byte; hier kann man angeben, ob waitpid auch für
gestoppte Kindprozesse zurückkehren soll, oder ob waitpid
einfach nur nachsehen soll, ob der Kindprozeß mit der angegebenen PID
terminiert hat, ohne zu warten, falls das nicht der Fall war. Daneben
gibt's noch die Funktion wait, die wartet, bis irgend ein
Kindprozeß terminiert, und dann dessen PID zurückgibt.
In welchem Teil sich das Programm befinden, kann wir anhand des Rückgabewerts
von fork()
festgestellt werden. Beim Kindprozeß ist dieser Null, beim Elternprozeß
die PID des Kind-Prozesses. Beide Prozesse haben zunächst denselben Eingabe-
und Ausgabekanal, und teilen sich auch alle anderen Filedeskriptoren. Wenn sie nun
beide wahllos auf den Ausgabekanal schreiben, werden die beiden Ausgaben einfach
durcheinandergemischt. Wenn sie beide von der Eingabe lesen, gewinnt der Schnellere,
wenn eine neue Eingabe ansteht. Um nun wirklich kommunizieren zu können,
müssen vor dem fork() ein Paar (oder auch mehrere) von
zusätzlichen Kanälen geschaffen werde, von denen einer benutzt wird,
damit der Elternprozeß Daten an den Kindprozeß senden kann, und ein
anderer, damit der Kindprozeß Daten an den Elternprozeß senden kann.
Es gibt hierfür zwei verschiedene Systemfunktionen. Die erste heißt
pipe() und erzeugt ein Paar von zusammengehörigen Filedeskriptoren,
wobei auf dem ersten gelesen und auf dem zweiten geschrieben wird. Der zweite heißt
socketpair() und erzeugt zwei Sockets für den gleichen Zweck.
Der fork-Mechanismus löst auf einfache Weise das Problem der Bearbeitung mehrerer
paralleler Anfragen. Der Prozeß erzeugt einen Sohn, der auch den Socket
erbt, über den die Verbindung zum Client erhalten bleibt. Die Endlosschleife
des C-Serverprogramms muß dazu geändert werden:
for (;;)
{
ForeignSocket = accept(MySocket, &AdrPartnerSocket, &len);
if (fork() == 0) /* Das ist der Kindprozess */
{
MsgLen = recv(ForeignSocket, Puffer, MAXPUF, 0);
send(ForeignSocket, Puffer, MsgLen, 0);
close(ForeignSocket);
exit(0); /* Kindprozess wird beendet */
}
close(ForeignSocket); /* der Elternprozess schliesst die Verbindung */
}
Vorteile:
-
Die Programmierung ist sehr, sehr einfach, da Parent und Child sehr klare,
abgesteckte Aufgaben haben.
-
Da Parent und Child völlig unabhängig voneinander sind, sind mit
fork() arbeitende Server typischerweise sehr stabil: Stürzt ein
Client ab, hat dies keinerlei Konsequenzen für die anderen Childs
oder den Parent.
Dem stehen als Nachteile gegenüber:
- Eine Kommunikation zwischen Parent und den verschiedenen Childs ist nur
schwer möglich und schlecht portabel.
- fork() steht typischerweise nur unter Unix zur Verfügung.
Signale
Mit Signalen können Prozesse veranlaßt werden, von ihrem "normalen"
Ablauf abzuweichen. Sie können beispielsweise durch Ausführung
eines fehlerhaften Befehls - wie Division durch 0, Zugriff auf einen geschützten
Speicherbereich, etc. - verursacht werden, aber auch durch "asynchrone"
Ereignisse, wie das Drücken der Taste Ctrl-C, oder dadurch, daß
ein Prozeß einem anderen ein Signal zusendet. Letzteres ist beispielsweise
nötig, wenn ein Prozeß abgebrochen werden soll, da es in Unix grundsätzlich
nicht möglich ist, den Zustand eines Prozesses "von außen" zu
verändern. Der Prozeß muß über die gewünschte Zustandsänderung
informiert werden, um diese dann selbst durchzuführen. In Linux sind beispielsweise
32 Signale definiert (/usr/include/signum.h). Einige wichtige sind hier
aufgelistet:
#define SIGHUP 1 // "Auflegen" - z.B. bei einer Terminalleitung
#define SIGINT 2 // Interrupt - z.B. Ctrl-C
#define SIGILL 4 // Falscher Befehlscode
#define SIGBUS 7 // Busfehler
#define SIGKILL 9 // "Töten" eines Prozesses
#define SIGSEGV 11 // Fehlerhafter Speicherzugriff
#define SIGALRM 14 // Timer-Signal
#define SIGCHLD 17 // "Vater, eines deiner Kinder ist tot"
#define SIGCONT 18 // Prozeß fortsetzen (aus Zustand "stopped")
#define SIGSTOP 19 // Prozeß anhalten -> Zustand "stopped"
Das Senden eines Signales an einen Prozeß entspricht im Wesentlichen dem
Setzen des entsprechenden Bits in einem dafür vorgesehenen Speicherwort
des Prozeßkontrollblockes. Der Prozeß kann zu jedem
beliebigen Zeitpunkt festlegen, ob beim Empfang eines bestimmten Signales
- die vom System standardmäßig vorgesehene Reaktion ausgelöst
werden soll (meist Beenden des Prozesses),
- ob stattdessen eine im Anwenderprogramm bereitgestellte Bearbeitungsroutine
durchgeführt wwerden soll,
- ob das Signal ignoriert wird.
Letzteres ist allerdings nicht bei allen Signalen möglich.
Was macht man, wenn beispielsweise 25 Kindprozesse aktiv sind, und sich im
Prinzip jeder jederzeit beenden kann, man aber nicht die Übersicht
verlieren will? Wenn der Kindprozeß stirbt, schickt er dem
Elternprozeß ein Signal, SIGCHLD.
Solange der Elternprozeß dieses Signal nicht annimmt, kann der Kindprozeß
nicht aus der Prozeßtabelle entfernt werden, obwohl es nicht mehr aktiv
ist. Solche Prozesse nennt man "Zombie-Prozesse". Erst wenn der Elternprozeß
mit waitpid() oder wait() das Signal des Kindes
beachtet und dessen Rückgabewert entgegengenommen hat, wird das Kind
aus der Prozeßtabelle entfernt. Stirbt hingegen der Elternprozeß und
verwaist das Kind, so erbt der Prozeß mit Prozeß-ID 1 - in aller Regel
init - diesen Prozeß. Die PPID wird entsprechend abgeändert.
Wenn man für SIGCHLD einen Signalhandler setzt, kann das Problem ganz einfach
gelöst werden. Dazu müssen wir uns aber zuerst mit Signalen beschäftigen.
Signale sind die wohl einfachste Form der Prozeßkommunikation. Jeder Prozeß
kann seinen Kindern und auch allen anderen Prozessen desselben Anwenders Signale
schicken. Ein Prozeß mit Root-Rechten kann jedem Prozeß Signale
schicken. Wann immer ein Prozeß vom Scheduler aktiviert wird oder vom Aufruf
einer Systemfunktion zurückkehrt, wird nachgesehen, ob irgendwelche Signale
angekommen sind, und gegebenenfalls die hierfür eingetragenen Signalhandler
aktiviert. Man kann alle Signale mit Ausnahme von SIGKILL und SIGSTOP ignorieren.
Signalhandler, laufen unter besonderen Bedingungen, weshalb sie so klein und einfach
wie möglich gehalten werden sollten.
Sehen wir uns hierzu das folgende Perl-Programm an:
#!/usr/bin/perl -w
my $count = 0;
$SIG{INT} = sub
{
$count++;
warn "Oops! Das ist schon die Unterbrechung $count\n";
};
while ($count < 5)
{
print "Ratzepuehh!\n";
sleep(3);
}
Das Programm gibt alle drei Sekunden "Ratzepuehh!" aus und schläft dann
weiter. Immer wenn die Taste Control-C gedrückt wird, löst dies
einen Interrupt aus. Für diesen Interrupt (Signal INT) wurde ein
Signalhandler installiert, der eine Warnung ausgibt und die ANzahl der Unterbrechungen
hochzählt. Nach mehr als fünf Umterbrechungen beendet sich der Prozess.
Der magische Hash %SIG enthält zu jedem Signal eine Subroutine,
die aufgerufen wird, wenn dieses Signal ankommt. Mit der Anweisung
$SIG{INT} = sub { ... } setzen wir einen eigenen Signalhandler für
das Signal INT. Für die Aktionen eines Signalhandlers bieten sich
folgende Möglichkeiten:
$SIG{INT} = 'IGNORE'; | Ignoriert SIGINT |
$SIG{INT} = 'DEFAULT'; | Setzt die Default-Action für SIGINT |
$SIG{INT} = \&catcher; | führt den Code in sub catcher aus |
$SIG{INT} = sub { $counter++; }; | führt den Code der anonymen sub aus |
Signale sind asynchrone Ereignisse. Das laufende Programm wird unterbrochen und
die Anweisungen im Signalhandler werden ausgeführt. Je nachdem wo sich Ihr
Programm im Code gerade befindet, wenn ein Signal eintritt, können unterschiedliche
Ereignisse auftreten. Perl ist nicht reentrant, zumindest nicht im Bereich der
Low-Level-Systemzugriffe. Wenn ein Signal auftaucht, während Perl seine interne
Datenstruktur ändert (z.B. malloc) ist ein Absturz die Regel. Auch deshalb
sollten Signalhandler so kurz und einfach wie möglich sein. Probieren wir
ein Beispiel (in Perl) mit mehreren Prozessen:
#!/usr/bin/perl -w
$|=1;
my ($i, $pid, $time);
my %child_pids = (); # Hash fuer Prozessnummern
# Signalhandler fuer Childs
$SIG{CHLD} = sub
{
my $pid=wait();
print "Terminated: $pid\n";
delete $child_pids{$pid};
};
# Machen wir mal 10 Kinder
for($i = 0; $i < 10; $i++)
{
$pid = fork();
if($pid == 0) # KIND
{
sleep rand(20);
exit(0);
}
else # ELTERN
{
print "$pid wurde gestartet\n";
$child_pids{$pid} = 1; # merken
}
}
# Warten, bis alle Kinder terminiert sind
$time = 0;
while(0 + keys(%child_pids))
{
print "TIME: $time\n";
sleep 1;
$time++;
}
Der Signalhandler wird jedesmal beim Terminieren eines Kindes aufgerufen, da
der Erlternprozeß ein SIGCHLD-Signal erhält. Der Aufruf
von wait() beseitigt dann alle Spuren des Kindes (wobei hier auch
der Rückgabewert ignoriert wird). wait() wartet ja auf das
Ende eines Kindprozesses und liefert dessen ID zurück.
Man sollte erwarten, daß dieses Programm korrekt arbeitet. Es kommt je nach
Rechner häufiger oder seltener vor, daß der Elternprozeß nicht
mitbekommt, daß ein Kind terminiert ist, und am Schluß ewig wartet.
Das hängt damit zusammen, daß
- während ein Signal-Handler läuft, bestimmte Signale blockiert werden,
(insbesondere das durch diesen Handler verarbeitete Signal, sonst müßte
der Signalhandler reentrant sein).
- manchmal zwei gleiche Signale kurz hintereinander wie eins gewertet werden
können, falls der Prozeß nicht dazwischen Gelegenheit hatte, das erste
Signal entgegenzunehmen.
- Ein Kind wird gestoppt und gleich wieder gestartet.
Man kann sich beispielsweise behelfen, indem der Signalhandler passend erweitert wird.
Statt wait() kommt nun waitpid() zum Einsatz. Diese Funktion kann
über einen Parameter im Verhalten gesteuert werden. Werte für diesen Parameter
befinden sich im POSIX-Modul, weshalb dieses im folgenden Programm eingebunden wird.
Der Parameter WNOHANG versetzt waitpid() in den "nonblocking mode".
Die Funktion liefert entweder die ID eines terminierten Kindes oder -1. falls keines
existiert. Ein anderer nützlicher Wert ist WUNTRACED, der PIDs von
gestoppten und terminierten Kindern liefert. Im obigen Programm muß also
nur der Signalhandler geändert werden:
#!/usr/bin/perl -w
use POSIX ":sys_wait_h";
$|=1;
my ($i, $pid, $time);
my %child_pids = ();
$SIG{CHLD} = sub
{
my($pid);
foreach $pid (keys(%child_pids))
{
if(waitpid($pid,WNOHANG))
{
print "Terminated: $pid\n";
delete $child_pids{$pid};
}
}
};
# Machen wir mal 10 Kinder
for($i = 0; $i < 10; $i++)
{
$pid = fork();
if($pid == 0) # KIND
{
sleep rand(20);
exit(0);
}
else # ELTERN
{
print "$pid wurde gestartet\n";
$child_pids{$pid} = 1; # merken
}
}
# Warten, bis alle Kinder terminiert sind
$time = 0;
while(0 + keys(%child_pids))
{
print "TIME: $time\n";
sleep 1;
$time++;
}
Ein grundsätzliches Problem mit Kindprozessen ist, daß man stets damit
rechnen muß, daß Eltern- oder Kindprozeß aus unterschiedlichsten
Gründen verstirbt, und sei es nur, weil der Anwender ihm ein SIGKILL
geschickt hat. Für einen Elternprozeß ist es relativ einfach, verstorbene
Kinder auszumachen. Verwaiste Kinder werden hingegen nicht per Signal benachrichtigt.
BSD und POSIX-konforme Systeme verfügen über verläßliche Signale.
Manche Systeme, z. B. (ältere) System V verfügen über keine
zuverlässige Bibliothek zur Signalbehandlung. Für solche Systeme (und ggf. aus
Portabilitätsgründen) müssen Sie die Signalhandler nach jedem Auftreten
des Signals neu installieren.
#!/bin/perl
# globale Variablen initalisieren
my $sig = '';
# ALLE Signale erhalten einen Signalhandler
@sigs = keys %SIG;
for (@sigs)
{ $SIG{$_} = \&catcher; }
# Signalhandler
sub catcher
{
$sig = shift;
print STDERR "SIGNAL $sig \n";
# reinstall handler
$SIG{$sig} = \&catcher;
}
|
|
|