2 Punkte von GN⁺ 2025-10-09 | 1 Kommentare | Auf WhatsApp teilen
  • Cloudflare entdeckte beim Überwachen von Traffic im großen Maßstab einen seltenen Race-Condition-Bug im Go-Compiler auf der arm64-Plattform
  • Der Bug zeigte sich dadurch, dass Services beim Stack-Unwinding unerwartet in einen Panic-Zustand gerieten oder Speicherzugriffsfehler auftraten
  • Bei der Ursachenanalyse wurde bestätigt, dass das Problem zwischen der asynchronen Preemption der Go-Runtime und zwei vom Compiler erzeugten Instruktionen zur Anpassung des Stack-Pointers auftrat
  • Mit minimalem Reproduktionscode wurde nachgewiesen, dass es sich um ein Problem der Go-Runtime selbst handelt, und dass ein Race-Window von genau einer Instruktionsgröße existiert, in dem der Stack-Pointer nur unvollständig verändert wird
  • Das Problem wurde in go1.23.12, go1.24.6, go1.25.0 behoben; der neue Ansatz vermeidet Stack-Pointer-Manipulationen, die nicht sofort atomar geändert werden können, wodurch die Race Condition grundsätzlich blockiert wird

Analyse des von Cloudflare gefundenen Go-ARM64-Compiler-Bugs

Die Rechenzentren von Cloudflare verarbeiten in mehr als 330 Städten weltweit pro Sekunde 84 Millionen HTTP-Anfragen. In einer solchen Umgebung mit massivem Traffic werden selbst seltene Bugs regelmäßig sichtbar. Dieser Beitrag analysiert detailliert anhand eines realen Falls ein Race-Condition-Problem im vom Go-Compiler für die arm64-Plattform erzeugten Code.

Untersuchung eines seltsamen Panic-Phänomens

  • Im Cloudflare-Netzwerk laufen Services, die den Traffic von Produkten wie Magic Transit und Magic WAN im Kernel konfigurieren
  • Auf arm64-Maschinen wurden im Monitoring seltene, aber wiederkehrende fatal panic-Meldungen erkannt
  • Die erste Analyse zeigte, dass beim Stack-Unwinding eine Integritätsverletzung erkannt wurde; in älterem Code, der das Muster panic/recover verwendete, traten Panics besonders häufig auf
  • Vorübergehend wurde die panic/recover-Struktur entfernt, um die Häufigkeit der Panics zu senken, später traten jedoch verdächtige fatale Panics noch häufiger auf
  • Daher wurde entschieden, dass eine tiefere Ursachenanalyse über bloßes Muster-Tracking hinaus notwendig war

Überblick über Datenstrukturen der Go-Runtime und des Schedulers

  • Go verwendet als leichtgewichtigen User-Space-Scheduler ein M:N-Scheduling-Modell, bei dem mehrere Goroutines auf eine kleine Zahl von Kernel-Threads abgebildet werden
  • Die zentralen Strukturen des Schedulers sind g (Goroutine), m (Maschine/Kernel-Thread) und p (Prozessor)
  • Fehler beim Stack-Unwinding oder Speicherzugriffsfehler treten auf, wenn sich Stack-Pointer oder Return-Adresse ungewöhnlich verändern

Strukturelle Ursache der Fehler beim Stack-Unwinding

  • Die Analyse mehrerer Backtraces zeigte, dass alle Fehler während des Stack-Unwindings in der Funktion (*unwinder).next auftraten
  • In einem Fall war die Return-Adresse null, wodurch der Stack als ungültig erkannt und mit einem fatalen Fehler beendet wurde; in einem anderen Fall trat beim Zugriff auf ein Feld (incgo) der Go-Scheduler-Struktur m innerhalb eines Stack-Frames ein Segmentation Fault auf
  • Der Crash trat an einer Stelle auf, die deutlich vom eigentlichen Ort des Bugs entfernt lag, was die Ursachenanalyse erschwerte

Beobachtete Muster und Zusammenhang mit der Go-Netlink-Bibliothek

  • Die Prüfung der Stack Traces ergab, dass sich die Crashes auf Zeitpunkte konzentrierten, an denen eine Preemption in der Funktion NetlinkSocket.Receive der Go-Netlink-Bibliothek ausgelöst wurde
  • Danach wurden zwei Hypothesen aufgestellt
    • ein möglicher Bug durch die Verwendung von unsafe.Pointer in Go Netlink
    • ein möglicher Bug in der asynchronen Preemption und im Stack-Unwinding der Go-Runtime selbst
  • Eine Code-Auditierung fand jedoch keine direkten Muster von Speicherbeschädigung, weshalb vermutet wurde, dass der Kern des Problems in der Runtime und ihrer Strategie zur Stack-Verwaltung lag

