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 9

Synchronisation

Warum im Kernel ständig mehrere Abläufe gleichzeitig auf dieselben Daten zugreifen und wie er sie schützt – atomare Operationen, Speicherbarrieren, Spinlocks, Mutexe, Seqlocks, Per-CPU-Daten und RCU, dazu Deadlocks und ihre Erkennung.

Kurz gesagt

Im Kernel arbeiten viele Prozessorkerne gleichzeitig, und dazwischen kommen ständig Interrupts. Greifen zwei Abläufe zur selben Zeit auf dieselben Daten zu, können Daten verloren gehen oder kaputtgehen. Der Kernel schützt solche Daten deshalb mit unteilbaren Operationen und mit Sperren. Je nach Situation wartet man auf eine Sperre aktiv oder schlafend. Für Daten, die sehr oft gelesen und selten geändert werden, gibt es mit RCU ein Verfahren, bei dem Leser überhaupt nicht warten müssen.

Nach diesem Kapitel …

  • erklären, woher Nebenläufigkeit im Kernel kommt und wie Race Conditions entstehen
  • atomare Operationen und Speicherbarrieren einordnen
  • Spinlocks und Mutexe unterscheiden und für eine Situation die passende Sperre wählen
  • die Varianten spin_lock_irqsave und spin_lock_bh begründen
  • das Prinzip von Seqlocks, Per-CPU-Daten und RCU erklären
  • Deadlocks erkennen und vermeiden

Woher die Nebenläufigkeit kommt

Ein Anwendungsprogramm mit einem Thread kann sich darauf verlassen, dass niemand seine Variablen verändert, während es arbeitet. Im Kernel gilt das praktisch nie. Dieselben Daten können gleichzeitig angefasst werden von

  • anderen CPUs, die denselben Code oder anderen Kernel-Code ausführen,
  • einem Interrupt-Handler, der den laufenden Code mitten in einer Operation unterbricht (Kapitel 8),
  • einem Softirq, der beim Verlassen eines Interrupts läuft,
  • einem anderen Task, wenn der Kernel-Code verdrängt wird (Kapitel 6) oder selbst schläft.

Ein Codeabschnitt, der gemeinsam genutzte Daten verändert und dabei nicht unterbrochen werden darf, heißt kritischer Abschnitt. Wie leicht dabei etwas schiefgeht, zeigt schon die einfachste Operation:

Race Condition: zwei CPUs erhöhen denselben Zähler CPU 0 Register –5566 CPU 1 Register ––556 Speicher: counter 555667 load load add · store add · store lock incl counter++
  1. Ausgangslage

    counter steht auf 5. Beide CPUs führen gleichzeitig counter++ aus. Erwartetes Ergebnis: 7.

  2. CPU 0 lädt den Wert

    CPU 0 liest counter (5) in ein Register.

  3. CPU 1 lädt ebenfalls

    Bevor CPU 0 das Ergebnis zurückschreibt, liest auch CPU 1 den Wert – und bekommt ebenfalls 5.

  4. CPU 0 erhöht und speichert

    CPU 0 rechnet 5 + 1 und schreibt 6 zurück.

  5. CPU 1 überschreibt das Ergebnis

    CPU 1 rechnet ebenfalls 5 + 1 und schreibt 6 zurück. Die Erhöhung von CPU 0 ist verloren – counter steht auf 6 statt 7. Der Fehler tritt nur bei ungünstigem Timing auf und ist deshalb schwer zu finden.

  6. Lösung: eine atomare Operation

    Mit atomic_inc() wird auf x86 der Befehl lock incl ausgeführt. Die CPU hält die Cache-Zeile für die Dauer von Lesen, Erhöhen und Schreiben exklusiv; die andere CPU muss warten. Ergebnis: 7. Für größere kritische Abschnitte braucht man Sperren.

counter++ besteht in Wahrheit aus drei Schritten: laden, erhöhen, speichern. Überlappen sich zwei solche Folgen, geht eine Erhöhung verloren.

Eine solche Race ConditionRace ConditionFehler, bei dem das Ergebnis vom zufälligen zeitlichen Ablauf nebenläufiger Zugriffe abhängt, etwa wenn zwei CPUs gleichzeitig denselben Wert ändern. ist tückisch: Der Fehler tritt nur bei ungünstigem Timing auf, vielleicht einmal in einer Million Durchläufen, und verschwindet oft, sobald man Debug-Ausgaben einbaut. Im Kernel kann sie zu beschädigten Datenstrukturen, Speicherlecks, Abstürzen oder Sicherheitslücken führen.

Atomare Operationen

