Was atomic eigentlich ist
Ein Mutex ist eine Software-Konstruktion: Er sperrt einen kritischen Abschnitt, lässt Goroutines warten, parkt sie im Scheduler. Ein atomarer Zugriff ist etwas grundsätzlich anderes. Er ist eine einzelne CPU-Instruktion — auf x86 typischerweise LOCK XADD für ein atomares Inkrement oder LOCK CMPXCHG für Compare-and-Swap. Die CPU garantiert, dass diese Instruktion unteilbar abläuft: Kein anderer Kern kann zwischen Lesen und Schreiben in dieselbe Cache-Line eingreifen.
Das Paket sync/atomic ist die Brücke zwischen Go-Code und genau diesen Hardware-Primitiven. Du bekommst keine Locks im klassischen Sinne, sondern Operationen, die in einem Maschinenzyklus konsistent sind. Das macht atomic dort sinnvoll, wo es um einen einzelnen Wert auf einem heißen Pfad geht — ein Zähler, ein Flag, ein Pointer auf eine immutable Datenstruktur.
Die Grenze ist ebenso wichtig wie die Möglichkeit. Atomic schützt genau eine Speicherzelle mit fester Größe. Sobald dein Zustand aus mehreren Feldern besteht — etwa „inkrementiere total und schiebe gleichzeitig einen Eintrag in recent" — kannst du das nicht mehr atomar machen. Dann brauchst du einen Mutex oder ein Channel-basiertes Design. Wer atomic für zusammengesetzten State zweckentfremdet, baut subtile, schwer reproduzierbare Bugs.
Typisierte Wrapper ab Go 1.19
Seit Go 1.19 gibt es typisierte Wrapper-Typen, die das alte funktionale API vollständig ersetzen — und das ist die API, die du in neuem Code verwenden solltest. Verfügbar sind atomic.Int32, atomic.Int64, atomic.Uint32, atomic.Uint64, atomic.Uintptr, atomic.Bool und der generische atomic.Pointer[T]. Jeder dieser Typen bietet Load, Store, Swap, CompareAndSwap; numerische Typen zusätzlich Add.
Der Zero-Value ist direkt nutzbar — kein New…, kein Initialisierungs-Call. Du deklarierst var counter atomic.Int64 und kannst sofort counter.Add(1) aufrufen. Die Wrapper kümmern sich außerdem um das 64-Bit-Alignment (dazu später), was vor 1.19 eine berüchtigte Stolperfalle war.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Metrics struct {
requests atomic.Int64 // Zero-Value: 0, sofort nutzbar
errors atomic.Int64
active atomic.Bool
}
func main() {
m := &Metrics{}
m.active.Store(true)
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
m.requests.Add(1)
if i%100 == 0 {
m.errors.Add(1)
}
}(i)
}
wg.Wait()
fmt.Println("requests:", m.requests.Load())
fmt.Println("errors:", m.errors.Load())
fmt.Println("active:", m.active.Load())
}Wichtig: Diese Wrapper sind nicht kopierbar. Sobald du Metrics per Wert kopierst, fragmentierst du den Zustand — jede Kopie hat ihre eigenen Zähler. Deshalb arbeitest du fast immer mit Pointern auf Structs, die atomic-Felder enthalten. go vet warnt dich, falls du einen atomic-Typ versehentlich kopierst.
Die alten Funktions-APIs
Vor Go 1.19 sah jeder atomic-Zugriff so aus: atomic.AddInt64(&x, 1), atomic.LoadInt64(&x), atomic.StoreInt64(&x, 42). Du übergibst einen Pointer auf eine normale int64-Variable, das Paket erledigt den Rest. Diese Funktionen existieren weiterhin und werden auch nicht deprecated — viel Bestandscode lebt damit, und das Go-Team nimmt Backwards-Compat ernst.
package legacy
import "sync/atomic"
type Counter struct {
value int64 // muss 8-Byte-aligned sein — siehe Alignment-Sektion
}
func (c *Counter) Inc() { atomic.AddInt64(&c.value, 1) }
func (c *Counter) Get() int64 { return atomic.LoadInt64(&c.value) }
func (c *Counter) Set(v int64) { atomic.StoreInt64(&c.value, v) }Für neuen Code sind die typisierten Wrapper die bessere Wahl. Drei Gründe: Erstens kapseln sie das Alignment-Problem, sodass deine Structs robust auf 32-Bit-Plattformen funktionieren. Zweitens machen sie unmissverständlich klar, dass dieses Feld atomar zu behandeln ist — niemand kann versehentlich c.value++ schreiben und damit eine Race-Condition bauen. Drittens ist die API lesbarer: c.requests.Add(1) statt atomic.AddInt64(&c.requests, 1).
CompareAndSwap — die Mutter aller Lock-freien Algorithmen
CompareAndSwap (CAS) ist die wichtigste Primitive überhaupt. Die Semantik in einem Satz: Vergleiche den aktuellen Wert mit einem erwarteten Wert; nur wenn beide gleich sind, schreibe den neuen Wert. Der Rückgabewert ist ein bool, der dir sagt, ob der Tausch stattgefunden hat. Genau in dieser bedingten Operation liegt die ganze Macht: Du kannst eine Änderung nur dann committen, wenn niemand anderes dir in der Zwischenzeit zuvorgekommen ist.
Aus CAS baust du CAS-Loops, das Standard-Pattern für lock-freie Algorithmen. Du liest den aktuellen Zustand, berechnest den neuen Zustand, versuchst per CAS zu schreiben; wenn ein anderer Goroutine dazwischenkam, läufst du die Schleife noch einmal. Im optimistischen Fall ohne Konkurrenz ist das exakt eine Iteration. Unter hoher Contention werden es mehrere, aber niemand schläft, niemand wird geparkt.
package lockfree
import "sync/atomic"
type node struct {
value int
next *node
}
type Stack struct {
head atomic.Pointer[node]
}
func (s *Stack) Push(v int) {
n := &node{value: v}
for {
old := s.head.Load() // Snapshot des aktuellen Heads
n.next = old // neuer Knoten zeigt darauf
if s.head.CompareAndSwap(old, n) {
return // Erfolg: niemand hat dazwischen gepusht
}
// Fehlschlag: jemand anderes hat den Head geändert — retry
}
}
func (s *Stack) Pop() (int, bool) {
for {
old := s.head.Load()
if old == nil {
return 0, false
}
if s.head.CompareAndSwap(old, old.next) {
return old.value, true
}
}
}Die Schleife liest aus, was sie für die aktuelle Welt hält, und versucht dann atomar zu commiten. Wird sie überholt, beginnt sie neu. Beachte: Echte produktionsreife Lock-freie Stacks müssen zusätzlich das ABA-Problem lösen (ein anderer Thread könnte denselben Pointer-Wert zurückgesetzt haben, obwohl die Welt sich geändert hat) — typischerweise mit Versions-Tags. Für das Verständnis von CAS-Loops reicht das obige Modell.
atomic.Value — beliebige Werte atomar austauschen
atomic.Value ist der Vorgänger von atomic.Pointer[T] und speichert einen beliebigen Wert hinter einem interface{}. Du rufst v.Store(x) und v.Load(). Seit Go 1.17 gibt es zusätzlich v.CompareAndSwap(old, new) und v.Swap(new). Die wichtige, leicht zu übersehende Regel: Alle Stores müssen denselben konkreten Typ haben. Beim ersten Store merkt sich atomic.Value den Typ; ein späterer Store mit abweichendem Typ panickt zur Laufzeit.
package config
import "sync/atomic"
type Config struct {
Endpoint string
Timeout int
}
var current atomic.Value // hält *Config
func init() {
current.Store(&Config{Endpoint: "https://api.example.com", Timeout: 30})
}
func Get() *Config {
return current.Load().(*Config) // Type-Assertion nötig
}
func Reload(c *Config) {
current.Store(c) // muss wieder *Config sein, sonst Panic
}Die Type-Assertion bei jedem Load und die Disziplin, immer denselben Typ zu speichern, sind die zwei Schwachpunkte. Beides löst der typsichere Nachfolger, den wir gleich anschauen.
atomic.Pointer[T] — typsicher und generisch
Mit Generics in Go 1.18 und den typisierten Wrappern in 1.19 kam atomic.Pointer[T]. Der Typ ist parametrisiert über deinen Pointer-Typ, und der Compiler sorgt dafür, dass nur *T-Werte hineinkommen — keine Type-Assertion mehr, keine Laufzeit-Panic bei Typ-Verwechslung. Intern arbeitet atomic.Pointer[T] mit unsafe.Pointer und atomarem Pointer-Tausch; das ist effektiv eine atomare Wort-Operation auf der Maschine.
package config
import "sync/atomic"
type Config struct {
Endpoint string
Timeout int
}
var current atomic.Pointer[Config]
func init() {
current.Store(&Config{Endpoint: "https://api.example.com", Timeout: 30})
}
func Get() *Config {
return current.Load() // direkt *Config, keine Assertion
}
func Reload(c *Config) {
current.Store(c) // Compiler erzwingt *Config
}In neuem Code ist atomic.Pointer[T] praktisch immer die richtige Wahl gegenüber atomic.Value. Die einzige Ausnahme: Du brauchst aus irgendeinem Grund die interface{}-Flexibilität — etwa für ein generisches Framework, das den konkreten Typ erst zur Laufzeit kennt. Das ist selten.
Word-Alignment auf 32-Bit-Systemen
Eine atomare 64-Bit-Operation funktioniert auf manchen 32-Bit-Architekturen (ARM32, x86 mit bestimmten Modi) nur dann zuverlässig, wenn die Adresse auf 8 Byte ausgerichtet ist. Wenn nicht, crasht das Programm zur Laufzeit oder — schlimmer — liefert silent korrupte Werte. Vor Go 1.19 musstest du das selbst sicherstellen: Ein int64, auf den du atomic.AddInt64 aufrufen wolltest, musste das erste Feld seiner Struct sein, sonst konnte der Compiler ihn ungünstig platzieren.
// FALSCH auf 32-Bit-Plattformen: bool davor verschiebt counter
type BadStats struct {
enabled bool // 1 Byte + 7 Bytes Padding nötig
counter int64 // könnte mis-aligned sein
}
// RICHTIG: int64 zuerst
type GoodStats struct {
counter int64
enabled bool
}Mit den typisierten Wrappern (atomic.Int64, atomic.Uint64) ist dieses Problem transparent gelöst. Der Wrapper enthält intern das nötige Padding, sodass das eingebettete 64-Bit-Wort garantiert aligned liegt — unabhängig davon, an welcher Stelle in einer Struct du den Wrapper platzierst. Das allein ist schon ein guter Grund, in neuem Code ausschließlich die Wrapper zu verwenden.
Sequential Consistency in Go
Wenn du aus C++ oder Rust kommst, kennst du atomic mit verschiedenen Memory-Ordering-Modi: relaxed, acquire, release, acq_rel, seq_cst. Go macht es einfacher und strenger: Alle atomaren Operationen verhalten sich nach Sequential Consistency. Das heißt, es gibt eine globale Reihenfolge aller atomaren Operationen, und jede Goroutine sieht diese Reihenfolge gleich. Es gibt keinen Modus, mit dem du Garantien aufweichen könntest, um Performance zu gewinnen.
Das Go Memory Model (seit der Überarbeitung von 2022) garantiert konkret: Wenn Goroutine A x.Store(1) ausführt und Goroutine B danach x.Load() == 1 beobachtet, dann sieht B auch alle nicht-atomaren Schreibvorgänge, die A vor dem Store ausgeführt hat. Das ist die zentrale Synchronisations-Eigenschaft, mit der atomic-Operationen als „Sync-Points" dienen können — vergleichbar mit Mutex-Lock/Unlock.
Der Preis: Sequential Consistency erfordert auf manchen Architekturen Memory-Barriers, die teurer sind als die schwächsten Modi in C++. In der Praxis ist der Unterschied für reale Go-Programme aber zu vernachlässigen, und das einfache mentale Modell spart dir mehr Bugs, als die Mikrooptimierung gewinnen könnte.
Lock-freier Counter mit Snapshot-Histogramm
In der Realität willst du oft beides kombinieren: einen heißen Zähler, der von vielen Goroutines hochgezählt wird, und eine gelegentlich konsistente Sicht auf abgeleitete Werte (Quantile, Min/Max, gleitendes Mittel). Atomic löst beides — den Zähler direkt, das Snapshot mit atomic.Pointer[T].
package metrics
import (
"sync/atomic"
"time"
)
type Snapshot struct {
Total int64
Timestamp time.Time
RatePerSec float64
}
type Counter struct {
total atomic.Int64
snapshot atomic.Pointer[Snapshot]
}
func (c *Counter) Inc() { c.total.Add(1) }
// updateSnapshot wird von genau einer Goroutine periodisch aufgerufen.
// Reader sehen entweder den alten oder den neuen Snapshot — niemals
// einen halben.
func (c *Counter) updateSnapshot(prev *Snapshot) *Snapshot {
now := time.Now()
total := c.total.Load()
var rate float64
if prev != nil {
dt := now.Sub(prev.Timestamp).Seconds()
if dt > 0 {
rate = float64(total-prev.Total) / dt
}
}
next := &Snapshot{Total: total, Timestamp: now, RatePerSec: rate}
if c.snapshot.CompareAndSwap(prev, next) {
return next
}
return c.snapshot.Load()
}
func (c *Counter) Latest() *Snapshot { return c.snapshot.Load() }Die Aufteilung ist sauber: Inc ist im heißen Pfad eine einzige CPU-Instruktion. Latest ist ein lock-freies Lesen des Snapshot-Pointers. Nur der periodische Updater zahlt den Preis für den Snapshot-Aufbau, und der CAS schützt vor Race-Conditions, falls jemand versehentlich zwei Updater startet.
Config-Hot-Reload ohne Mutex
Das klassische Anwendungsfeld für atomic.Pointer[T] ist die immutable Config, die zur Laufzeit ausgetauscht werden kann. Reader sind viele und heiß; Writer ist einer und selten. Ein Mutex wäre korrekt, aber jedes RLock/RUnlock kostet eine Atomic-Operation plus Buchhaltung — und Reader-Writer-Mutexen können bei sehr vielen Readern Cache-Line-Contention erzeugen.
package runtimecfg
import (
"sync/atomic"
"time"
)
type Config struct {
Generation int64
Endpoint string
Timeout time.Duration
Features map[string]bool
}
var current atomic.Pointer[Config]
func init() {
current.Store(&Config{
Generation: 1,
Endpoint: "https://api.example.com",
Timeout: 30 * time.Second,
Features: map[string]bool{"v2": true},
})
}
// Get ist im heißen Pfad. Kein Mutex, kein Lock, ein atomares Load.
func Get() *Config { return current.Load() }
// Reload baut eine NEUE Config-Instanz und tauscht den Pointer.
func Reload(endpoint string, timeout time.Duration, features map[string]bool) {
old := current.Load()
next := &Config{
Generation: old.Generation + 1,
Endpoint: endpoint,
Timeout: timeout,
Features: features,
}
current.Store(next)
}Die zentrale Disziplin: Die Config ist nach dem Store immutable. Niemand fügt mehr Einträge in Features ein, niemand schreibt Endpoint um. Wer ändern will, baut eine ganz neue Config und tauscht den Pointer. Solange Reader nur lesen, brauchen sie keinerlei Synchronisation — der einzelne atomare Load reicht.
Das Generation-Feld ist Gold wert in Produktion. In Logs siehst du sofort, welche Config-Version eine Anfrage gesehen hat; bei Inkonsistenzen erkennst du, wann der Wechsel passierte. Es kostet acht Bytes und ist in der Praxis unbezahlbar.
Besonderheiten
Eine Zelle, kein State
sync/atomic schützt genau eine Speicherzelle. Sobald dein zu schützender Zustand aus mehreren Feldern besteht, die zusammen konsistent sein müssen, nimm einen Mutex oder tausche einen kompletten Pointer über atomic.Pointer[T] auf eine immutable Struktur aus.
Pointer[T] schlägt Value
In neuem Code ist atomic.Pointer[T] fast immer die richtige Wahl gegenüber atomic.Value. Typsicher zur Compile-Zeit, keine Type-Assertions, keine Laufzeit-Panic bei vertauschten Typen.
Lock-frei = CAS-Loop
Das Standard-Pattern für lock-freie Updates: Lade aktuellen Wert, berechne neuen, versuche CompareAndSwap. Bei false retry. Im unkontended Fall genau eine Iteration; unter Contention ein paar mehr, aber niemand schläft.
Alignment ab 1.19 erledigt
Vor Go 1.19 musste ein int64 für atomare Operationen das erste Struct-Feld sein, sonst Crash auf 32-Bit. Die typisierten Wrapper kapseln das Padding intern — ein guter Grund, sie konsequent zu nutzen.
atomic.Bool statt int32-Flag
Vor 1.19 schrieb man Flags als atomic.StoreInt32(&ready, 1). Mit atomic.Bool bekommst du dieselbe Semantik mit lesbarer API: ready.Store(true), ready.Load().
Sequential Consistency überall
Go bietet ausschließlich Sequential Consistency — keine relaxed/acquire/release-Modi wie in C++. Etwas teurer auf manchen Architekturen, aber das mentale Modell ist drastisch einfacher und Bug-resistenter.
Mutex kann atomic schlagen
Bei sehr hoher Contention auf demselben CAS-Pointer kann ein Mutex tatsächlich schneller sein als eine atomic-CAS-Loop, weil der Scheduler Wartezeiten besser verteilt. Immer messen, nicht vermuten.
Immer mit -race testen
Atomic-Code mit Pointer-Casts oder unsafe-Tricks kann subtile Bugs verstecken, die nur unter Last auftreten. Tests müssen mit go test -race laufen, am besten auch Integrationstests in CI.
Weiterführende Ressourcen
Externe Quellen
sync/atomic— Go Documentation- The Go Memory Model — Atomic operations
- Go 1.19 Release Notes — typed atomics
- Russ Cox: Updating the Go Memory Model
atomic.Value— Go Documentation