Asynchrone Preemption und Race Condition

  • Die seit Go 1.14 eingeführte asynchrone Preemption erzeugt für lange laufende Goroutines erzwungene Scheduling-Punkte, indem ein Signal (SIGURG) an den OS-Thread gesendet wird
  • Wenn diese Preemption zwischen zwei Assembler-Instruktionen zur Anpassung des Stack-Frame-Pointers eintritt, bleibt der Stack-Pointer in einem Zwischenzustand stehen
  • Wird der Stack für Garbage Collection, Panic-Handling oder das Erzeugen eines Stack Trace unwinded, können falsche Positionen gelesen und dadurch Funktionsadressen oder Daten falsch interpretiert werden

Erstellung von minimalem Reproduktionscode

  • Durch Anpassen der Größe der Stack-Frame-Allokation sowie das Schreiben einer Funktion mit expliziter Stack-Anpassung (big_stack) und Code für ständige Garbage-Collection-Aufrufe ließ sich die Race Condition reproduzieren
  • Tatsächlich wurde der Stack-Pointer im Assembler-Code durch zwei ADD-Instruktionen angepasst; wenn dazwischen asynchrone Preemption auftrat, kam es beim Stack-Unwinding zum Crash
  • Der Defekt ließ sich sogar nur mit Code aus der Standardbibliothek reproduzieren und bewies damit eine im vom Go-Compiler erzeugten Code angelegte Schwachstelle im Umfang von genau einer Instruktion

Ursache des Race-Windows auf ARM64-Compiler-Ebene

  • Wegen der festen Instruktionslänge der ARM64-Architektur und der Beschränkungen bei Immediate-Werten können für die Anpassung des Stack-Pointers zwei oder mehr Instruktionen nötig sein
  • In der internen Zwischenrepräsentation (IR) von Go ist die Länge solcher Immediate-Werte nicht bekannt; die Aufspaltung in mehrere Instruktionen erfolgt erst bei der Umwandlung in echten Maschinencode
  • Dadurch werden für die Rückgabe des Stack-Frames (ADD RSP, RSP) zwei Instruktionen verwendet, und es entsteht ein gegenüber Preemption anfälliges Race-Window von einer einzelnen Instruktion
  • Der Unwinder ist absolut auf die Korrektheit des Stack-Pointers angewiesen; wird mitten in einer Instruktionsfolge angehalten, führt das zu Fehlinterpretationen von Werten und fatalem Versagen
  • Der tatsächliche Crash-Ablauf sieht so aus:
    1. Asynchrone Preemption tritt zwischen den beiden ADD-Instruktionen auf
    2. Durch GC oder andere Ursachen läuft die Stack-Unwinding-Routine an
    3. Es wird eine ungewöhnliche Stack-Pointer-Position durchsucht und eine falsche Funktionsadresse interpretiert
    4. Die Runtime crasht

Bugfix und grundlegende Verbesserung

  • Das Cloudflare-Team meldete den Fehler auf Basis des minimalen Reproduktionscodes und der detaillierten Analyse an das offizielle Go-Repository; das Problem wurde schnell gepatcht und veröffentlicht
  • Seit go1.23.12, go1.24.6, go1.25.0 wird zuerst der vollständige Offset in einem temporären Register berechnet und danach der Stack-Pointer in einer einzigen Instruktion verändert, wodurch die Preemption-Anfälligkeit beseitigt wird
  • Der Stack-Pointer ist damit nun jederzeit garantiert gültig, sodass die Race Condition strukturell blockiert wird
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET

Fazit und Einordnung

  • Dieser Bug ist ein Beispiel dafür, wie Compiler-Codegenerierung für eine bestimmte Architektur und Nebenläufigkeitssteuerung durch asynchrone Preemption auf unerwartete Weise kollidieren können
  • Besonders bemerkenswert ist, dass hier eine extrem seltene Race Condition auf Instruktionsebene, die nur in Umgebungen mit großem Maßstab sichtbar wird, mit realen Betriebsdaten und systematischer Analyse aufgespürt wurde
  • Wenn Sie Services auf Basis aktueller Go-Umgebungen und der ARM64-Architektur betreiben, ist ein Upgrade auf die entsprechenden Go-Versionen wichtig

