eBPF-Spinlocks und die Kunst des Kernel-Debuggings
Wie eBPF-Spinlock-Bugs im Linux-Kernel auftreten, warum sie schwer zu finden sind und was das Debugging über eBPFs Sicherheitsmodell verrät.

Ein Spinlock-Bug in eBPF meldet sich nicht mit einem Stack Trace oder einer Fehlermeldung. Er zeigt sich dadurch, dass die Maschine nicht mehr reagiert. Keine Logs, keine Warnungen, kein kontrollierter Abbau – nur ein CPU-Kern, der endlos auf einen Lock dreht, der nie freigegeben wird. Wenn du Glück hast, schlägt der Watchdog-Timer an, und du bekommst einen Kernel Panic mit Trace. Wenn du Pech hast, friert die Maschine einfach ein, und du musst sie per Power-Cycle neu starten.
eBPF soll sicher sein. Es läuft im Kernel, durchläuft aber einen Verifier, der beweist, dass Programme terminieren, nicht auf ungültigen Speicher zugreifen und den Kernel-Zustand nicht korrumpieren. Wie kommen Spinlock-Bugs dann trotzdem durch? Der Verifier prüft einzelne Programme, Spinlock-Bugs entstehen aber aus dem Zusammenspiel von Programmen, Maps und den Scheduling-Entscheidungen des Kernels – Dinge, die ein statischer Verifier nicht vollständig vorhersagen kann.
Wofür eBPF-Spinlocks da sind
eBPF-Programme laufen im Kernel-Kontext, oft auf mehreren CPUs gleichzeitig. Wenn zwei eBPF-Programme denselben Map-Wert aktualisieren müssen – einen Zähler, eine Datenstruktur, einen Zustandsautomaten –, brauchen sie Synchronisation. Dafür stellt eBPF bpf_spin_lock und bpf_spin_unlock bereit.
Ein Spinlock ist der simpelste Lock überhaupt: Man versucht, ihn zu holen, und ist er belegt, dreht man in einer engen Schleife, bis er wieder frei ist. Kein Schlafen, kein Abgeben der CPU, keine Warteschlange. Das passt zu eBPF, weil eBPF-Programme in Kontexten laufen, in denen Schlafen verboten ist – Interrupt-Handler, Softirqs, Abschnitte mit deaktivierter Preemption. Ein Mutex, der den Aufrufer schlafen legt, würde den Kernel abstürzen lassen. Ein Spinlock verbrennt einfach CPU-Zyklen, bis der Lock frei wird.
struct map_value {
struct bpf_spin_lock lock;
__u64 counter;
__u32 last_pid;
};
SEC("tp/sched/sched_switch")
int handle_sched_switch(struct trace_event_raw_sched_switch *ctx) {
__u32 key = 0;
struct map_value *val;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
bpf_spin_lock(&val->lock);
val->counter++;
val->last_pid = ctx->next_pid;
bpf_spin_unlock(&val->lock);
return 0;
}
Das klingt unkompliziert, und für einfache Fälle wie diesen – sperren, Wert ändern, entsperren – funktioniert es auch gut. Die Probleme beginnen, wenn die Locking-Muster komplexer werden oder wenn die Wechselwirkungen zwischen eBPF-Programmen und dem eigenen Locking des Kernels unerwartete Abhängigkeiten erzeugen.
So zeigen sich Spinlock-Bugs
Der klassische Spinlock-Bug ist ein Deadlock: Programm A hält Lock 1 und will Lock 2 holen, während Programm B Lock 2 hält und Lock 1 holen will. Beide drehen ewig und warten aufeinander. Im Kernel-Kontext heißt das: Diese CPU-Kerne sind weg – sie werden nie wieder etwas anderes ausführen.
Der eBPF-Verifier verhindert einige dieser Fälle. Er lehnt Programme ab, die gleichzeitig mehr als einen Spinlock halten, und schließt damit den klassischen AB-BA-Deadlock aus. Alle Deadlock-Szenarien kann er aber nicht verhindern, weil manche aus dem Verhältnis zwischen eBPF-Spinlocks und dem eigenen Locking des Kernels entstehen.
Ein Beispiel: Ein eBPF-Programm, das an einen Tracepoint gehängt ist, holt einen Spinlock in einer Map. Während der Lock gehalten wird, feuert ein Hardware-Interrupt auf derselben CPU. Der Interrupt-Handler führt ein weiteres eBPF-Programm aus, das denselben Spinlock holen will. Deadlock: Der Interrupt kann nicht zurückkehren, bevor er den Lock bekommt, aber der Lock kann nicht freigegeben werden, bevor das unterbrochene Programm weiterläuft, und das wiederum kann erst passieren, wenn der Interrupt zurückkehrt.
Deadlock scenario (same CPU):
CPU 0 running eBPF program A
→ acquires spinlock on map_value
→ hardware interrupt fires
→ CPU 0 runs interrupt handler
→ interrupt handler runs eBPF program B
→ program B tries to acquire same spinlock
→ spinlock is held by program A
→ program B spins... forever
→ interrupt handler never returns
→ program A never resumes
→ program A never releases lock
→ CPU 0 is permanently stuck
Der Kernel löst das bei eigenen Spinlocks, indem er Interrupts deaktiviert, während der Lock gehalten wird (spin_lock_irqsave). eBPF-Spinlocks machen es ähnlich: bpf_spin_lock deaktiviert Preemption und ab Kernel 5.1 zusätzlich Softirqs. Aber nicht alle Interrupt-Kontexte sind abgedeckt, und nicht alle Kernel-Versionen gehen identisch damit um. Genau dort stecken die subtilen Bugs.
Das Debugging-Problem
Spinlock-Probleme in eBPF zu debuggen ist aus konkreten Gründen schwer.
Die Reproduktion ist nicht deterministisch. Spinlock-Bugs hängen vom Timing ab: welche CPU welches Programm ausführt, wann Interrupts feuern, wie lange der kritische Abschnitt dauert. Ein Test, der 999-mal problemlos läuft, kann beim 1000. Mal deadlocken. Lokal lassen sie sich kaum zuverlässig reproduzieren, weil deine Entwicklungsmaschine eine andere CPU-Topologie, Interrupt-Frequenz und Last hat als die Produktion.
Der Fehlermodus zerstört die Beweise. Wenn ein Spinlock-Deadlock auftritt, führen die betroffenen CPUs nichts mehr aus, auch nicht die Logging- und Monitoring-Infrastruktur, die dir verraten würde, was passiert ist. Betrifft der Deadlock eine einzelne CPU, läuft die Maschine vielleicht noch weiter. Blockieren mehrere CPUs (oder hält die gesperrte CPU Ressourcen, die andere brauchen), hängt sich das ganze System auf.
Klassische Debugging-Tools helfen nicht weiter. Einen Debugger kannst du nicht einfach an den Kernel hängen, um den blockierten Zustand zu untersuchen (mit KGDB geht das zwar, aber die serielle Verbindung muss schon vor dem Deadlock eingerichtet sein). dmesg bringt nichts, weil niemand mehr hineinschreibt. Die einzig verlässlichen Informationen kommen vom Lockup-Detector, falls er anschlägt, bevor die Maschine endgültig stirbt.
Strategien, die tatsächlich funktionieren
Trotz dieser Hürden findet und behebt man eBPF-Spinlock-Bugs durchaus. So geht’s.
Analyse der Lock-Reihenfolge. Bevor du irgendetwas laufen lässt, analysiere das Locking-Verhalten jedes eBPF-Programms. Auf welche Maps greift jedes Programm zu? Welche Locks holt es? Können zwei Programme, die denselben Lock anfassen, auf derselben CPU laufen (gleicher Tracepoint oder Tracepoint gegen Interrupt)? Das ist mühsame Handarbeit, fängt aber die häufigsten Deadlock-Muster ab, bevor sie auftreten.
Überwachung der Lock-Haltezeit. Instrumentiere deine eBPF-Programme, um zu messen, wie lange Spinlocks gehalten werden. Ein Spinlock, der länger als ein paar Mikrosekunden gehalten wird, ist ein Warnsignal: Er vergrößert das Zeitfenster für interruptbasierte Deadlocks und lässt andere CPUs Zyklen mit Warten verschwenden. Kurze kritische Abschnitte sind schneller und sicherer.
// Monitoring lock hold time in eBPF
SEC("tp/sched/sched_switch")
int handle_switch(void *ctx) {
struct map_value *val;
__u64 start, elapsed;
__u32 key = 0;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
start = bpf_ktime_get_ns();
bpf_spin_lock(&val->lock);
// Critical section — keep this minimal
val->counter++;
bpf_spin_unlock(&val->lock);
elapsed = bpf_ktime_get_ns() - start;
// Report if lock was held too long
if (elapsed > 1000) { // > 1 microsecond
bpf_printk("lock held for %llu ns", elapsed);
}
return 0;
}
Per-CPU-Datenstrukturen. Der beste Spinlock-Bug ist der, der gar nicht auftreten kann. Wenn jede CPU mit ihrer eigenen Kopie der Daten arbeitet und du später aggregierst, gibt es keine Konkurrenz und keine Locks. eBPFs BPF_MAP_TYPE_PERCPU_HASH und BPF_MAP_TYPE_PERCPU_ARRAY sind genau dafür gedacht. Zähler, Histogramme und Akkumulatoren brauchen fast nie einen gemeinsamen Spinlock.
Atomare Operationen statt Locks. Für einfache Operationen wie das Erhöhen eines Zählers oder das Vergleichen und Tauschen eines Werts sind __sync_fetch_and_add und ähnliche Atomics schneller und deadlock-frei. Zum Hochzählen brauchst du keinen Spinlock. Reserviere Spinlocks für Operationen, bei denen tatsächlich mehrere Felder gemeinsam atomar aktualisiert werden müssen.
Was das über eBPFs Sicherheitsmodell verrät
Die Sicherheitsgeschichte von eBPF ist beeindruckend, aber nicht ohne Nuancen. Der Verifier garantiert, dass einzelne Programme sicher sind: Sie terminieren, greifen nicht auf ungültigen Speicher zu und korrumpieren den Kernel-Zustand nicht. Aber „sichere“ Programme können sich zu unsicheren Systemen zusammensetzen, wenn ihre Wechselwirkungen Timing-Abhängigkeiten erzeugen, die der Verifier nicht analysieren kann.
Das ist eine grundsätzliche Grenze statischer Analyse. Der Verifier betrachtet jedes Programm isoliert. Er weiß nicht, welche anderen Programme geladen sind, welche Maps sie sich teilen oder in welchen Kernel-Kontexten sie laufen. Ein Programm, das einen Spinlock holt, ist „sicher“, denn es sperrt und entsperrt korrekt. Ob es aber in Kombination mit jedem anderen geladenen eBPF-Programm sicher ist, hängt von Laufzeitbedingungen ab.
Die Kernel-Community geht das schrittweise an. Neuere Patches haben eingeschränkt, welche eBPF-Programmtypen Spinlocks nutzen dürfen, wie lange kritische Abschnitte sein dürfen und welche Operationen erlaubt sind, während ein Spinlock gehalten wird. Jede Einschränkung verkleinert das Zeitfenster für Bugs, begrenzt aber auch, was eBPF-Programme tun können.
Praktische Regeln für eBPF-Locking
Nach genug Spinlock-Problemen zeichnen sich Muster ab, wie man sie vermeidet.
- Bevorzuge Per-CPU-Maps. Wenn sich deine Daten nach CPU aufteilen und im User Space zusammenführen lassen, mach das. Keine Locks, keine Konkurrenz, keine Deadlocks. Das deckt 80 % der Anwendungsfälle ab, also Zähler, Event-Logs und Histogramme.
- Bevorzuge Atomics gegenüber Spinlocks. Brauchst du einen gemeinsamen Zähler oder einen Compare-and-Swap, nutze atomare Operationen. Sie sind lock-frei und können nicht deadlocken.
- Wenn du Spinlocks brauchst, halte die kritischen Abschnitte winzig. Zähler erhöhen, Zeitstempel aktualisieren, Zeiger tauschen. Mehr nicht. Ruf während eines gehaltenen Locks niemals Helper-Funktionen auf, denn du weißt nicht, welches Locking sie intern verwenden.
- Halte denselben Lock nie aus zwei eBPF-Programmtypen heraus. Sperren ein kprobe-Programm und ein Tracepoint-Programm denselben Map-Wert, bist du einen Interrupt vom Deadlock entfernt. Nutze getrennte Maps oder Per-CPU-Daten.
- Teste unter Last. Spinlock-Bugs hängen vom Timing ab. Sie treten bei Konkurrenz auf, also bei hoher CPU-Auslastung, häufigen Interrupts und vielen gleichzeitig laufenden eBPF-Programmen. Teste mit realistischer Produktionslast, nicht auf leerlaufenden Entwicklungsmaschinen.
eBPF hat grundlegend verändert, wie wir mit dem Linux-Kernel interagieren, und gibt User-Space-Programmen sicheren Zugriff auf Observability und Netzwerkfunktionen auf Kernel-Ebene. Aber „sicher“ hat Grenzen. Der Verifier macht eBPF deutlich sicherer als das Schreiben roher Kernel-Module, und zwar drastisch. Er macht eBPF aber nicht so sicher, wie User-Space-Programmierung sicher ist. Zu verstehen, wo diese Grenzen liegen, besonders bei gemeinsamem Zustand und Locking, trennt eBPF-Programme, die in der Produktion laufen, von solchen, die nur in Tests funktionieren.


