Alle Rezepte
OS Internals · Linux · Prozesse, Threads, Speicher

Prozess und Thread:
ein Unterschied im Adressraum

Acht Abschnitte über eine Unterscheidung, die im Kernel gar keine ist: Für Linux sind beide dasselbe Objekt, unterschiedlich ist nur, was sie sich teilen. Aus dieser einen Entscheidung folgen Crash-Isolation, Race Conditions, Umschaltkosten und das Verhalten unter CPU-Limits.

0GrundlagenTask, PID, Adressraum 1SpeicherlayoutText, Heap, Stack, Seiten 2ThreadsGeteilt, privat, Absturz 3clonefork, Flags, Container 4SchedulingUmschalten, Kosten, Limits 5Race ConditionsSperren, Atomics, Deadlock 6Beobachtenproc, RSS, Werkzeuge 7ModellwahlProzesse, Threads, Async
0

Grundlagen: für den Kernel gibt es nur Tasks

Die Lehrbuchunterscheidung „Prozess enthält Threads" beschreibt die Sicht der Programmiersprache. Der Linux-Kernel kennt beides als dieselbe Struktur und unterscheidet nur, welche Teile geteilt werden.

Ein Objekt, zwei Namen

Jede lauffähige Einheit ist im Kernel eine task_struct. Sie enthält Verweise auf den Adressraum, die offenen Dateien, die Signal-Handler und den Zustand der Register. Ein „Thread" entsteht dadurch, dass eine neue Task denselben Adressraum verwendet statt einer eigenen Kopie.

BegriffKernel-SichtSichtbar als
ProzessTask mit eigenem AdressraumPID, Eintrag in /proc
ThreadTask, die einen Adressraum mitbenutztTID unter /proc/<pid>/task/
Thread-Gruppealle Tasks mit derselben tgiddas, was ps als einen Prozess zeigt

Deshalb liefert getpid() in jedem Thread denselben Wert – nämlich die tgid – während gettid() die tatsächliche Task-Nummer zurückgibt. Wer Threads in top vermisst, sieht die Gruppe, nicht die Tasks.

Prozess ist die Wohnung, Threads sind die Bewohner – jeder hat sein eigenes Bett (Stack), aber Küche, Kühlschrank und Wohnungstür (Heap, Dateien, Rechte) gehören allen gemeinsam.
Selbst nachsehen: Tasks eines Prozesses

Der Beweis, dass Threads eigene Einträge im Kernel haben, steht im Dateisystem – ein Verzeichnis je Task.

ls /proc/self/task
grep -E 'Tgid|^Pid|Threads' /proc/<pid>/status

Dieselbe Information in Prozesslisten, wenn man sie ausdrücklich anfordert:

ps -o pid,tid,comm -L -p <pid>
top -H -p <pid>
1

Speicherlayout: der eigene Stadtplan

Jeder Prozess sieht einen zusammenhängenden Adressraum, der ihm allein gehört. Was davon im physischen RAM liegt, entscheidet der Kernel – der Prozess erfährt es nicht.

Die Bereiche und was Threads davon bekommen

Von unten nach oben: der Maschinencode, die globalen Daten, der wachsende Heap, die eingeblendeten Bibliotheken und ganz oben der Stack. Ein zusätzlicher Thread bekommt aus diesem Bild genau ein Element neu – seinen eigenen Stack.

ein Prozess, ein Thread derselbe Prozess, drei Threads Kernel-Bereich für den Prozess nicht lesbar Stack lokale Variablen, Rücksprungadressen mmap-Bereich Bibliotheken, große Allokationen Heap malloc, new – wächst nach oben BSS globale Variablen ohne Startwert Data globale Variablen mit Startwert Text der Maschinencode, nur lesbar Kernel-Bereich für den Prozess nicht lesbar mmap-Bereich Bibliotheken, große Allokationen Heap malloc, new – wächst nach oben BSS globale Variablen ohne Startwert Data globale Variablen mit Startwert Text der Maschinencode, nur lesbar Stack 1 Thread 1 Stack 2 Thread 2 Stack 3 Thread 3 Stack wächst nach unten Heap wächst nach oben hohe Adresse 0x0 ab hier gemeinsam Jeder Prozess sieht diesen Adressraum als seinen eigenen – die MMU bildet die Seiten auf echten Speicher ab. Ein Thread bekommt nur einen eigenen Stack; Code, Heap und globale Daten sind gemeinsam.

