fork, exec, wait und exit

isotopp image Kristian Köhntopp -
January 7, 2007
a featured image

In de.comp.os.unix.linux.misc fragte jemand:

  • Werden in einem Skript die Befehle streng sequentiell ausgeführt, d.h. der nächste erst bearbeitet, wenn der Vorgänger vollständig ausgeführt ist, oder wird automatisch bei unvollständiger Auslastung des Systems bereits der nächste Befehl angefangen?
  • Läßt sich das Standardverhalten - wie auch immer es sein mag - bei Bedarf ändern?

Wenn man in ein Shellbuch schaut, wird einem an der einen oder anderen Stelle möglicherweise erläutert, daß die Shell jeden Befehl in einem eigenen Prozeß abarbeitet. Dann wiederum fängt man möglicherweise an zu denken und fragt sich, wie das alles zusammenhängt. Sobald man dort angekommen ist, kann man sich mit dem Unix-Prozeßzyklus beschäftigen.

Prozeß und Programm

Ein Programm ist in Unix eine Serie von ausführbaren Maschineninstruktionen auf der Platte. Man kann mit dem Befehl size einen sehr oberflächlichen Blick auf die Struktur des Programmes werfen oder mit objdump sehr viel mehr Detailinformation bekommen. Der Aspekt, der uns hier interessieren soll: Ein Programm ist eine Folge von Anweisungen und Daten (auf der Platte), die möglicherweise einmal ausgeführt werden.

Ein Prozeß ist ein in Ausführung befindliches Programm. Er besteht aus dem Programm selbst (also der Versammlung von Anweisungen und Daten) und dem aktuellen Zustand der Ausführung. Dazu gehört neben der Memory-Map, die sagt wie das Programm und seine Daten im Speicher angeordnet sind auch der Programmzähler, die Prozessorregister und der Stack des Prozesses, aber auch sein Root-Directory, sein aktuelles Verzeichnis, die Umgebungsvariablen und alle offenen Dateien sowie einigen weiteren Dingen.

Unix behandelt Prozesse und Programme als die verschiedenen Dinge, die es sind: Es ist möglich, ein Programm mehr als einmal auszuführen - es ist zum Beispiel möglich, mehr als eine Kopie des Texteditors vi offen zu haben, die zwei unterschiedliche Texte bearbeiten. Programm und (initiale) Daten beider Prozesse sind gleich, aber der Zustand beider Prozesse ist verschieden. Es ist auch möglich, im selben Prozeß nacheinander mehr als ein Programm auszuführen - dazu schmeißt sich das aktuelle Programm in dem Prozeß selbst weg und ersetzt sich durch ein zweites, in diesen Prozeß nachgeladene Programm.

Unix regelt all diese Dinge mit vier sehr einfachen Systemkonzepten - fork(), exec(), wait() und exit().

Usermode und Kernel

Prozeßwechsel: Es wird ein Stück Prozeß 1 abgearbeitet, dann (1) auf Prozeß 2 umgeschaltet. Nach einer Weile wird (2) wieder auf Prozeß 1 zurück geschaltet. Die Ausführung von Prozeß 1 erscheint lückenlos, erfolgt aber in zeitlich nicht zusammenhängenden Intervallen.

Wenn ein Unix-Prozeß eine Systemfunktion aufruft (und noch bei ein paar anderen Gelegenheiten), dann verläßt der betreffende Prozeß seinen Userkontext und betritt den privilegierten Betriebsystemkern, den Kernel. Dort wird die aufgerufene Systemfunktion ausgeführt und danach landet jede dieser Funktionen im Scheduler. Der Scheduler entscheidet dann, welcher Prozeß als nächstes dran kommt und kehrt aus dem Kernel in diesen Prozeß zurück. Das kann unser Ausgangsprozeß oder nach Entscheidung des Schedulers ein anderer Prozeß sein.

Wir halten für die Zwecke diese Textes fest: Jeder Systemaufruf wechselt vom Userkontext in den Kernel. Der einzige Weg aus dem Kernel in einen Userkontext führt durch den Scheduler, und dann kehren wir unter Umständen nicht in den Ausgangsprozeß zurück. Bei jedem Systemaufruf kann ein Prozeß also seine CPU verlieren.

Das ist nicht schlimm, weil dieser andere Prozeß auch irgendwann einmal die CPU aufgeben muß und wir dann in unseren eigenen Prozeß zurück kehren als sei nichts gewesen.

Unser Programm wird also nicht linear abgearbeitet, sondern in kurzen linearen Segmenten, zwischen denen Pausen liegen können. In den Pausen arbeitet die CPU an den Segmenten der anderen Prozesse, die ebenfalls lauffähig sind.

