Tour 6: "processes"

Überblick

Unter UNIX sind Prozesse das "Hausmittel" für parallele Ausführung von Code. Mit den POSIX Threads steht zwar auch eine Thread-Implementierung zur Verfügung, aber im Rahmen dieses Tutorials werden wir Threads nur unter Windows benutzen.

Prozesse zeichnen sich durch ihren eigenen Adressraum aus, d.h. ohne ausdrückliche Inter-Prozess-Kommunikation (IPC) kann ein Prozess keinen Einfluss auf andere Prozesse ausüben. Das heißt insbesondere, dass globale Variablen nicht geteilt werden und somit erst mal keine gemeinsamen Datenstrukturen existieren. Prozesse eignen sich daher für Aufgaben, bei denen Clients unabhängig voneinander bedient werden, wie etwa bei einem Webserver.

Prozesse werden quasi-parallel (Single-Core-Systeme) bzw. echt-parallel (Multi-Core-Systeme) abgearbeitet. Daher sind blockierende Systemaufrufe normalerweise kein Problem. Sollen dennoch mehrere Sockets bearbeitet werden, bietet sich der Einsatz von select() an.

Prozesse finden insbesondere als Hintergrundprozesse (daemons) Anwendung. Dies sind spezielle Prozesse, die sich nach dem Start vom Kontroll-Terminal ablösen und im Hintergrund weiter laufen, um z.B. auf Server-Sockets Verbindungen anzunehmen. Da diese Techniken eher in Richtung allgemeine Systemprogrammierung gehen werden sie nicht mehr in den Socket-Tipps behandelt. Auf der Seite Wie geht's weiter? gibt es ein paar Hinweise auf weiterführende Literatur.

Erzeugen neuer Prozesse

Prozesse werden mit der Funktion fork() erzeugt. Diese Funktion ist etwas Besonderes, da sie ein Mal aufgerufen wird, aber zwei Mal zurückkehrt, nämlich ein Mal für den Elternprozess, der aufgerufen hat, und ein Mal für den neu erzeugten Kindprozess. Die Prozesse können anhand des Rückgabewerts erkennen, wer sie sind:

Im Fehlerfall erhält der Elternprozess -1 zurück und es gibt keinen Kindprozess. Seine eigene Prozess-ID kann der Kindprozess via getpid() erfragen, aber diese Information wird tatsächlich eher selten benötigt.

pid_t id = fork();
if (id > 0)
{
        /* parent process */
}
else if (id == 0)
{
        /* child process */
}
else if (id == -1)
{
        fprintf(stderr, "fork() failed: %s\n", strerror(errno));
}

Ansonsten laufen beide Prozesse direkt nach dem Aufruf von fork() weiter. Hier unterscheidet sich die Auffassung von Prozessen fundamental von der unter Windows, bei der Prozesse immer neue Programme von Beginn an ausführen. Unter UNIX ist der neue Prozess eine identische Kopie des Elternprozesses, alle bereits verfügbaren Ressourcen wie z.B. geöffnete Dateien, allokierter Speicher oder eben Sockets sind ebenfalls verfügbar.

Für Sockets und generell Dateideskriptoren unterhält das System einen Referenzzähler. Erst wenn die letzte Referenz abgegeben wird, wird die Ressource tatsächlich geschlossen bzw. freigegeben. Das heißt bei vererbten Socket-Verbindungen, dass sowohl der Elternprozess als auch der Kindprozess, der den Client bedienen soll, den Socket schließen muss, damit die Verbindung abgebaut wird.

Beenden von Prozessen

Prozesse können sich selbst beenden, z.B. durch einen Aufruf von exit() oder durch das Verlassen der Hauptfunktion main(). Prozesse können aber auch "gewaltsam" beendet werden indem ihnen das Signal SIGKILL gesendet wird. Das gewaltsame Beenden ist problematisch, weil überlichweise nicht bekannt ist, in welchem Zustand sich der Kindprozess befindet, und dadurch unvorhersehbare Dinge passieren können. Besser ist eine Kombination beider Ansätze: wenn der Elternprozess das Ende des Kindprozesses wünscht, dann sendet er ein spezielles ("nicht-tödliches") Signal, woraufhin sich der Kindprozess baldmöglicht selbst beendet.

Wenn ein Prozess beendet wird, dann erhält der Elternprozess das Signal SIGCHLD. Als Reaktion darauf kann dieser die Funktion wait() aufrufen. Dies ist notwendig, um den Beendigungsstatus des Kinds abzurufen. Passiert das nicht, hält das System diese Informationen weiterhin bereit und kann die damit verbundenen Ressourcen nicht freigeben. Solche tote, aber nicht richtig versorgten Prozesse werden Zombies genannt und tauchen in einer mit ps erzeugten Prozessliste als <defunct> auf.

Ein Aufruf von wait() ohne beendeten Kindprozess blockiert. Wenn die Reaktion auf SIGCHLD keine Option ist, dann kann auch periodisch mit waitpid() und der Option WNOHANG auf tote Kinder geprüft werden. Des weiteren gibt es ein Verfahren, das double fork genannt wird. Dabei wird quasi ein Zwischenprozess erzeugt, der nur einen weiteren Kindprozess erzeugt und sich danach beendet (worauf der ursprüngliche Elternprozess warten kann, weil es ja gleich passiert). Dadurch ist der Enkelprozess verwaist und wird vom init-Prozess "adoptiert". Dieser ruft automatisch wait() auf, wenn sich eins seiner Kinder beendet, sodass keine Zombies entstehen.

Starten anderer Programme

Bis jetzt haben wir nur Prozesse erzeugt, die weiterhin das gleiche Programm ausführen. Oft möchte man aber ein anderes Programm ausführen. Dazu gibt es die Funktionen der exec-Familie, z.B. execl(). Diese Funktionen tauschen das aktuelle Prozess-Abbild gegen ein neues aus, d.h. führen ein anderes Programm im gleichen Prozess aus. Diese Formulierung ist keine Spitzfindigkeit, sondern von elementarer Bedeutung, denn man kann den Prozess vorher "konfigurieren" und somit dem zu startenden Programm neue Tricks beibringen. Im Beispiel-Programm wird das Programm ls gestartet, aber dessen Ausgabe über einen verbundenen Socket an den Client geschickt. Zu diesem Zweck wird im Kindprozess noch vor der Überlagerung mit dem neuen Prozess-Abbild der Socket mittels dup2() auf stdout dupliziert.


Zurück