Wichtig ist der Unterschied zwischen reserviert und belegt: Adressraum ist billig, physischer Speicher nicht. Eine Seite wird erst dann echt zugewiesen, wenn sie zum ersten Mal berührt wird.

Virtuell

  • Gleiche Adresse, andere Bytes – zwei Prozesse können beide auf 0x400000 zugreifen und meinen verschiedene Daten
  • Seitenweise – die Abbildung erfolgt in Seiten, üblicherweise 4 KB
  • Schutz inklusive – ein Zugriff auf eine nicht zugeordnete Adresse endet als Segmentation Fault

Physisch

  • Gemeinsame Seiten – Bibliotheken liegen einmal im RAM und werden in viele Prozesse eingeblendet
  • Erst bei Bedarf – reservierter Speicher kostet zunächst nichts
  • Auslagerbar – der Kernel darf Seiten jederzeit verschieben oder auslagern
Achtung: Ein Thread reserviert seinen Stack in voller Größe – üblicherweise dem Wert aus ulimit -s, oft 8 MB. Bei tausend Threads stehen damit acht Gigabyte im virtuellen Speicher, während physisch vielleicht ein paar Megabyte benutzt sind. Wer in der Überwachung auf VSZ schaut, meldet einen Speicherfresser, der keiner ist; wer daraufhin das Limit senkt, erzeugt Stack-Überläufe. Für die Frage „wie viel RAM braucht das wirklich" zählt der belegte Anteil, nicht der reservierte.
Virtueller Speicher ist ein persönlicher Stadtplan – jeder Prozess hat seine eigene Hausnummer 12, und die MMU weiß, welches echte Haus gemeint ist.
2

Threads: was geteilt wird und was nicht

Die vollständige Liste ist kurz und lohnt sich auswendig – aus ihr folgt fast jede Eigenschaft, die Threads im Guten wie im Schlechten haben.

Privat je Thread, gemeinsam im Prozess

Prozess · Thread-Gruppe, tgid 4711 Thread 1 · Stack · Register und PC · TID · errno · Thread-lokale Daten · Signalmaske Thread 2 · Stack · Register und PC · TID · errno · Thread-lokale Daten · Signalmaske Thread 3 · Stack · Register und PC · TID · errno · Thread-lokale Daten · Signalmaske privat je Thread gemeinsam für alle Threads · Code · Heap · globale Variablen · offene Dateien · Signal-Handler · Arbeitsverzeichnis · cgroup · Namespaces Ein Absturz zerstört den Rahmen, nicht eine Säule: SIGSEGV in einem Thread beendet den ganzen Prozess.

Aus „gemeinsam" folgt die Stärke: Zwei Threads tauschen Daten, indem einer schreibt und der andere liest – kein Kopieren, kein Serialisieren, keine Zustellung. Aus derselben Zeile folgt die Schwäche: Genau deshalb können sie sich gegenseitig überschreiben.

Was Threads billig macht

  • Erzeugung – kein neuer Adressraum, keine neuen Seitentabellen
  • Datenaustausch – ein Zeiger genügt
  • Umschalten – der Adressraum bleibt, die Übersetzungspuffer bleiben gültig

Was Threads teuer macht

  • Kein Schutz – ein fehlerhafter Zeiger beschädigt fremde Daten im selben Prozess
  • Synchronisation – jeder gemeinsame Zustand braucht eine Regel
  • Kein Teilausfall – der Absturz eines Threads beendet alle