fork() und exit()

In traditionellem Unix ist der Systemaufruf fork() die einzige Methode, einen neuen Prozeß zu erzeugen. Der neue Prozeß enthält eine Kopie des laufenden Programmes. Er hat eine neue Prozeß-ID und die Prozeß-ID des Erzeugers ist als seine Parent Prozeß-ID (PPID) eingetragen.

Im Parent-Prozeß kehrt fork() mit der PID des neuen Prozesses als Ergebnis zurück. Der neue Prozeß kehrt ebenfalls aus dem Systemaufruf fork() zurück, liefert dort aber das Resultat 0.

Der Systemaufruf fork() ist also insofern besonders, als daß er einmal betreten wird, aber zweimal verlassen wird: Einmal im Elternprozeß und einmal im neu erzeugten Kindprozeß. fork() erhöht die Anzahl der laufenden Prozesse im System um eins.

Jeder Unix-Prozeß beginnt seine Existenz also, indem er aus einem fork()-Systemaufruf spontan zurückkehrt und ein Programm ausführt, daß eine Kopie des Elternprogrammes ist. Sein Schicksal unterscheidet sich vom Schicksal des Elternprozesses, weil das Ergebnis des fork()-Aufrufes unterschiedlich ist (0 statt der PID des Kindes) und man dies zur Verzweigung nutzen kann.

In Code:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

main(void) {
        pid_t pid = 0;

        pid = fork();
        if (pid == 0) {
                printf("Ich bin der Kindprozess.\n");
        }
        if (pid > 0) {
                printf("Ich bin der Elternprozess, das Kind ist %d.\n", pid);
        }
        if (pid < 0) {
                perror("In fork():");
        }

        exit(0);
}

und

kris@linux:/tmp/kris> make probe1
cc     probe1.c   -o probe1
kris@linux:/tmp/kris> ./probe1
Ich bin der Kindprozess.
Ich bin der Elternprozess, das Kind ist 16959.

Wir vereinbaren also eine Variable pid vom Typ pid_t.

Diese Variable speichert das Ergebnis des Systemaufrufes fork() und mit Hilfe dieses Wertes aktivieren wir entweder das eine (“Ich bin der Kindprozeß”) oder das andere (“Ich bin der Elternprozeß”) if().

Starten wir das Programm, erhalten wir zwei Ausgaben. Da innerhalb eines Prozesses nur ein Status existieren kann und nur eines der beiden if() betreten werden kann, wir aber zwei Ausgaben erhalten haben, müssen wir zwei Prozesse erzeugt haben.

Indem wir das Ergebnis von getpid() druckten, könnten wir das sogar noch anschaulicher zeigen.

Der Systemaufruf fork() wird einmal betreten, aber zweimal verlassen und erhöht die Anzahl der Prozesse im System um eins. Nach dem Ablauf unseres Programmes ist die Anzahl der Prozesse im System aber wieder genauso hoch wie vor dem Aufruf des Programmes. Es muß also einen weiteren Systemaufruf geben, der die Anzahl der Prozesse im System um eins erniedrigt.

Dieser Aufruf ist exit().

exit() wird einmal betreten und nie verlassen. Er verkleinert die Anzahl der Prozesse im System um eins. exit() liefert außerdem einen Exitstatus, den der Elternprozeß abholen kann (oder gar muß) und der ihn über das Schicksal seines Kindes informiert.

In unserem Beispiel enden alle Varianten unseres Programmes mit exit() - wir rufen exit() also im Elternprozeß und im Kindprozeß auf, beenden also zwei Prozesse. Das können wir nur deswegen tun, weil unser Elternprozeß auch ein Kindprozeß ist und zwar ein Kind der Shell.

Die Shell arbeitet also genau wie wir:

bash (16957) --- erzeugt durch fork() ---> bash (16958) --- wird zu ---> probe1 (16958)

probe1 (16958) --- erzeugt durch fork() ---> probe1 (16959) --> exit()
   |
   +---> exit()

exit() schließt alle Dateien und Internetverbindungen eines Prozesses, gibt allen Speicher frei und beendet dann den Prozeß. Der Parameter von exit(), der Exitstatus, wird an den Elternprozeß zurückgegeben.

wait()

Der Kindprozeß endet durch ein exit(0). Die 0 ist der Exitstatus unseres Programmes und steht nun zur Abholung bereit. Wir müssen im Elternprozeß den Exitstatus abholen. Dies geschieht mit der Systemfunktion wait().

In Code:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