Die Grundbausteine aller Synchronisation sind Befehle, die die Hardware unteilbar ausführt. Für einfache Zähler und Flags reichen sie schon allein:

atomic_t aktive = ATOMIC_INIT(0);

atomic_inc(&aktive);                       /* unteilbar erhöhen */
if (atomic_dec_and_test(&aktive))          /* verringern, 0 erreicht? */
	alles_aufraeumen();

/* Compare-and-Swap: nur setzen, wenn der Wert noch 'alt' ist */
if (atomic_cmpxchg(&zustand, BEREIT, LAEUFT) == BEREIT)
	/* wir haben den Zustand gewonnen */;

set_bit(FLAG_AKTIV, &dev->flags);          /* atomare Bit-Operationen */

Für Referenzzähler gibt es den spezialisierten Typ refcount_t. Er erkennt Über- und Unterläufe und bleibt dann auf einem sicheren Wert stehen, statt wieder bei 0 zu beginnen. Viele frühere Sicherheitslücken beruhten auf solchen Überläufen: Ein Objekt wurde freigegeben, obwohl es noch benutzt wurde (use after free).

Tiefer eintauchenSpeicherbarrieren: wenn die Reihenfolge nicht stimmt

Compiler und CPU dürfen Speicherzugriffe umsortieren, solange das Ergebnis für den eigenen Thread gleich bleibt. Für andere CPUs kann das überraschende Folgen haben:

/* CPU 0 */                       /* CPU 1 */
daten = 42;                       while (!bereit)
bereit = 1;                               ;
                                  print(daten);   /* evtl. alter Wert! */

Die Zuweisungen von CPU 0 können für CPU 1 in umgekehrter Reihenfolge sichtbar werden. Auf ARM und RISC-V passiert das tatsächlich, x86 ist hier strenger. Abhilfe schaffen Barrieren, die eine Reihenfolge erzwingen:

  • smp_store_release(&bereit, 1) auf der schreibenden und smp_load_acquire(&bereit) auf der lesenden Seite: Alles vor dem Release ist sichtbar, bevor das Flag gesetzt erscheint.
  • smp_mb(), smp_wmb(), smp_rmb(): allgemeine, Schreib- bzw. Lesebarrieren.
  • READ_ONCE() und WRITE_ONCE() verhindern, dass der Compiler Zugriffe zusammenfasst, wiederholt oder wegoptimiert.

Sperren enthalten die nötigen Barrieren bereits. Man braucht sie also nur bei sperrfreien Verfahren. Wie sich Zugriffe genau verhalten dürfen, legt das Linux Kernel Memory Model fest, das sich mit dem Werkzeug herd7 sogar formal überprüfen lässt (tools/memory-model/).

Spinlocks

Ein SpinlockSpinlockSperre, bei der ein wartender Prozessor in einer Schleife aktiv prüft, bis sie frei wird. Nur für sehr kurze Abschnitte; der Halter darf nicht schlafen. ist die einfachste Sperre: Wer sie nicht bekommt, prüft in einer Schleife immer wieder, ob sie frei geworden ist, er „dreht sich“ (to spin).

static DEFINE_SPINLOCK(liste_lock);

spin_lock(&liste_lock);
list_add(&eintrag->node, &liste);     /* kritischer Abschnitt – kurz halten! */
spin_unlock(&liste_lock);

Spinlocks haben strenge Regeln:

  • Kurz halten. Jede wartende CPU verbrennt Rechenzeit.
  • Niemals schlafen, solange man einen Spinlock hält. Kein Mutex, kein GFP_KERNEL, kein copy_from_user(), das einen Seitenfehler auslösen könnte. Ein schlafender Halter würde alle Wartenden unbegrenzt kreisen lassen.
  • Während ein Spinlock gehalten wird, ist die Verdrängung abgeschaltet. Der Halter wird also nicht vom Scheduler unterbrochen.

Spinlocks und Interrupts

Kritisch wird es, wenn dieselben Daten auch in einem Interrupt-Handler benutzt werden. Hält Code im Prozesskontext die Sperre und kommt auf derselben CPU ein Interrupt, dessen Handler dieselbe Sperre will, dreht sich der Handler endlos: Der Halter kann erst weitermachen, wenn der Handler fertig ist. Das ist ein Deadlock auf einer einzigen CPU. Deshalb gibt es Varianten:

Daten werden auch benutzt in …Variante im Prozesskontext
nur Prozesskontextspin_lock() / spin_unlock()
Softirqs, Tasklets, BH-Workqueuesspin_lock_bh() / spin_unlock_bh(): sperrt zusätzlich Softirqs auf dieser CPU
Interrupt-Handlernspin_lock_irqsave(&lock, flags) / spin_unlock_irqrestore(&lock, flags): sperrt zusätzlich Interrupts auf dieser CPU
Tiefer eintauchenWie ein Spinlock implementiert ist

