Programm, Prozess, Thread – und Task
Diese Begriffe werden oft durcheinandergeworfen, deshalb zuerst eine Abgrenzung:
- Ein Programm ist eine Datei auf dem Datenträger, etwa
/usr/bin/vim, also passiver Code und Daten. - Ein Prozess ist ein Programm in Ausführung. Dazu gehören ein eigener virtueller Adressraum, offene Dateien, Zugriffsrechte, Signal-Einstellungen und mindestens ein Ausführungsstrang. Dasselbe Programm kann als viele unabhängige Prozesse gleichzeitig laufen.
- Ein Thread ist ein Ausführungsstrang mit eigenem Befehlszähler, eigenen Registern und eigenem Stack. Mehrere Threads eines Prozesses teilen sich Adressraum und Ressourcen.
Linux kennt intern weder „Prozess“ noch „Thread“ als eigene Objekte, sondern nur den TaskTaskDie Einheit, die der Linux-Scheduler verwaltet. Prozesse und Threads sind für den Kernel gleichermaßen Tasks (struct task_struct)., beschrieben durch eine struct task_struct. Ein Prozess mit einem Thread ist ein Task. Ein Prozess mit acht Threads besteht aus acht Tasks, die sich bestimmte Strukturen teilen. Ob zwei Tasks „Threads desselben Prozesses“ oder „zwei Prozesse“ sind, hängt allein davon ab, was sie gemeinsam nutzen:
PID und TGID
Jeder Task hat eine eigene Nummer, die PID. Alle Threads eines Prozesses tragen zusätzlich dieselbe Thread Group ID (TGID), nämlich die PID des ersten Threads. Was im User Space als „Prozess-ID“ erscheint, ist in Wahrheit die TGID:
| Aufruf | liefert im Kernel | Bedeutung im User Space |
|---|---|---|
getpid() | tgid | ID des Prozesses (für alle Threads gleich) |
gettid() | pid | ID dieses einzelnen Threads |
Für einen Prozess mit nur einem Thread sind beide Werte gleich. In /proc hat jeder Prozess ein Verzeichnis /proc/<tgid>/, seine Threads stehen darunter in /proc/<tgid>/task/<pid>/.
Die zentrale Datenstruktur: task_struct
task_struct ist in include/linux/sched.h definiert und eine der größten Strukturen des Kernels: mehrere hundert Felder, insgesamt mehrere Kilobyte pro Task. Die wichtigsten Felder lassen sich in Gruppen einteilen:
| Gruppe | Felder (Auswahl) | Inhalt |
|---|---|---|
| Zustand | __state, exit_state, flags | läuft, schläft, beendet … |
| Identität | pid, tgid, comm[16] | Nummern und Kurzname (wie in ps) |
| Verwandtschaft | real_parent, parent, children, sibling, group_leader | Prozessbaum und Thread-Gruppe |
| Scheduling | prio, static_prio, policy, se, on_cpu | Priorität, Scheduling-Klasse, Laufzeitstatistik (Kapitel 6) |
| Speicher | mm, active_mm | Adressraum (struct mm_struct, Kapitel 7) |
| Dateien | fs, files | Arbeits- und Wurzelverzeichnis, Tabelle der offenen Dateien |
| Signale | signal, sighand, blocked, pending | Signal-Handler, blockierte und ausstehende Signale (Kapitel 14) |
| Rechte | cred | Benutzer- und Gruppen-IDs, Capabilities (Kapitel 15) |
| Isolation | nsproxy, cgroups | Namespaces und Ressourcengruppen, die Grundlage von Containern |
| CPU-Zustand | thread, stack | gesicherte Register beim Kontextwechsel, Kernel-Stack |
Die Zeiger mm, files, fs und sighand sind genau die Strukturen aus dem Diagramm oben: Threads zeigen auf dieselben Objekte, getrennte Prozesse auf eigene. Jedes dieser Objekte hat einen Referenzzähler und wird erst freigegeben, wenn der letzte Task es loslässt.
current: wer bin ich?
Im Kernel-Code begegnet man ständig dem Ausdruck current. Er liefert einen Zeiger auf die task_struct des Tasks, in dessen Auftrag der Code gerade läuft. Auf x86-64 steht dieser Zeiger in einer Per-CPU-Variablen, die beim Kontextwechsel aktualisiert wird. Der Zugriff kostet dadurch nur einen einzigen Speicherzugriff.
/* Beispiel: Name und PID des aufrufenden Prozesses ausgeben */
pr_info("aufgerufen von %s (PID %d)\n", current->comm, task_tgid_nr(current));
Prozesse erzeugen: fork(), clone() und execve()
Unix trennt das Erzeugen eines neuen Prozesses in zwei Schritte, die zusammen erstaunlich flexibel sind:
fork()erzeugt eine nahezu identische Kopie des aufrufenden Prozesses. Beide laufen danach an derselben Stelle weiter. Der einzige Unterschied ist der Rückgabewert: Der Elternprozess erhält die PID des Kindes, das Kind erhält 0.execve()ersetzt im aufrufenden Prozess das laufende Programm durch ein neues. PID, offene Dateien und viele Einstellungen bleiben dabei erhalten.
Dazwischen kann das Kind seine Umgebung vorbereiten, bevor das neue Programm startet, zum Beispiel Dateideskriptoren umlenken. Genau so setzt eine Shell ls > liste.txt um:
#include <fcntl.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
pid_t pid = fork();
if (pid == 0) { /* Kind */
int fd = open("liste.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO); /* Standardausgabe umlenken */
close(fd);
execlp("ls", "ls", "-l", (char *)NULL); /* kehrt nur bei Fehler zurück */
perror("exec");
_exit(127);
}
int status; /* Eltern */
waitpid(pid, &status, 0); /* auf das Kind warten */
printf("Kind %d beendet mit Status %d\n", pid, WEXITSTATUS(status));
return 0;
}
Was im Kernel passiert
In der glibc ist fork() heute ein Aufruf des allgemeineren Systemaufrufs clone(). Im Kernel landen fork(), vfork(), clone() und clone3() alle in derselben Funktion, kernel_clone in kernel/fork.c:
copy_processlegt eine neuetask_structan, zunächst als Kopie der Eltern-Struktur.- Für jede Ressource entscheiden die clone-Flags, ob sie geteilt (Referenzzähler erhöhen) oder kopiert wird. Für den Adressraum übernimmt das
copy_mm(), für offene Dateiencopy_files()usw. - Der neue Task bekommt eine PID, wird in den Prozessbaum eingehängt und erhält eigene Scheduling-Daten.
wake_up_new_taskübergibt ihn dem Scheduler. Ab jetzt kann er laufen.
Copy-on-Write: warum fork() so schnell ist
Ein Prozess kann Gigabytes an Speicher belegen. Würde fork() all das kopieren, wäre es extrem langsam, und meist umsonst, weil das Kind ohnehin gleich execve() aufruft. Linux kopiert deshalb nur die Seitentabellen und teilt die Seiten selbst nach dem Prinzip Copy-on-WriteCopy-on-Write (CoW)Technik, bei der mehrere Nutzer zunächst dieselbe Speicherseite verwenden. Erst wenn einer schreibt, erhält er eine eigene Kopie. Macht fork() schnell und spart Speicher.:
-
Schritt 1 von 4
Ausgangslage
Der Elternprozess hat drei Bereiche: Code (nur lesbar und ausführbar), Daten und Stack (beide beschreibbar). Seine Seitentabelle verweist auf drei physische Seiten A, B und C.
-
Schritt 2 von 4
fork(): nur die Seitentabellen werden kopiert
Der Kindprozess erhält eine Kopie der Seitentabelle, die auf dieselben physischen Seiten zeigt. Alle beschreibbaren Seiten werden in beiden Prozessen auf „nur lesbar“ gesetzt, und der Kernel zählt mit, dass jede Seite jetzt zwei Nutzer hat (×2). Kopiert wurden keine Daten – deshalb ist fork() auch bei großen Prozessen schnell.
-
Schritt 3 von 4
Ein Schreibzugriff löst die Kopie aus
Das Kind schreibt auf seinen Stack. Weil die Seite schreibgeschützt ist, meldet die MMU einen Seitenfehler. Der Kernel erkennt eine Copy-on-Write-Seite, legt eine Kopie von C an, trägt sie beim Kind als beschreibbar ein und lässt den Befehl erneut ausführen. Schreibt später der Elternprozess auf C, ist er der einzige Nutzer – der Kernel muss dann nichts kopieren, sondern nur den Schreibschutz aufheben.
-
Schritt 4 von 4
exec(): das Kind lädt ein neues Programm
Ruft das Kind direkt danach execve() auf, verwirft der Kernel seinen kompletten Adressraum und baut für das neue Programm einen neuen auf. Die mitbenutzten Seiten A und B gehören wieder allein dem Elternprozess. Fast nichts wurde je kopiert – genau deshalb ist die Kombination fork() + exec() so effizient.
Copy-on-Write ist ein Paradebeispiel dafür, wie der Kernel Seitenfehler gezielt einsetzt: Der Schreibschutz ist kein Fehler, sondern ein Signal an den Kernel, im richtigen Moment einzugreifen.
clone(): ein Systemaufruf für Prozesse und Threads
clone() bekommt eine Bitmaske von Flags, die für jede Ressource festlegen, ob sie geteilt wird:
| Flag | gesetzt bedeutet … |
|---|---|
CLONE_VM | gemeinsamer Adressraum (mm) |
CLONE_FILES | gemeinsame Tabelle offener Dateien |
CLONE_FS | gemeinsames Arbeits- und Wurzelverzeichnis, gemeinsame umask |
CLONE_SIGHAND | gemeinsame Signal-Handler |
CLONE_THREAD | gleiche Thread-Gruppe (gleiche TGID), also ein Thread desselben Prozesses |
CLONE_NEWPID, CLONE_NEWNET … | neue Namespaces, eine der Grundlagen von Containern (Kapitel 15) |
Ein fork() ist ein clone() ohne diese Flags. pthread_create() ruft clone() dagegen etwa mit CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS | … auf. Zwischen diesen Extremen ist jede Kombination möglich. Container-Laufzeiten nutzen genau das.
Tiefer eintauchenvfork(), posix_spawn() und clone3()
vfork() stammt aus der Zeit vor Copy-on-Write. Das Kind teilt sich den Adressraum mit dem Elternprozess, und der Elternprozess wird angehalten, bis das Kind execve() oder _exit() aufruft. Das spart sogar das Kopieren der Seitentabellen, ist aber gefährlich: Ändert das Kind Variablen, ändert es sie auch für die Eltern.
posix_spawn() fasst „neuen Prozess starten und Programm laden“ in einem Aufruf zusammen. Die glibc setzt es intern mit clone(CLONE_VM | CLONE_VFORK) und einem eigenen kleinen Stack um. Das ist besonders für Prozesse mit sehr großem Adressraum schneller als fork() + exec().
clone3() (seit Linux 5.3) ist die erweiterbare Nachfolgerin von clone(). Statt einer langen Argumentliste erhält sie einen Zeiger auf eine struct clone_args. Neue Optionen lassen sich ergänzen, ohne einen weiteren Systemaufruf einzuführen. Ein Beispiel ist CLONE_PIDFD, das einen Dateideskriptor für den neuen Prozess liefert, mit dem sich Wettlaufsituationen bei PIDs vermeiden lassen.
execve(): ein neues Programm laden
execve() ist das Gegenstück zu fork(). Der Prozess bleibt derselbe, aber sein Inhalt wird ausgetauscht:
- Der Kernel öffnet die Programmdatei und prüft die Rechte.
- Ein passender Binärformat-Handler übernimmt: für ELF-Dateien
binfmt_elf, für Skripte mit#!binfmt_script. Überbinfmt_misclassen sich weitere Formate registrieren, etwa für Java- oder Windows-Programme. - Der alte Adressraum wird verworfen und ein neuer angelegt. Die Segmente der ELF-Datei werden eingeblendet (nicht geladen, das erledigen später Seitenfehler).
- Auf dem neuen Stack legt der Kernel Argumente (
argv), Umgebungsvariablen (envp) und den Auxiliary Vector mit Informationen für das Programm ab. - Braucht das Programm Bibliotheken, startet zuerst der dynamische Linker (z. B.
/lib64/ld-linux-x86-64.so.2), der sie einbindet und dann zumain()springt.
Offene Dateideskriptoren bleiben über execve() hinweg geöffnet, außer sie sind mit O_CLOEXEC markiert. Darauf beruht die Umleitung im Beispiel oben.
Der Lebenszyklus eines Tasks
Vom Erzeugen bis zum Ende durchläuft ein Task verschiedene Zustände:
ps-STAT | Zustand im Kernel | Bedeutung |
|---|---|---|
R | TASK_RUNNING | läuft gerade oder wartet in der Run-Queue auf eine CPU |
S | TASK_INTERRUPTIBLE | schläft und wartet auf ein Ereignis; Signale wecken ihn auf |
D | TASK_UNINTERRUPTIBLE | schläft und ignoriert Signale, meist während Festplatten-I/O |
T | __TASK_STOPPED | angehalten, z. B. mit Strg+Z oder SIGSTOP |
t | __TASK_TRACED | angehalten von einem Debugger |
Z | EXIT_ZOMBIE | beendet, Exit-Status noch nicht abgeholt |
I | TASK_IDLE | untätiger Kernel-Thread (zählt nicht zur Systemlast) |
Warum gibt es den Zustand D? Manche Operationen lassen sich nicht sinnvoll mittendrin abbrechen, etwa wenn ein Treiber auf die Antwort der Hardware wartet. Solche Tasks sind nicht einmal mit kill -9 zu beenden. Hängen viele Prozesse lange in D, deutet das meist auf ein Problem mit Datenträger, Netzwerkdateisystem (NFS) oder Treiber hin. Als Kompromiss gibt es TASK_KILLABLE: Der Task ignoriert alle Signale außer tödlichen.
Schlafen und Aufwecken
Wartet ein Task auf etwas, etwa auf Daten von der Festplatte, einen Tastendruck oder eine freigegebene Sperre, legt er sich mit folgendem Muster schlafen:
- Er trägt sich in eine Warteschlange (wait queue) für das Ereignis ein.
- Er setzt seinen Zustand auf
TASK_INTERRUPTIBLEoderTASK_UNINTERRUPTIBLE. - Er ruft
schedule()auf und gibt damit die CPU ab.
Tritt das Ereignis ein, zum Beispiel weil ein Interrupt meldet, dass die Daten da sind, ruft der zuständige Code wake_up() für die Warteschlange auf. Die wartenden Tasks werden wieder TASK_RUNNING, kommen in die Run-Queue, und irgendwann wählt der Scheduler sie aus.
Beenden: Zombies und Waisen
Ein Prozess endet durch exit() (bzw. das Ende von main()) oder durch ein Signal. Im Kernel räumt do_exit auf: Es gibt Adressraum, offene Dateien und andere Ressourcen frei und benachrichtigt den Elternprozess mit dem Signal SIGCHLD.
Vollständig verschwinden kann der Task aber noch nicht. Der Elternprozess hat ein Recht darauf, den Exit-Status zu erfahren. Bis er ihn mit wait() oder waitpid() abholt, bleibt vom Task ein kleiner Rest übrig: ein ZombieZombie-ProzessBeendeter Prozess, dessen Exit-Status der Elternprozess noch nicht mit wait() abgeholt hat. Belegt keine Ressourcen außer seinem Eintrag in der Prozesstabelle. (Zustand Z). Ein Zombie belegt keinen Speicher für Programmdaten mehr, aber eine PID und einen Eintrag in der Prozesstabelle.
- Zombies loswerden: Ein Zombie ist schon tot,
killwirkt also nicht. Entweder holt der Elternprozess den Status ab, oder der Elternprozess endet selbst. - Waisen: Endet ein Elternprozess vor seinen Kindern, werden diese adoptiert, und zwar von PID 1 oder von einem Prozess, der sich mit
prctl(PR_SET_CHILD_SUBREAPER)als „Subreaper“ angemeldet hat, etwasystemd --user. Der Adoptivelternteil holt später den Exit-Status ab, sodass keine dauerhaften Zombies entstehen (Kapitel 3).
Der Prozessbaum
Weil jeder Prozess von einem anderen erzeugt wurde, bilden alle Prozesse einen Baum. An der Wurzel des User Space steht PID 1 (das Init-System), an der Wurzel aller Kernel-Threads PID 2 (kthreadd). Beide entstehen beim Booten (Kapitel 3). Im Kernel ist der Baum über die Felder children und sibling jeder task_struct verkettet. Zusätzlich hängen alle Tasks in einer globalen Liste, und über eine Hashtabelle findet der Kernel einen Task schnell anhand seiner PID.
Der Kontextwechsel
Laufen mehr Tasks als CPUs vorhanden sind, muss der Kernel ständig zwischen ihnen umschalten. Entscheidet der Scheduler, dass ein anderer Task an die Reihe kommt, führt context_switch in kernel/sched/core.c den Wechsel aus:
- Adressraum wechseln (
switch_mm): Gehört der neue Task zu einem anderen Prozess, wird das RegisterCR3auf dessen Seitentabellen umgestellt. Bei Threads desselben Prozesses entfällt dieser Schritt. Kernel-Threads haben keinen eigenen Adressraum und „leihen“ sich einfach den vorherigen (active_mm). - Register und Stack wechseln (
switch_to): Die Register des alten Tasks werden gesichert, die des neuen geladen. Dazu gehört auch der Stackpointer. Ab diesem Moment läuft der Code auf dem Kernel-Stack des neuen Tasks weiter, undcurrentzeigt auf ihn.
Dass der neue Task danach einfach weiterläuft, liegt daran, dass er beim letzten Mal an genau derselben Stelle angehalten wurde, nämlich mitten in context_switch(). Aus seiner Sicht kehrt nur ein Funktionsaufruf zurück.
Die direkten Kosten eines Kontextwechsels (Register sichern und laden) sind klein, die indirekten oft größer: Der neue Task findet seine Daten nicht in den Caches der CPU, und nach einem Wechsel des Adressraums sind die zwischengespeicherten Adressübersetzungen (TLB) für ihn wertlos. Moderne CPUs mildern Letzteres mit Process Context Identifiers (PCID), mit denen TLB-Einträge mehrerer Adressräume nebeneinander bestehen bleiben können.
Der Kernel zählt für jeden Task, wie oft er die CPU freiwillig abgegeben hat (weil er warten musste) und wie oft er verdrängt wurde (weil der Scheduler einen anderen Task bevorzugte). Wie diese Entscheidung fällt, ist Thema von Kapitel 6.
Selbst ausprobieren
Selbst ausprobieren: Prozesse und Threads untersuchen
# Zustand, PIDs, Threads, Kontextwechsel der eigenen Shell
grep -E 'State|^Pid|Tgid|PPid|Threads|ctxt_switches' /proc/$$/status
# Prozessbaum mit PIDs
pstree -p | head -n 30
# Alle Threads anzeigen: LWP = PID des Threads, NLWP = Anzahl Threads
ps -eLf | head -n 20
# Prozesse nach Zustand gruppieren
ps -eo stat= | cut -c1 | sort | uniq -c
# Systemaufrufe beim Starten eines Programms: clone/execve/wait4
strace -f -e trace=clone,clone3,execve,wait4 sh -c 'ls > /dev/null'Selbst ausprobieren: Einen Zombie erzeugen und beobachten
# Eine Subshell startet „sleep 1“ im Hintergrund und ersetzt sich dann per exec
# durch „sleep 60“. Das neue Programm weiß nichts von seinem Kind und ruft nie wait() auf.
(sleep 1 & exec sleep 60) &
sleep 2
# Das Kind von „sleep 60“ ist jetzt ein Zombie (STAT = Z, <defunct>)
ps -o pid,ppid,stat,comm --ppid $!Nach 60 Sekunden endet auch der Elternprozess. Der Zombie wird dann an PID 1 bzw. einen Subreaper weitergereicht, der ihn sofort abräumt.