#include <sys/types.h>
#include <sys/wait.h>

main(void) {
        pid_t pid = 0;
        int   status;

        pid = fork();
        if (pid == 0) {
                printf("Ich bin der Kindprozess.\n");
                sleep(10);
                printf("Ich bin der Kindprozess 10 Sekunden spaeter.\n");
        }
        if (pid > 0) {
                printf("Ich bin der Elternprozess, das Kind ist %d.\n", pid);
                pid = wait(&status);
                printf("Ende des Prozesses %d: ", pid);
                if (WIFEXITED(status)) {
                        printf("Der Prozess wurde mit exit(%d) beendet.\n", WEXITSTATUS(status));
                }
                if (WIFSIGNALED(status)) {
                        printf("Der Prozess wurde mit kill -%d beendet.\n", WTERMSIG(status));
                }
        }
        if (pid < 0) {
                perror("In fork():");
        }

        exit(0);
}

und

kris@linux:/tmp/kris> make probe2
cc     probe2.c   -o probe2
kris@linux:/tmp/kris> ./probe2
Ich bin der Kindprozess.
Ich bin der Elternprozess, das Kind ist 17399.
Ich bin der Kindprozess 10 Sekunden spaeter.
Ende des Prozesses 17399: Der Prozess wurde mit exit(0) beendet.

Die Variable status wird dem Systemaufruf wait() als Referenzparameter mit übergeben und von diesem überschrieben. Neben dem Exitstatus finden wir dort auch noch weitere Informationen über den Grund des Programmendes hinterlegt. Zur Decodierung stellt das System eine Reihe von Prädikaten wie WIFEXITED() oder WIFSIGNALED() zur Abfrage bereit und Extraktoren wie WEXITSTATUS() und WTERMSIG(). wait() gibt außerdem die Prozeß-ID des Prozesses zurück, der beendet wurde.

wait() hängt im Elternprozeß so lange bis entweder ein Signal eintrifft oder ein Kindprozeß beendet wird.

Das Programm init mit der PID 1 macht übrigens den ganzen lieben langen Tag nix anderes: Es hängt im wait() und frühstückt die ihm zugeworfenen Exitstati ab, um sie zu verwerfen. Außerdem liest es die /etc/inittab und startet die dort konfigurierten Programme. Ist eines dieser Programme auf Respawn gesetzt und wird beendet, wird es von init neu gestartet.

Beendet sich ein Kindprozeß, ohne daß der Elternprozeß ein wait() macht, zerstört exit() schon einmal alle Datenstrukturen des Kindprozesses, kann jedoch den Prozeßlisteneintrag des Prozesses noch nicht wegwerfen, denn hier steht der Exitstatus des Kindes drin. Es könnte ja nun sein, daß der Elternprozeß sich irgendwann entschließt, ein wait() auszuführen und dann muß der Exitstatus ja bereitstehen.

Der Kindprozeß ist also bereits tot - er hat exit() ausgeführt und alle Ressourcen freigegeben, kann aber noch nicht sterben, weil ja der Elternprozeß den Status noch nicht abgeholt hat. Unix nennt so einen Prozeß einen Zombie-Prozeß. Zombies werden in der Prozeßliste sichtbar, wenn ein Prozeßerzeuger falsch programmiert ist und nicht ausreichend wait() aufruft.

Anders herum ist es auch möglich, daß ein Kindprozeß weiter läuft, während ein Elternprozeß beendet wird. Dann wird die Parent Prozeß-ID (PPID) des Kindes von der PID des Elternprozesses auf die Konstante 1 geändert, oder in anderen Worten - init erbt den Prozeß.

Beendet sich das Kind, empfängt init den Exitstatus des Kindes, denn init hängt ja sowieso dauernd im wait. Dadurch wird die Entstehung eines Zombies in diesem Fall verhindert.

Wenn die Anzahl der Prozesse im System über die Laufzeit des System im Mittel konstant ist, dann ist die Anzahl der fork(), exit() und wait()-Aufrufe im System ebenfalls im Mittel gleich, denn für jedes fork() muß irgendwann einmal ein exit() gemacht werden und für jedes exit() muß der Elternprozeß einmal ein wait() machen (In Wirklichkeit ist die Situation wegen einiger anderer Regeln noch ein wenig komplizierter, aber erst einmal soll dies hier genügen).

Wir haben also ein sauberes fork-exit-wait-Dreieck.

exec()

So wie fork() Prozesse erzeugt, so lädt exec() Programme in einen Prozeß.

In Code:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

#include <sys/types.h>
#include <sys/wait.h>