Ein naiver Spinlock (ein Wort, das alle per Compare-and-Swap zu setzen versuchen) hat zwei Probleme. Er ist unfair: Wer gerade die Cache-Zeile hat, gewinnt oft erneut. Und er skaliert schlecht: Alle wartenden CPUs reißen sich ständig um dieselbe Cache-Zeile.

Linux verwendet deshalb auf den meisten Architekturen Queued Spinlocks (kernel/locking/qspinlock.c), die auf dem MCS-Verfahren beruhen. Wartende reihen sich in eine Warteschlange ein, und jede CPU dreht sich dabei auf ihrer eigenen Variablen, bis ihr Vorgänger sie freigibt. Das ist fair (wer zuerst kommt, ist zuerst dran) und erzeugt kaum Verkehr auf dem Speicherbus. Im Normalfall ohne Konkurrenz bleibt die Sperre trotzdem ein einziges 32-Bit-Wort, das mit einem einzigen atomaren Befehl genommen wird.

Mit PREEMPT_RT wird spinlock_t übrigens zu einer schlafenden Sperre (rt_mutex). Nur raw_spinlock_t bleibt ein echter Spinlock und ist für die wenigen Stellen reserviert, an denen Schlafen tatsächlich unmöglich ist.

Mutexe und Semaphoren

Muss ein kritischer Abschnitt länger dauern oder darin geschlafen werden, etwa weil auf I/O gewartet oder Speicher angefordert wird, ist ein MutexMutexSperre mit gegenseitigem Ausschluss, bei der wartende Tasks schlafen gelegt werden. Darf nur im Prozesskontext benutzt werden. die richtige Wahl. Ein wartender Task wird schlafen gelegt, statt die CPU zu blockieren:

Warten auf eine Sperre: Spinlock und Mutex Spinlock CPU 0 A · hält Sperre CPU 1 wartet aktiv (dreht sich) B · hält Sperre Mutex CPU 0 A · hält Sperre CPU 1 Task C rechnet wecken B · hält Sperre ↑ Task B schläft
Beim Spinlock wartet die zweite CPU aktiv und verbraucht Rechenzeit – lohnend nur, wenn die Sperre sehr kurz gehalten wird. Beim Mutex legt sich der wartende Task schlafen, die CPU kann anderes tun; dafür kosten Schlafen und Aufwecken Kontextwechsel.
static DEFINE_MUTEX(konfig_lock);

mutex_lock(&konfig_lock);
err = konfiguration_neu_laden();       /* darf schlafen, z. B. Datei lesen */
mutex_unlock(&konfig_lock);

Mutexe sind nur im Prozesskontext erlaubt, nie in Interrupt-Handlern oder Softirqs, weil dort nicht geschlafen werden darf. Nur der Task, der den Mutex genommen hat, darf ihn wieder freigeben. Als Optimierung dreht sich ein wartender Task kurz, solange der aktuelle Halter auf einer anderen CPU läuft (optimistic spinning), denn dann wird der Mutex wahrscheinlich gleich frei, und ein teurer Schlaf-und-Weck-Zyklus wird gespart.

Weitere schlafende Sperren:

  • Semaphoren (struct semaphore) erlauben bis zu n gleichzeitige Halter. Im Kernel werden sie kaum noch verwendet.
  • Lese-Schreib-Semaphoren (rw_semaphore) erlauben viele gleichzeitige Leser oder einen Schreiber. Ein Beispiel ist die Sperre für den Adressraum eines Prozesses (mmap_lock, Kapitel 7).
  • RT-Mutexe unterstützen Prioritätsvererbung: Hält ein niedrig priorisierter Task eine Sperre, auf die ein hoch priorisierter wartet, erbt der Halter vorübergehend die höhere Priorität. Das verhindert die Prioritätsinversion, ein klassisches Problem in Echtzeitsystemen.

Welche Sperre wofür?

SituationMittel der Wahl
ein einzelner Zähler, ein Flag, ein Referenzzähleratomic_t, refcount_t, Bit-Operationen
kurzer Abschnitt, darf nicht schlafenSpinlock (Variante je nach Kontext, s. o.)
längerer Abschnitt oder Schlafen nötigMutex
viele Leser, seltene Schreiber, Schlafen nötigrw_semaphore
sehr viele Leser, seltene ÄnderungenRCU
kleine Daten, die sehr oft gelesen werden (z. B. die Uhrzeit)Seqlock
Daten, die jede CPU für sich hat (Statistiken)Per-CPU-Variablen