Achtung: „Der Thread ist abgestürzt, der Rest läuft weiter" gibt es nicht. Ein Speicherzugriffsfehler in einem Thread liefert ein Signal an die gesamte Thread-Gruppe und beendet den Prozess mitsamt allen anderen Threads – auch denen, die gerade sauber arbeiten. Ebenso beendet der OOM-Killer nie einen Thread, sondern immer den ganzen Prozess. Wer Ausfallisolation zwischen Arbeitseinheiten braucht, braucht getrennte Prozesse, nicht getrennte Threads.
Signale in Thread-Programmen

Signale gehen an die Gruppe, nicht an einen bestimmten Thread. Der Kernel stellt sie irgendeinem Thread zu, der sie nicht blockiert hat – welchem, ist nicht vorhersagbar.

# Signalmasken aller Tasks eines Prozesses
grep SigBlk /proc/<pid>/task/*/status

Das übliche Muster in Serverprogrammen: alle Arbeitsthreads blockieren die interessanten Signale, ein einzelner Thread nimmt sie gezielt entgegen. Ohne dieses Muster landet ein SIGTERM beim beliebigen Thread und die Aufräumreihenfolge wird zufällig.

3

clone: ein Systemaufruf für alles

Prozess, Thread und Container entstehen unter Linux durch denselben Aufruf. Was dabei herauskommt, entscheiden die Flags.

Dieselbe Tür, andere Schalterstellung

Was entstehtEntscheidende FlagsErgebnis
Prozess (fork)keine Sharing-Flagseigener Adressraum als Kopie
Thread (pthread_create)CLONE_VM, CLONE_FILES, CLONE_SIGHAND, CLONE_THREADgemeinsamer Adressraum, gleiche tgid
Containerzusätzlich CLONE_NEWPID, CLONE_NEWNET, CLONE_NEWNS, CLONE_NEWUSER …eigene Sicht auf Prozesse, Netz, Dateibaum, Benutzer

Das ist die Verbindung zwischen diesem Thema und Containern: Ein Container ist kein besonderes Objekt im Kernel, sondern ein gewöhnlicher Prozess, der mit zusätzlichen Namespace-Flags gestartet wurde. Deshalb kann man ihn mit denselben Werkzeugen betrachten wie jeden anderen Prozess.

# Namespaces eines beliebigen Prozesses
ls -l /proc/<pid>/ns/

# mitlesen, welche Flags beim Start gesetzt werden
strace -f -e trace=clone,clone3 -- ./programm
Ein Schalterbrett statt drei Türen – fork, pthread_create und der Containerstart betätigen dasselbe Brett, nur mit unterschiedlich vielen umgelegten Schaltern.

Copy-on-Write: warum fork trotzdem schnell ist

Ein fork kopiert nicht den Speicher, sondern nur die Seitentabellen. Alle Seiten werden für beide Seiten schreibgeschützt markiert; erst beim ersten Schreibzugriff erzeugt der Kernel eine private Kopie der betroffenen Seite.

Praktische Folge: Ein Elternprozess mit 8 GB Datenbank-Cache kann sich in Millisekunden verzweigen. Beginnt das Kind jedoch, überall zu schreiben, wächst der Speicherbedarf nachträglich – der Aufruf war billig, der Betrieb wird es nicht zwingend.

Achtung: fork in einem Programm mit mehreren Threads erzeugt ein Kind, in dem nur der aufrufende Thread existiert. Alle anderen sind weg – aber ihre Sperren nicht: Ein Mutex, den ein anderer Thread im Moment des fork gehalten hat, bleibt im Kind für immer gesperrt. Das Kind blockiert dann beim nächsten malloc oder Log-Aufruf, ohne Fehlermeldung und ohne erkennbaren Zusammenhang. Zwischen fork und exec gehören deshalb nur wenige, ausdrücklich dafür freigegebene Aufrufe – oder gleich posix_spawn.
4

Scheduling: was ein Wechsel kostet

Der Scheduler verteilt Rechenzeit an Tasks, nicht an Prozesse. Ob zwei Tasks denselben Adressraum haben, merkt er erst beim Umschalten – dort wird der Unterschied messbar.

Drei Arten von Wechsel

WechselWas getauscht wirdKosten
Thread → Thread, gleicher ProzessRegister, Stackzeigeram günstigsten, Adressraum bleibt
Prozess → Prozesszusätzlich Adressraum und Seitentabellenteurer, Übersetzungspuffer verlieren an Wirkung
Task → Kernel und zurückPrivilegienwechsel bei jedem Systemaufrufam häufigsten, deshalb im Profil oft dominant

Die Größenordnung liegt im Bereich von Mikrosekunden und hängt stark von CPU, Kernel und Sicherheitsmaßnahmen ab. Wichtiger als absolute Zahlen ist das Verhältnis: Ein Wechsel innerhalb eines Prozesses ist billiger als zwischen Prozessen, und beides ist teuer, wenn es hunderttausendfach pro Sekunde passiert.

Adressraumwechsel ist ein Schreibtischwechsel – zum Nachbarplatz genügt der Stuhl, ins andere Büro muss man alle Unterlagen mitnehmen und die Ablage neu suchen.

Threads und CPU-Grenzen in Containern

Eine cgroup begrenzt Rechenzeit, nicht die Anzahl der Threads. Ein Programm, das seine Thread-Anzahl aus der Zahl der sichtbaren CPU-Kerne ableitet, startet in einem Container mit 32 sichtbaren Kernen 32 Arbeitsthreads – und darf zusammen vielleicht eine halbe CPU verbrauchen.

Die Folge ist kein Fehler, sondern Drosselung: Die Threads werden am Ende jeder Periode angehalten, die Latenz steigt sprunghaft, die Auslastung sieht dabei niedrig aus.

# Sicht des Containers auf die Grenze
cat /sys/fs/cgroup/cpu.max
nproc

# Drosselung nachweisen
grep -E 'nr_throttled|throttled_usec' /sys/fs/cgroup/cpu.stat
Achtung: Genau diese Stelle erklärt viele „langsam trotz niedriger Auslastung"-Fälle in Kubernetes. Die Laufzeitumgebung liest die Kernanzahl des Nodes, nicht das Limit des Containers – bei Go über GOMAXPROCS, bei Java über die Größe der Standard-Threadpools, bei vielen Bibliotheken über eigene Worker-Zahlen. Steigt nr_throttled, hilft weder mehr Speicher noch ein größerer Node: Entweder das CPU-Limit anheben oder der Anwendung die richtige Zahl mitteilen.
Threads und Kerne zueinander bringen

Für Go lässt sich die Zahl direkt aus dem Limit ableiten, statt sie zu raten. Ohne Anpassung entspricht sie der Kernanzahl des Hosts.

GOMAXPROCS=2
# oder im Deployment aus dem Limit ableiten
kubectl set env deployment/<name> GOMAXPROCS=2

Aktuelle JVM-Versionen berücksichtigen cgroup-Grenzen von sich aus; nachsehen lohnt trotzdem, weil eigene Pools davon unberührt bleiben.

java -XX:+PrintFlagsFinal -version | grep -i ActiveProcessorCount
5

Race Conditions: der Preis des gemeinsamen Speichers

Gemeinsamer Speicher heißt gemeinsamer Zugriff. Ohne Regel gewinnt derjenige, der zufällig zuletzt schreibt – und die Zufälligkeit ist das eigentliche Problem.

Eine verlorene Erhöhung, Schritt für Schritt

Eine Zeile Quelltext ist keine Einheit für die CPU. counter++ zerfällt in Laden, Rechnen und Zurückschreiben – und zwischen diesen Schritten darf jederzeit umgeschaltet werden.

Thread A counter++ Speicher counter Thread B counter++ Start: counter = 0 liest 0 t1 liest 0 t2 rechnet 0+1 t3 rechnet 0+1 t4 schreibt 1 t5 schreibt 1 t6 counter = 1 counter = 1 Ergebnis: 1 statt 2 counter++ ist drei Schritte: lesen, rechnen, schreiben. Zwischen ihnen darf der Scheduler wechseln – und beide Threads arbeiten mit demselben veralteten Wert. Ohne Sperre geht genau eine Erhöhung verloren.

Der Fehler ist selten, weil das Zeitfenster kurz ist. Genau deshalb überlebt er Tests: Auf einem Kern mit wenig Last tritt er kaum auf, unter Produktionslast auf vielen Kernen ständig.

Die üblichen Mittel

MittelWofürPreis
Mutexzusammenhängende Abschnitte schützenWartezeit, Gefahr von Verklemmungen
Atomare Operationeinzelne Zähler und Zeigernur für kleine Einheiten geeignet
Nachrichten statt ZustandÜbergabe zwischen ArbeitseinheitenKopieraufwand, dafür kein gemeinsamer Zustand
Unveränderliche Datenalles, was gelesen wirdSpeicher für Kopien
Der Mutex ist der Schlüssel zum Besprechungsraum – wer ihn hat, ist drin; wer ihn behält und in Urlaub fährt, legt die ganze Firma lahm.
Achtung: Zwei Denkfehler halten sich hartnäckig. Erstens: Eine Zuweisung sei atomar, weil sie eine Zeile ist – das gilt schon für einen 64-Bit-Wert auf mancher Architektur nicht. Zweitens: volatile in C oder C++ mache einen Zugriff threadsicher – es unterdrückt nur bestimmte Optimierungen und garantiert weder Atomarität noch eine Reihenfolge zwischen Kernen. Threadsicherheit kommt von atomaren Typen oder Sperren, nicht von Schlüsselwörtern, die danach klingen.
Races finden, statt auf sie zu warten

Wettlaufsituationen lassen sich nicht zuverlässig ertesten, aber die Werkzeuge der Sprachen finden sie zur Laufzeit, indem sie Zugriffe protokollieren statt auf den Zufall zu warten.

# Go
go test -race ./...

# C und C++ mit Clang oder GCC
gcc -fsanitize=thread -g programm.c -o programm

Für Verklemmungen ist der Blick auf blockierte Tasks der schnellste Weg: Wer worauf wartet, steht im Kernel-Zustand jeder Task.

cat /proc/<pid>/task/*/stack
grep State /proc/<pid>/task/*/status
6

Beobachten: was die Zahlen wirklich sagen

Die meisten Fehlschlüsse über Speicher entstehen nicht durch falsche Werkzeuge, sondern durch das Addieren von Werten, die man nicht addieren darf.

Die drei Speicherzahlen

WertBedeutungNützlich für
VSZreservierter Adressraumfast nichts – enthält alles Ungenutzte
RSStatsächlich belegte Seiten, gemeinsame eingerechnetein einzelner Prozess
PSSbelegte Seiten, gemeinsame anteilig verrechnetSummen über mehrere Prozesse
ps -o pid,tid,rss,vsz,comm -L -p <pid>
grep -E '^(Rss|Pss)' /proc/<pid>/smaps_rollup
pmap -x <pid> | tail -3
Achtung: Die Summe der RSS-Werte aller Prozesse ist regelmäßig größer als der eingebaute Speicher – gemeinsame Bibliotheken und Copy-on-Write-Seiten werden bei jedem Prozess voll mitgezählt. Bei zwanzig Kindprozessen desselben Servers erscheint derselbe Cache zwanzigmal. Für Summen ist PSS zuständig, für die Frage, was ein Container verbraucht, der Wert aus der cgroup – nicht die addierte Prozessliste.

Womit man anfängt

  • Wie viele Tasks? – ls /proc/<pid>/task | wc -l
  • Wer rechnet? – top -H -p <pid>
  • Wer wartet worauf? – strace -f -p <pid>
  • Was liegt im Adressraum? – cat /proc/<pid>/maps

Im Container

  • Speicher – /sys/fs/cgroup/memory.current statt Prozessliste
  • Drosselung – /sys/fs/cgroup/cpu.stat
  • Prozessgrenze – /sys/fs/cgroup/pids.max
  • Beendigungen – Events des Nodes, nicht das Anwendungslog
Hinweis: Die Prozessgrenze der cgroup zählt Tasks, also auch Threads. Ein Programm, das viele Threads erzeugt, kann sie erreichen, obwohl es nur einen Prozess betreibt – der Aufruf scheitert dann mit „resource temporarily unavailable" statt mit einer Speichermeldung.
7

Modellwahl, der Reihe nach

Die Frage ist nicht „Prozess oder Thread", sondern in welcher Reihenfolge man die Anforderungen abarbeitet. Am Ende steht meist eine Mischung.

Die vier Phasen auf einen Blick

  1. Phase A · Isolation klärenSchritt 1: Darf ein Fehler alles mitreißen?
  2. Phase B · Datenfluss klärenSchritt 2: Wie viel gemeinsamer Zustand ist wirklich nötig?
  3. Phase C · Lastart klärenSchritt 3: Wartet die Arbeit oder rechnet sie?
  4. Phase D · Grenzen setzenSchritt 4: Anzahl an das CPU-Limit binden und messen.

Phase A · Isolation

Schritt 1 · Wie viel darf ein Fehler mitreißen

Wenn eine Arbeitseinheit unabhängig überleben soll, ist die Antwort ein eigener Prozess – es gibt keinen Weg, einen abgestürzten Thread zu isolieren. Klassische Beispiele: ein Browser mit einem Prozess je Website, ein Webserver mit mehreren Worker-Prozessen, eine Datenbank mit einem Prozess je Verbindung.

Der Preis ist teurerer Datenaustausch – gemeinsame Speicherbereiche, Sockets oder eine Datenbank statt eines Zeigers.

Phase B · Datenfluss

Schritt 2 · Gemeinsamen Zustand kleinhalten

Threads sind attraktiv, wenn große Datenmengen geteilt werden – ein Zwischenspeicher, ein geladenes Modell, ein Index. Sobald aber jeder Zugriff eine Sperre braucht, verschwindet der Vorteil in Wartezeiten.

Die belastbare Regel: gemeinsam nur, was gelesen wird; alles Veränderliche entweder in eine Einheit legen oder über Nachrichten übergeben.

Phase C · Lastart

Schritt 3 · Wartende und rechnende Arbeit trennen

Wartet die Arbeit überwiegend auf Netz oder Platte, brauchen tausend gleichzeitige Vorgänge keine tausend Threads: Ein ereignisgesteuertes Modell oder leichtgewichtige Einheiten der Sprache – Goroutinen, virtuelle Threads, Coroutinen – erledigen das auf wenigen Betriebssystem-Threads.

Rechnet die Arbeit dagegen, hilft nur echte Parallelität, und die ist durch die verfügbaren Kerne begrenzt. Mehr Threads als Kerne erzeugen dann nur zusätzliche Umschaltvorgänge.

Phase D · Grenzen

Schritt 4 · Anzahl an das Limit binden

Der letzte Schritt ist der, der in Containern am häufigsten fehlt: Die Anzahl der Arbeitseinheiten muss aus dem Limit folgen, nicht aus der Hardware des Nodes. Danach messen, ob gedrosselt wird – vorher ist jede Zahl geraten.

grep nr_throttled /sys/fs/cgroup/cpu.stat
ls /proc/<pid>/task | wc -l

Wie es andere machen

SystemModellGrund
Webserver mit Worker-Prozessenmehrere Prozesse, je ereignisgesteuertIsolation plus wenig Umschalten
Klassische relationale Datenbankein Prozess je Verbindungein abgestürzter Vorgang reißt nichts mit
BrowserProzess je Website, Threads darinSicherheitsgrenze zwischen fremdem Code
Go-Programmeviele leichte Einheiten auf wenigen Threadswartende Last ohne Thread je Vorgang
Container-Laufzeitein Prozess je Container, eigene Namespacesdieselben clone-Flags, andere Schalterstellung
Die Wahl ist eine Frage nach dem Schaden, nicht nach der Geschwindigkeit – erst klären, was ein Fehler mitreißen darf, dann optimieren.