main(void) {
        pid_t pid = 0;
        int   status;

        pid = fork();
        if (pid == 0) {
                printf("Ich bin der Kindprozess.\n");
                execl("/bin/ls", "ls", "-l", "/tmp/kris", (char *) 0);
                perror("In exec(): ");
        }
        if (pid > 0) {
                printf("Ich bin der Elternprozess, das Kind ist %d.\n", pid);
                pid = wait(&status);
                printf("Ende des Prozesses %d: ", pid);
                if (WIFEXITED(status)) {
                        printf("Der Prozess wurde mit exit(%d) beendet.\n", WEXITSTATUS(status));
                }
                if (WIFSIGNALED(status)) {
                        printf("Der Prozess wurde mit kill -%d beendet.\n", WTERMSIG(status));
                }
        }
        if (pid < 0) {
                perror("In fork():");
        }

        exit(0);
}
kris@linux:/tmp/kris> make probe3
cc     probe3.c   -o probe3

kris@linux:/tmp/kris> ./probe3
Ich bin der Kindprozess.
Ich bin der Elternprozess, das Kind ist 17690.
total 36
-rwxr-xr-x 1 kris users 6984 2007-01-05 13:29 probe1
-rw-r--r-- 1 kris users  303 2007-01-05 13:36 probe1.c
-rwxr-xr-x 1 kris users 7489 2007-01-05 13:37 probe2
-rw-r--r-- 1 kris users  719 2007-01-05 13:40 probe2.c
-rwxr-xr-x 1 kris users 7513 2007-01-05 13:42 probe3
-rw-r--r-- 1 kris users  728 2007-01-05 13:42 probe3.c
Ende des Prozesses 17690: Der Prozess wurde mit exit(0) beendet.

Hier wird im Sohnprozeß der Code von probe3 weggeworfen (Das perror("In exec():") wird niemals ausgeführt) und durch den angegebenen Aufruf von ls ersetzt. In der Ausführung erkennen wir, daß probe3 wartet, bis das ls sich mit exit() beendet hat und dann seine eigene Ausführung danach fortsetzt.

Als Shellscript

Die Beispiele oben operieren in C. In bash sieht es so aus:

kris@linux:/tmp/kris> cat probe1.sh
#! /bin/bash --

echo "Starte Kindprozess"
sleep 10 &
echo "Der Kindprozess hat die ID $!"
echo "Der Elternprozess hat die ID $$"
echo "$(date): Elternprozess geht schlafen."
wait
echo "Der Kindprozess $! hat den Exit-Status $?"
echo "$(date): Elternprozess ist aufgewacht."

kris@linux:/tmp/kris> ./probe1.sh
Starte Kindprozess
Der Kindprozess hat die ID 18071
Der Elternprozess hat die ID 18070
Fri Jan  5 13:49:56 CET 2007: Elternprozess geht schlafen.
Der Kindprozess 18071 hat den Exit-Status 0
Fri Jan  5 13:50:06 CET 2007: Elternprozess ist aufgewacht.

Und hier beobachten wie die Shell bei der Ausführung von Kommandos:

kris@linux:~> strace -f -e execve,clone,fork,waitpid bash
kris@linux:~> ls
clone(Process 30048 attached
child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD,
child_tidptr=0xb7dab6f8) = 30048
[pid 30025] waitpid(-1, Process 30025 suspended
 <unfinished ...>
[pid 30048] execve("/bin/ls", ["/bin/ls", "-N", "--color=tty", "-T", "0"],
[/* 107 vars */]) = 0
...
Process 30025 resumed
Process 30048 detached
<... waitpid resumed> [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WSTOPPED
WCONTINUED) = 30048
--- SIGCHLD (Child exited) @ 0 (0) ---
...

Linux verwendet eine Verallgemeinerung von fork() mit dem Namen clone() um einen Kindprozeß zu erzeugen. Daher sehen wir keinen fork(), sondern einen clone()-Aufruf mit einigen Parametern.

Linux verwendet außerdem die Variante waitpid() von wait(), um auf eine bestimmte PID zu warten.

Linux startet außerdem das Programm mit execve() statt mit execl(), aber das ist nur eine andere Anordnung von Parametern. Nach dem Ende von ls (PID 30048) wird der Prozeß 30025 aus dem wait() erweckt und fortgesetzt.

Und hier ist der C-Code der Originalshell aus dem Jahre 1979, mit dem fork() (Man suche nach “case TFORK:” und wundere sich nicht über den Programmierstil von Herrn Bourne). Ja, bash ist schöner - GNU Code oder nicht.

(nach einem News-Artikel von mir)

Share