Ohne Sperren lesen: Seqlocks und Per-CPU-Daten

Seqlocks eignen sich für kleine Daten, die sehr oft gelesen und selten geschrieben werden. Der Schreiber erhöht vor und nach dem Schreiben eine Folgenummer, die während des Schreibens also ungerade ist. Leser nehmen gar keine Sperre: Sie lesen Folgenummer, Daten und erneut die Folgenummer. Hat sich die Nummer geändert oder war sie ungerade, versuchen sie es einfach noch einmal:

do {
	seq = read_seqbegin(&zeit_lock);
	sek  = aktuelle_zeit.sek;
	nsek = aktuelle_zeit.nsek;
} while (read_seqretry(&zeit_lock, seq));

So arbeitet zum Beispiel die Zeitverwaltung des Kernels, auch die Zeitabfrage im vDSO (Kapitel 4).

Per-CPU-Variablen vermeiden Konkurrenz von vornherein: Jede CPU bekommt ihre eigene Kopie. Statistiken wie Paketzähler werden pro CPU geführt und nur beim Auslesen aufsummiert. Man muss lediglich verhindern, dass der Task während des Zugriffs auf eine andere CPU wechselt. Die Funktionen this_cpu_inc() und Verwandte erledigen das in einem einzigen Befehl.

RCU: Read-Copy-Update

Für Daten, die sehr oft gelesen und selten geändert werden, hat Linux ein eigenes, besonders leistungsfähiges Verfahren: RCURCU (Read-Copy-Update)Synchronisationsverfahren für überwiegend gelesene Daten: Leser brauchen keine Sperren, Schreiber arbeiten auf Kopien und geben alte Versionen erst nach einer Gnadenfrist frei.. Leser nehmen dabei keinerlei Sperre und schreiben nicht einmal in gemeinsam genutzten Speicher. Sie skalieren deshalb nahezu perfekt mit der Anzahl der CPUs.

RCU: eine Liste ändern, während gelesen wird Liste A C B B′ Leser 1 Leser 1 ✓ Leser 2 Gnadenfrist: warten auf alle Leser, die vorher begonnen haben kfree(B)
  1. Ausgangslage

    Eine verkettete Liste A → B → C. Leser 1 hat mit rcu_read_lock() begonnen und liest gerade Element B. rcu_read_lock() kostet fast nichts: Auf Kerneln ohne Verdrängung ist es praktisch leer, sonst erhöht es nur einen Zähler im Task.

  2. Copy: der Schreiber legt eine Kopie an

    Der Schreiber will B ändern. Statt B selbst zu verändern (Leser 1 könnte dann halb alte, halb neue Daten sehen), erzeugt er eine Kopie B′ mit den neuen Werten und lässt sie ebenfalls auf C zeigen. Noch sieht kein Leser B′.

  3. Update: der Zeiger wird umgehängt

    Mit rcu_assign_pointer() lässt der Schreiber A auf B′ zeigen. Das ist eine einzige, atomare Zuweisung mit Speicherbarriere. Neue Leser wie Leser 2 sehen ab jetzt B′; Leser 1 liest ungestört weiter das alte, aber in sich konsistente B.

  4. Gnadenfrist abwarten

    B darf noch nicht freigegeben werden, solange ein Leser darauf zugreifen könnte. Der Schreiber wartet mit synchronize_rcu() (oder lässt sich per call_rcu() benachrichtigen), bis jede CPU einmal einen Punkt durchlaufen hat, an dem sie sicher außerhalb eines RCU-Leseabschnitts war. Leser 1 ist inzwischen fertig.

  5. Reclaim: das alte Element freigeben

    Nach der Gnadenfrist kann niemand mehr eine Referenz auf B haben – B wird freigegeben. Die Leser haben während des gesamten Vorgangs weder gewartet noch eine Sperre angefasst. Deshalb ist RCU ideal für Daten, die sehr oft gelesen und selten geändert werden, etwa Routing-Tabellen oder die Dateisystem-Caches.

Read-Copy-Update: Leser kommen ganz ohne Sperren aus. Der Schreiber ersetzt ein Element durch eine Kopie, veröffentlicht sie mit einer einzigen Zeigerzuweisung und gibt das alte Element erst frei, wenn kein Leser mehr darauf zugreifen kann.

In Code sieht das so aus:

/* Leser – beliebig viele gleichzeitig, auch im Interruptkontext */
rcu_read_lock();
r = rcu_dereference(routing_tabelle);   /* Zeiger sicher lesen */
ziel = suche(r, adresse);
rcu_read_unlock();