1 Kommentare

 
GN⁺ 2025-10-09
Hacker-News-Kommentare
  • Wirklich ein beeindruckender Fund, und in dem Moment, in dem ich den Assemblercode sah, konnte ich den Debugging-Pfad direkt nachvollziehen. Eigentlich ist diese Vorgehensweise nicht nur auf Assembler beschränkt; sie könnte auch auf IR-Ebene möglich sein, wird aber aus verschiedenen Gründen nicht so gemacht. Dass man ARM-Assembler lesen kann, ist hier ein großer Vorteil. Ich hatte auch überlegt, die Stack-Größe per push oder pop zu verändern, um Befehlszeilen zu sparen, war mir aber nicht sicher, weil ich nicht genau weiß, was der GC konkret prüft. Würde gern andere Meinungen dazu hören.
    • Normalerweise verwendet man dafür die ARM-Pseudoinstruktion „LDR Rd, =expr“. Wenn sich eine Konstante nicht direkt erzeugen lässt, wird sie an einer PC-relativen Position abgelegt und dann relativ zum PC in ein Register geladen. Damit lässt sich der Schritt „Konstante zu SP addieren“ in 2 ausführbare Instruktionen umwandeln, und man braucht insgesamt 12 Byte: 8 Byte Code und 4 Byte Datenbereich (für eine 17-Bit-Konstante). Dokumentation dazu: Erklärung der LDR-Pseudoinstruktion
    • Es überrascht mich, dass dieser Sonderfall beim Addieren eines Immediate-Werts zu RSP im Assembler nicht speziell behandelt wurde. Wenn der Patch nur im Compiler angewendet wurde, könnte dasselbe Problem auch an anderen Stellen im aarch64-Assembler bestehen bleiben.
    • Diese merkwürdige Syntax mit Dollarzeichen in der ARM-Assembler-Schreibweise ist kein standardmäßiger AArch64-Assembler, und ich hätte mir gewünscht, dass im Artikel auch die Regel erwähnt wird, dass „der Stack nur einmal verschoben werden darf“.
    • In Laufzeitumgebungen wie Java oder .NET setzt man Safepoints explizit, damit kein Kontextwechsel mitten in einer Instruktionsfolge stattfinden kann.
    • Die richtige Lösung scheint mir zu sein, dass der Compiler die Konstante in zwei Schritten in ein Register lädt und dann mit einem einzigen add den SP atomar anpasst. Natürlich ist das eine Instruktion mehr, aber die Atomizität ist damit gewährleistet. Alternativ könnte man auch erst mit einem temporären Register rechnen und dann zurückkopieren.
  • Für alle, die es eilig haben, hier der Link zum Fix-Commit: golang/go Commit-Link
    • Als ich mir das Issue angesehen habe, fragte ich mich, ob das Go-Team einen Natural-Language-Bot verwendet oder ob in den Kommentaren einfach nur auf das Schlüsselwort „backport“ geprüft wird. Relevanter Kommentar: github issue comment
  • Technisch ein hervorragender Blogpost, und weil die Erklärung so klar ist, versteht man ihn sehr leicht und fühlt sich danach fast klüger. Obwohl ich seit x86-Assembler lange keinen Assembler mehr angefasst hatte, konnte ich gut folgen. Und bei so einem Team entsteht auch Vertrauen, dass es jederzeit die Fähigkeit und Qualitätskontrolle hat, solche Probleme zu lösen. Ich hatte für den Serverausbau auch Ampere Altra in Betracht gezogen, habe wegen ausreichend Platz dann aber doch Epyc verwendet.
  • Ich denke, wenn es in Go einen Modus gäbe, der jede Instruktion im Single-Step ausführt und nach jeder Instruktion einen GC-Interrupt auslöst, ließen sich solche Bugs viel leichter finden.
  • Ich frage mich, wofür ARM64-Server eingesetzt werden. Letztes Jahr hieß es, dass Gen-12-Server auf AMD EPYC-Basis eingeführt werden, aber von ARM64 war keine Rede. Inzwischen scheint ARM64 in der Produktion eingesetzt zu werden.
    • Ich arbeite nicht bei Cloudflare, aber ich lese ihren Blog oft, und soweit ich weiß, setzen sie unter anderem wegen Secure Boot schon seit einigen Jahren Ampere parallel zu AMD ein. Der Einsatzzweck scheint vor allem Edge-Effizienz zu sein, es könnte aber auch andere Anwendungen geben. Mehr Infos dazu hier: Beitrag zum Edge-Server-Design, Ampere Altra vs AWS Graviton2 und Qualcomm-ARM-Evaluierung.
    • Ich meine mich zu erinnern, dass Cloudflare einen Teil der Non-Edge-Computing-Workloads in der Public Cloud hostet, zum Beispiel die Control Plane. Also könnte das sein.
  • Ich dachte in letzter Zeit, Cloudflare würde nur noch 100 % Rust und x86 (EPYC) verwenden. Dass sie auch Go und ARM einsetzen, finde ich interessant.
  • Ich finde Cloudflare-Blogposts jedes Mal großartig, weil sie das Wesen von Engineering zeigen, ganz ohne Infrastruktur- oder ML-Magie. Irgendwann würde ich mich dort gern bewerben. Compiler-Bugs sind häufiger, als man denkt (früher habe ich in gcc jedes Jahr ein paar gefunden), aber wie im Artikel sind es oft seltene Fälle, die erst in großem Maßstab sichtbar werden. Die meisten kommen nie in solche Größenordnungen.
    • Ich frage mich, warum du dich nicht heute bewirbst.
  • Es muss betont werden, dass der Stack Pointer immer atomar angepasst werden muss.
    • Diejenigen, die die Präemption implementiert haben, haben den Code wohl mit x86 als Maßstab geschrieben, wo so etwas atomar geschieht, weil die Instruktion den Konstantwert direkt enthalten kann. Beim ARM-Port wurde das auf höherer Ebene automatisch aufgeteilt, und so ist dieser Bug entstanden. Niemandes persönlicher Fehler, aber ein ungünstiges Ergebnis.
    • Genau dieser Gedanke kam mir auch sofort.
  • Ich verstehe nicht ganz, wie ein Maschinen-Thread mitten zwischen zwei Instruktionen anhalten konnte. Ich frage mich, ob so etwas auf Bare Metal möglich ist.
    • Go verwendet Interrupts für GC-Benachrichtigungen.
    • Signale (signals)
  • Zu der Aussage im Artikel „Das war ein sehr interessantes Problem“: Es muss zwar befriedigend gewesen sein, so ein grundlegendes Problem zu lösen, aber solange es ungelöst ist, macht es sicher überhaupt keinen Spaß. Solche Bugs zehren an allen Nerven. Niemand denkt, dass die Standardbibliothek oder der Compiler schuld sein könnte, daher herrscht eine Kultur, in der Entwickler immer weiter nur ihren eigenen Code verdächtigen. Ich selbst habe einmal einen Bug in einer Standardbibliothek gefunden, und dass das Problem beim SDK lag, war das Allerletzte, woran ich gedacht habe. Dadurch verschwendet man Zeit an völlig falschen Stellen, und wenn es dann wie hier auch noch eine Race Condition ist, lässt es sich schwer reproduzieren und wirkt ständig so, als sei es verschwunden, nur um dann wieder aufzutauchen.
    • Dieser Kommentar fügt zwar eine eigene ähnliche Erfahrung hinzu, aber dadurch, dass er unbedingt der Freude des Autors widersprechen will, wirkt die Begeisterung eher abgeschwächt. Menschen empfinden unterschiedliche Dinge als interessant oder unterhaltsam.
    • Manche Menschen freuen sich gerade über sehr ungewöhnliches Debugging, unter dem andere leiden würden. Was für den einen frustrierend ist, ist für den anderen spannend.
    • Ich glaube, der Autor meinte wahrscheinlich nicht „fun“, sondern eher „satisfying“. Ich selbst habe einmal unter Zeitdruck einen sscanf-Bug in der Ubuntu-GCC-ARM-Toolchain gefunden. Spaß gemacht hat das nicht, aber als ich das Problem genau identifiziert und sogar einen Regressionstest geschrieben hatte, war es wirklich sehr befriedigend.
    • Wenn man einen tief sitzenden Fehler behebt, ist die Erleichterung danach enorm. Ich habe oft gerade dann die größte Freude empfunden, wenn ich Bugs im Compiler oder in der CPU behoben habe.
    • Wenn es in einer Managed Language zu einem Segfault kommt, ohne dass man überhaupt etwas wie Unsafe verwendet hat, ist das für mich meist ein Signal, dass das Problem wahrscheinlich nicht in meinem Code liegt.