# Low-Level-Synchronisation in Go: Atomics, Barrieren und Instruktionsumordnung
Das Verständnis von Synchronisationsmechanismen auf Prozessorebene ist kein akademischer Luxus – es ist eine praktische Notwendigkeit für Entwickler, die mehrfädige Go-Anwendungen erstellen. In diesem Artikel erklären wir, wie die CPU die Codeausführung optimiert, warum das die erwartete Logik in konkurrierenden Programmen durcheinanderbringt und welche Hardwareprimitiven die Ordnung wiederherstellen.
Wie der Prozessor den Programmierer austrickst: Umordnung und Out-of-Order-Ausführung
Wenn man x = 1; ready = true schreibt, erwartet man eine strikte sequentielle Reihenfolge. Der Prozessor? Fehlanzeige. Er ordnet unabhängige Instruktionen um, um Pipeline-Stalls zu überbrücken. Das nennt man Instruktionsumordnung. Ziel: Maximale Auslastung der Ausführungseinheiten und Verbergen von Speicherlatenzzeiten.
CPUs nutzen Out-of-Order-Ausführung: Instruktionen werden nicht in Programmreihenfolge ausgeführt, sondern sobald ihre Operanden bereit sind. Beispiel:
MOVQ A(SB), AX ; load from memory
MOVQ B(SB), BX ; load from memory
ADDQ DX, CX ; add registers
Der Prozessor kann mit ADDQ beginnen, weil die Operanden bereits in Registern sind, während die Loads im Hintergrund laufen – sie brauchen Hunderte von Takten. Das logische Ergebnis für den aktuellen Thread bleibt gleich, aber andere Threads könnten etwas anderes sehen.
Pipeline-Stufen, in denen das Chaos entsteht:
- Fetch → Decode → Register Rename → Dispatch → Execute → Commit
Schlüsselstufe: Dispatch. Hier wählt der Scheduler Instruktionen mit fertigen Operanden aus und ignoriert die ursprüngliche Reihenfolge. Der Reorder Buffer (ROB) sorgt dafür, dass Instruktions-Commits zum architektonischen Zustand in der richtigen Sequenz erfolgen – aber nur für den aktuellen Kern.
Speicherumordnung: Wenn der Store Buffer Sie verrät
Das Problem verschärft sich auf Speicherebene. Schreibvorgänge in den RAM sind nicht sofortig – sie landen im Store Buffer und werden asynchron in den Cache gespült. Für einen Kern:
x = 1
ready = true
Für einen anderen Kern könnte es so aussehen:
ready = true
x = 0
Weil der ready=true-Schreibvorgang früher den Store Buffer verlassen könnte. Das ist Speicherumordnung – Umordnung von Speichervorgängen, die anderen Threads sichtbar sind. Klassisches Data-Race-Beispiel:
G1:
x = 1
ready = true
G2:
if ready {
print(x) // might print 0!
}
Speicherbarrieren: MFENCE und andere Werkzeuge zur Wiederherstellung der Ordnung
Lösung: Memory Fences (Speicherbarrieren). Diese Instruktionen erzwingen die Synchronisation der Operanden-Sichtbarkeit zwischen Kernen. Auf x86 ist die Hauptbarriere MFENCE:
MOVQ $1, x(SB)
MFENCE ; barrier
MOVQ $1, ready(SB)
MFENCE garantiert, dass alle Stores davor allen Kernen sichtbar sind, bevor Operationen danach ausgeführt werden. Es verbietet vier Arten von Umordnungen:
- Store → Store
- Load → Load
- Load → Store
- Store → Load
Wichtig: x86 hat ein relativ starkes Speichermodell – viele Umordnungen sind hardwareseitig bereits verboten. Aber Store→Load ist immer noch möglich, daher bleibt MFENCE für strenge Synchronisation essenziell.
Arten von Barrieren auf verschiedenen Architekturen:
- x86: MFENCE (voll), SFENCE (store→store), LFENCE (load→load)
- ARM: DMB (Data Memory Barrier), ISH (inner shareable domain)
- RISC-V: FENCE (flexibel, mit Richtungsangaben)
Atomare Operationen: High-Level-Abstraktion über LOCK
Go versteckt Barrieren hinter atomaren Operationen aus dem sync/atomic-Paket. Unter der Haube verwenden sie das LOCK-Präfix, das Read-Modify-Write-Operationen über alle Kerne hinweg unteilbar macht. Beispiel XADDQ:
TEXT ·Xadd64(SB), NOSPLIT, $0-24
MOVQ ptr+0(FP), BX
MOVQ delta+8(FP), AX
MOVQ AX, CX
LOCK
XADDQ AX, 0(BX) ; atomically: tmp=*ptr; *ptr+=AX; AX=tmp
ADDQ CX, AX
MOVQ AX, ret+16(FP)
RET
LOCK funktioniert, indem es die Cache-Zeile im MESI-Protokoll sperrt:
- Kern erwirbt exklusive Rechte an der Cache-Zeile
- Führt die Operation aus
- Gibt die Zeile frei – Änderungen werden anderen Kernen sichtbar
Atomarität ≠ Reihenfolge! Eine atomare Operation garantiert Unteilbarkeit, schafft aber nicht notwendigerweise eine Speicherbarriere. Auf x86 sind einfache Load/Store atomar, bieten aber keine Reihenfolge.
Eigene atomare AND schreiben: Praxis mit Go-Assembler
Die Go-Standardbibliothek bietet keine atomic.And. Implementieren wir sie selbst. Erstellen Sie die Datei Xand8_x86.s:
#include "textflag.h"
TEXT ·And8(SB), NOSPLIT, $0-9
MOVQ ptr+0(FP), BX // BX = pointer
MOVB mask+8(FP), AX // AX = mask (8 bits)
LOCK
ANDB AX, 0(BX) // *ptr &= mask
RET
Und die zugehörige main.go:
package main
import "C"
func And8(ptr *uint8, mask uint8)
func main() {
var smth uint8 = 3
And8(&smth, uint8(2))
println("Result:", smth) // outputs 2
}
Bauen und ausführen mit go run . – die ASM-Datei wird automatisch erkannt. Hinweis: Wir verwenden ANDB mit LOCK-Präfix, obwohl x86 keine XAND-Instruktion hat. Das funktioniert – LOCK ist mit grundlegenden logischen Operationen kompatibel.
Wichtige Erkenntnisse
- Instruktionsumordnung – CPU-Optimierung, die die Ausführungsreihenfolge unabhängiger Instruktionen zur Leistungssteigerung ändert.
- Speicherumordnung – Desynchronisation der Sichtbarkeit von Schreibvorgängen zwischen Kernen aufgrund des Store Buffers.
- Speicherbarrieren (Barrieren) – Instruktionen, die die Reihenfolge von Speichervorgängen zwischen Threads gewaltsam synchronisieren.
- Atomare Operationen – unteilbare Read-Modify-Write-Aktionen, implementiert über LOCK und Barrieren kapselnd.
- LOCK + ANDB – eine funktionierende Methode, um eine bitweise atomare Operation zu implementieren, die in der Go-Standardbibliothek fehlt.
— Editorial Team
Noch keine Kommentare.