/* Schreiber – untereinander z. B. mit einem Mutex synchronisiert */
neu = kopiere_und_aendere(alt);
rcu_assign_pointer(routing_tabelle, neu);   /* veröffentlichen */
synchronize_rcu();                           /* Gnadenfrist abwarten */
kfree(alt);                                  /* oder kurz: kfree_rcu(alt, rcu) */

Woher weiß der Kernel, dass die Gnadenfrist vorbei ist? Ein RCU-Leser darf in seinem Leseabschnitt nicht schlafen (in der Grundvariante). Hat also jede CPU seit Beginn der Wartezeit mindestens einmal einen Ruhezustand durchlaufen, also einen Kontextwechsel, den Leerlauf oder die Rückkehr in den User Space, kann kein alter Leser mehr aktiv sein. RCU muss also nicht die Leser zählen, sondern nur beobachten, was die CPUs ohnehin tun.

RCU wird im Kernel an tausenden Stellen eingesetzt, unter anderem für Routing-Tabellen, den Dentry-Cache des VFS, die Prozessliste und die Liste der geladenen Module. Paul McKenney, der Hauptentwickler von RCU in Linux, hat das Verfahren ausführlich dokumentiert (Documentation/RCU/).

Deadlocks

Ein DeadlockDeadlock (Verklemmung)Zustand, in dem zwei oder mehr Beteiligte jeweils auf eine Sperre warten, die ein anderer hält, sodass keiner weiterkommt. entsteht, wenn sich Abläufe gegenseitig blockieren. Das klassische Muster heißt ABBA:

CPU 0                         CPU 1
mutex_lock(&A);               mutex_lock(&B);
mutex_lock(&B);  /* wartet */ mutex_lock(&A);  /* wartet */

Beide warten für immer aufeinander. Die wichtigste Regel dagegen lautet: Sperren immer in derselben, festgelegten Reihenfolge nehmen. Größere Subsysteme dokumentieren diese Reihenfolge ausdrücklich in Kommentaren.

Weil solche Fehler nur bei ungünstigem Timing auftreten, hat der Kernel einen eingebauten Prüfer: lockdep (CONFIG_PROVE_LOCKING). Er protokolliert zur Laufzeit, welche Sperren in welcher Reihenfolge und in welchem Kontext genommen werden, und meldet schon die bloße Möglichkeit eines Deadlocks, etwa „possible circular locking dependency detected“, auch wenn er tatsächlich noch nie aufgetreten ist. Kernel-Entwickler testen ihre Änderungen deshalb mit aktiviertem lockdep.

Selbst ausprobieren

Selbst ausprobieren: Eine Race Condition im User Space

Dasselbe Problem tritt in jedem Programm mit mehreren Threads auf. Speichere den folgenden Code als race.c:

// race.c – übersetzen mit: gcc -O2 -pthread -o race race.c
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>

#define N 10000000

static volatile long counter;          /* ungeschützt */
static atomic_long atomic_counter;     /* atomar */

static void *worker(void *arg)
{
	(void)arg;
	for (long i = 0; i < N; i++)
		counter++;                              /* laden, erhöhen, speichern */
	for (long i = 0; i < N; i++)
		atomic_fetch_add(&atomic_counter, 1);   /* unteilbar */
	return NULL;
}

int main(void)
{
	pthread_t a, b;
	pthread_create(&a, NULL, worker, NULL);
	pthread_create(&b, NULL, worker, NULL);
	pthread_join(a, NULL);
	pthread_join(b, NULL);
	printf("erwartet:    %ld\n", 2L * N);
	printf("ungeschützt: %ld\n", counter);
	printf("atomar:      %ld\n", (long)atomic_counter);
	return 0;
}
gcc -O2 -pthread -o race race.c && ./race

Auf einem Mehrkernrechner fehlt beim ungeschützten Zähler typischerweise ein großer Teil der Erhöhungen, oft fast die Hälfte. Der atomare Zähler stimmt immer.

Selbst ausprobieren: Sperren im laufenden System

# Wie warten Threads im User Space auf Sperren? Über den Systemaufruf futex
strace -f -c -e trace=futex ./race

# Konkurrenz um Kernel-Sperren messen (benötigt perf und Root-Rechte)
sudo perf lock contention -a -- sleep 5

Ein pthread_mutex im User Space benötigt nur dann einen Systemaufruf, wenn tatsächlich gewartet werden muss. Dahinter steckt der Futex, der in Kapitel 14 genauer erklärt wird.