Kapitelübersicht
  1. 1 Einführung
  2. 2 Architektur-Überblick
  3. 3 Der Bootvorgang
  4. 4 Systemaufrufe
  5. 5 Prozesse & Threads
  6. 6 Der Scheduler
  7. 7 Speicherverwaltung
  8. 8 Interrupts & verzögerte Arbeit
  9. 9 Synchronisation
  10. 10 VFS & Dateisysteme
  11. 11 Block-I/O
  12. 12 Gerätetreiber
  13. 13 Netzwerk-Stack
  14. 14 Interprozesskommunikation
  15. 15 Sicherheit & Isolation
  16. 16 eBPF
  17. 17 Kernel-Entwicklung

Kapitel 5

Prozesse & Threads

Wie der Kernel laufende Programme verwaltet – von der zentralen Datenstruktur task_struct über fork(), clone() und execve() bis zu Prozesszuständen, Zombies und dem Kontextwechsel.

Kurz gesagt

Ein Prozess ist ein laufendes Programm mit eigenem Speicher, eigenen offenen Dateien und weiteren Ressourcen. Threads sind mehrere Ausführungsstränge innerhalb eines Prozesses, die sich diese Ressourcen teilen. Linux macht hier intern kaum einen Unterschied, denn beides sind „Tasks“. Neue Prozesse entstehen durch Kopieren eines bestehenden (fork) und laden dann meist ein neues Programm (exec). Der Kernel verfolgt für jeden Task, ob er gerade rechnet, auf etwas wartet oder beendet ist.

Nach diesem Kapitel …

  • Programm, Prozess, Thread und Task voneinander abgrenzen
  • die wichtigsten Felder von task_struct und ihre Bedeutung kennen
  • den Ablauf von fork(), clone() und execve() im Kernel beschreiben und Copy-on-Write erklären
  • die Zustände eines Tasks und die Bedeutung der Buchstaben in ps interpretieren
  • erklären, wie Zombies und Waisen entstehen und wie sie aufgelöst werden
  • die Schritte und Kosten eines Kontextwechsels benennen

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:

Threads und Prozesse: geteilte und eigene Ressourcen Zwei Threads eines Prozesses teilen sich alle Ressourcen Thread 1 task_struct Thread 2 task_struct Adressraum mm_struct Offene Dateien files_struct Arbeitsverzeichnis fs_struct Signal-Handler sighand_struct Zwei Prozesse nach fork() jeder hat eigene Strukturen Eltern task_struct mm_struct files_struct fs_struct sighand_struct Kind task_struct mm_struct files_struct fs_struct sighand_struct gestrichelt: Kopie
Für den Kernel sind Threads und Prozesse gleichermaßen Tasks. Der Unterschied liegt darin, welche Strukturen sie gemeinsam nutzen – festgelegt durch die Flags beim Aufruf von clone().

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:

Aufrufliefert im KernelBedeutung im User Space
getpid()tgidID des Prozesses (für alle Threads gleich)
gettid()pidID 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:

GruppeFelder (Auswahl)Inhalt
Zustand__state, exit_state, flagsläuft, schläft, beendet …
Identitätpid, tgid, comm[16]Nummern und Kurzname (wie in ps)
Verwandtschaftreal_parent, parent, children, sibling, group_leaderProzessbaum und Thread-Gruppe
Schedulingprio, static_prio, policy, se, on_cpuPriorität, Scheduling-Klasse, Laufzeitstatistik (Kapitel 6)
Speichermm, active_mmAdressraum (struct mm_struct, Kapitel 7)
Dateienfs, filesArbeits- und Wurzelverzeichnis, Tabelle der offenen Dateien
Signalesignal, sighand, blocked, pendingSignal-Handler, blockierte und ausstehende Signale (Kapitel 14)
RechtecredBenutzer- und Gruppen-IDs, Capabilities (Kapitel 15)
Isolationnsproxy, cgroupsNamespaces und Ressourcengruppen, die Grundlage von Containern
CPU-Zustandthread, stackgesicherte 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:

  1. 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.
  2. 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:

  1. copy_process legt eine neue task_struct an, zunächst als Kopie der Eltern-Struktur.
  2. 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 Dateien copy_files() usw.
  3. Der neue Task bekommt eine PID, wird in den Prozessbaum eingehängt und erhält eigene Scheduling-Daten.
  4. 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.:

fork() mit Copy-on-Write Elternprozess Seitentabelle Code Daten Stack r-x rw- r-- CoW rw- r-- CoW Kindprozess Seitentabelle Code Daten Stack r-x r-- CoW r-- CoW rw- ⚡ Seitenfehler! neues Programm eigener Adressraum Physischer Arbeitsspeicher Seite A Seite B Seite C Kopie von C ×2 ×2 ×2
  1. 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.

  2. 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.

  3. 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.

  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.

fork() kopiert nicht den Speicher, sondern nur die Seitentabellen. Erst wenn ein Prozess eine gemeinsam genutzte Seite beschreibt, legt der Kernel eine Kopie an.

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:

Flaggesetzt bedeutet …
CLONE_VMgemeinsamer Adressraum (mm)
CLONE_FILESgemeinsame Tabelle offener Dateien
CLONE_FSgemeinsames Arbeits- und Wurzelverzeichnis, gemeinsame umask
CLONE_SIGHANDgemeinsame Signal-Handler
CLONE_THREADgleiche 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:

  1. Der Kernel öffnet die Programmdatei und prüft die Rechte.
  2. Ein passender Binärformat-Handler übernimmt: für ELF-Dateien binfmt_elf, für Skripte mit #! binfmt_script. Über binfmt_misc lassen sich weitere Formate registrieren, etwa für Java- oder Windows-Programme.
  3. Der alte Adressraum wird verworfen und ein neuer angelegt. Die Segmente der ELF-Datei werden eingeblendet (nicht geladen, das erledigen später Seitenfehler).
  4. Auf dem neuen Stack legt der Kernel Argumente (argv), Umgebungsvariablen (envp) und den Auxiliary Vector mit Informationen für das Programm ab.
  5. Braucht das Programm Bibliotheken, startet zuerst der dynamische Linker (z. B. /lib64/ld-linux-x86-64.so.2), der sie einbindet und dann zu main() 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:

Zustände eines Tasks TASK_RUNNING (R) wartend Scheduler wählt aus verdrängt / gibt ab wartet auf Ereignis aufgeweckt SIGSTOP exit() Neu erzeugt fork(), clone() Lauffähig wartet in der Run-Queue Läuft auf einer CPU Schlafend (S) TASK_INTERRUPTIBLE Schlafend (D) TASK_UNINTERRUPTIBLE Gestoppt (T) SIGCONT → lauffähig Zombie (Z) EXIT_ZOMBIE nach wait() des Elternprozesses freigegeben
Vereinfachter Zustandsautomat eines Tasks. Linux unterscheidet nicht zwischen „bereit“ und „läuft“: Beide Tasks sind TASK_RUNNING, nur einer davon ist gerade auf der CPU. Die Buchstaben entsprechen der STAT-Spalte von ps.
ps-STATZustand im KernelBedeutung
RTASK_RUNNINGläuft gerade oder wartet in der Run-Queue auf eine CPU
STASK_INTERRUPTIBLEschläft und wartet auf ein Ereignis; Signale wecken ihn auf
DTASK_UNINTERRUPTIBLEschläft und ignoriert Signale, meist während Festplatten-I/O
T__TASK_STOPPEDangehalten, z. B. mit Strg+Z oder SIGSTOP
t__TASK_TRACEDangehalten von einem Debugger
ZEXIT_ZOMBIEbeendet, Exit-Status noch nicht abgeholt
ITASK_IDLEuntä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:

  1. Er trägt sich in eine Warteschlange (wait queue) für das Ereignis ein.
  2. Er setzt seinen Zustand auf TASK_INTERRUPTIBLE oder TASK_UNINTERRUPTIBLE.
  3. 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, kill wirkt 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, etwa systemd --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:

  1. Adressraum wechseln (switch_mm): Gehört der neue Task zu einem anderen Prozess, wird das Register CR3 auf 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).
  2. 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, und current zeigt 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.