Zurück zur Startseite

Synchronisation in Go: Atomics und Memory Barriers

Analyse von Synchronisationsmechanismen auf Prozessorebene: Anweisungs- und Speicherreordering, Memory Fences, atomare Operationen. Praktisches Beispiel zur Implementierung von atomic.And im Go Assembler.

Low-Level-Synchronisation in Go: von CPU zu Atomics
Advertisement 728x90

# 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:

Google AdInline article slot
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.

Google AdInline article slot

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:

Google AdInline article slot
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

Advertisement 728x90

Weiterlesen