Mutual Exclusion jenseits des Counter-Klassikers
Im Race-Detector-Kapitel hat sync.Mutex den klassischen Counter gerettet. Dieser Artikel geht eine Schicht tiefer: er erklärt, wie der Lock intern signalisiert, warum er bewusst nicht reentrant ist, wie man ihn idiomatisch in Strukturen einbettet und wo die Falltüren liegen, die selbst erfahrene Teams regelmäßig aufreißen. Mutex ist nicht nur ein Werkzeug, um Schreibkonflikte auf einem int zu verhindern — er ist das Standardinstrument, um unveränderliche Invarianten über mehrere zusammengehörige Felder hinweg zu garantieren, etwa bei einem Cache, einem Verbindungspool oder einer Session-Tabelle.
Die offizielle Referenz unter pkg.go.dev/sync ist erstaunlich knapp, weil sie nur die Schnittstelle dokumentiert; die eigentliche Disziplin liegt im Umgang. Drei Designprinzipien tragen dabei alles, was folgt: erstens den Lock auf die kleinste sinnvolle Datenstruktur beschränken (nicht den ganzen Service, sondern den konkreten geteilten Zustand), zweitens defer mu.Unlock() unmittelbar nach mu.Lock() setzen, drittens akzeptieren, dass Go kein Recursive-Lock kennt — wer in einer gesperrten Methode eine andere gesperrte Methode aufruft, deadlockt sich selbst.
Mutex steht für „mutual exclusion": zu jedem Zeitpunkt darf höchstens eine Goroutine den geschützten Abschnitt betreten. Dieses Versprechen klingt simpel, ist aber die Grundlage, auf der höhere Abstraktionen wie sync.RWMutex, sync.Cond oder thread-safe Container ruhen. Wer Mutex souverän einsetzt, schreibt nebenläufigen Code, der sich später wie sequenzieller liest — und genau das ist das Ziel.
Wie sync.Mutex funktioniert
Ein sync.Mutex ist ein winziger Struct mit zwei Feldern, dessen Zero-Value bereits einsatzbereit ist. Es gibt also keinen Konstruktor NewMutex(), kein Init(), kein „vergessenes Setup". Wer ein var mu sync.Mutex deklariert oder einen Struct mit eingebettetem Mutex per &Cache{} anlegt, hat einen funktionsfähigen Lock. Diese Eigenschaft ist bewusst gewählt: sie macht Mutex in eingebetteten Feldern besonders bequem, weil keine separate Initialisierung nötig ist.
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex // Zero-Value reicht — kein NewMutex nötig.
mu.Lock()
fmt.Println("kritischer Abschnitt")
mu.Unlock()
// Zweite Sperre, nachdem die erste freigegeben wurde: problemlos.
mu.Lock()
fmt.Println("zweiter kritischer Abschnitt")
mu.Unlock()
}kritischer Abschnitt
zweiter kritischer AbschnittHinter Lock() steht eine schnelle atomare Compare-and-Swap-Operation für den unkontentierten Fall; nur wenn der Lock bereits gehalten wird, geht die Goroutine in eine Wartequeue und wird vom Runtime-Scheduler suspendiert. Wichtig ist die Happens-Before-Garantie, die das Go Memory Model an Unlock/Lock koppelt: jeder Speicherzugriff vor einem Unlock wird für die Goroutine sichtbar, die anschließend den passenden Lock erhält. Genau diese Garantie macht Mutex zum legitimen Werkzeug, um nicht nur einen int zu schützen, sondern eine ganze Datenstruktur, deren Felder konsistent zueinander sein müssen.
Eine Eigenschaft trennt Go von Java oder C#: sync.Mutex ist nicht reentrant. Wer in einer gesperrten Methode dieselbe Mutex erneut sperrt, blockiert sich selbst — der Runtime erkennt nach einer Weile den fehlenden Fortschritt und meldet einen Deadlock.
package main
import "sync"
type Account struct {
mu sync.Mutex
balance int
}
func (a *Account) Deposit(v int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += v
}
func (a *Account) DepositTwice(v int) {
a.mu.Lock()
defer a.mu.Unlock()
a.Deposit(v) // Falle: Deposit will erneut sperren -> Deadlock.
}
func main() {
a := &Account{}
a.DepositTwice(10)
}fatal error: all goroutines are asleep - deadlock!Diese Designentscheidung ist Absicht: Reentrant-Locks verschleiern, wer welchen Zustand wann hält, und verleiten zu undurchsichtigen Aufrufgraphen. Idiomatisches Go trennt deshalb gesperrte und ungesperrte Methoden konsequent. Eine verbreitete Konvention ist, interne Helfer mit Suffix Locked zu versehen (etwa evictLocked) — sie setzen voraus, dass der Aufrufer den Lock bereits hält, und sperren selbst nicht erneut.
Das Embedded-Mutex-Pattern
Die mit Abstand häufigste Form, einen Lock zu nutzen, ist das Einbetten in einen Struct, der die geschützten Daten zusammenfasst. Der Mutex steht dabei traditionell als erstes Feld und ist unexported, ebenso wie alle Felder, deren Konsistenz er garantiert. Dadurch entsteht eine klare API: Aufrufer sehen nur die Methoden, die intern korrekt sperren, und können den Zustand gar nicht erst ohne Lock anfassen.
package main
import "sync"
type Counter struct {
mu sync.Mutex // schützt 'value'
value int64
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}Drei Details fallen auf: Methoden verwenden einen Pointer-Receiver (*Counter), weil ein Wert-Receiver eine Kopie und damit einen separaten Mutex erzeugen würde; der Mutex steht im Code-Kommentar explizit als Schutz für value — das ist die wichtigste Doku-Konvention in nebenläufigem Go-Code; und auch das reine Lesen geht durch den Lock, weil die Happens-Before-Garantie sonst nicht greift und ein Reader veraltete oder zerrissene Werte sehen kann.
Bei mehreren geschützten Feldern hilft eine Kommentar-Notation, die in der Standard-Library und in großen Codebasen verbreitet ist: man trennt die Felder visuell und schreibt über die geschützte Gruppe ein // fields below are guarded by mu. Das macht in Code-Reviews sofort sichtbar, welcher Zustand zur selben Invariante gehört.
Die Copy-Lock-Falle
Ein Mutex darf nach der ersten Sperre niemals kopiert werden. Eine Kopie erzeugt einen zweiten, unabhängigen Lock — beide bewachen dieselben Daten, aber nichts mehr ist synchronisiert. Genau diese Falle entsteht erschreckend leicht: ein Wert-Receiver statt Pointer-Receiver, ein append(slice, structMitMutex), ein var b = a oder ein Aufruf wie process(c Counter).
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
value int
}
// Falsch: Wert-Receiver kopiert den Mutex.
func (c Counter) IncBroken() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func main() {
var c Counter
c.IncBroken()
fmt.Println(c.value) // 0 — die Kopie wurde inkrementiert, nicht c.
}0Der Compiler akzeptiert diesen Code, aber go vet läuft mit dem copylocks-Check und meldet die Stelle: IncBroken passes lock by value: main.Counter contains sync.Mutex. Diese Warnung gehört in jede CI-Pipeline; sie ist eine der wenigen Lint-Meldungen in Go, die quasi nie ein False Positive sind. Hinter ihr steckt ein einfaches Modell: jeder Typ, der einen sync.Locker enthält, ist semantisch nicht kopierbar, und das überträgt sich rekursiv auf umschließende Strukturen.
Die Konsequenz für die API: Methoden bekommen Pointer-Receiver, Funktionen, die solche Strukturen entgegennehmen, akzeptieren *Counter, nicht Counter. Wer dennoch eine logische „Kopie" braucht (etwa für einen Snapshot), schreibt eine explizite Methode wie Snapshot() CounterData, die unter Lock die Daten herauskopiert und einen separaten, lock-freien Wertetyp zurückgibt.
TryLock — die Ausnahme von der Regel
Seit Go 1.18 existiert Mutex.TryLock() bool. Die Methode versucht zu sperren und gibt sofort zurück: true, wenn der Lock erworben wurde, false, wenn er bereits gehalten wird. Sie blockiert nie. Die offizielle Doku enthält dazu einen ungewöhnlich deutlichen Hinweis: „Note that while correct uses of TryLock do exist, they are rare, and use of TryLock is often a sign of a deeper problem in a particular use of mutexes."
package main
import (
"sync"
"time"
)
// Refresher aktualisiert einen Cache periodisch.
// Wenn ein Refresh bereits läuft, soll der Tick nicht warten,
// sondern still übersprungen werden.
type Refresher struct {
mu sync.Mutex
}
func (r *Refresher) Tick() {
if !r.mu.TryLock() {
return // anderer Refresh läuft schon — Tick überspringen
}
defer r.mu.Unlock()
r.refresh()
}
func (r *Refresher) refresh() {
time.Sleep(500 * time.Millisecond) // simulierter Reload
}Die Anwendung oben ist legitim: ein periodischer Tick darf einen laufenden Refresh nicht stauen, weil der nächste Tick ohnehin wieder schaut. Das ist eine Best-Effort-Operation mit klarem Fallback. Für so etwas wurde TryLock gebaut. Sobald aber die Semantik lautet „dann mache ich eben etwas anderes Wichtiges" oder „dann hole ich mir das Recht woanders", ist die Architektur falsch — das ist genau der Geruch, vor dem die Standard-Doku warnt. Wer feingranulare „besetzt oder nicht"-Entscheidungen braucht, sollte stattdessen Channels mit select oder ein explizites Token-System einsetzen, weil dort die Semantik im Typsystem sichtbar wird, statt sich hinter einem Lock-Versuch zu verstecken.
sync.RWMutex — Reader-Writer-Lock
sync.RWMutex erweitert das Mutex-Modell um eine zweite Sperrart: neben dem exklusiven Schreibzugriff via Lock/Unlock gibt es den geteilten Lesezugriff via RLock/RUnlock. Beliebig viele Goroutinen dürfen gleichzeitig im RLock stehen, solange kein Writer hält oder wartet. Sobald ein Writer kommt, blockiert er neue RLock-Anfragen und wartet, bis alle laufenden Reader fertig sind — danach läuft er exklusiv.
package main
import "sync"
// ConfigStore wird selten geschrieben, aber sehr oft gelesen.
type ConfigStore struct {
mu sync.RWMutex
data map[string]string
}
func (c *ConfigStore) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func (c *ConfigStore) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}Die Faustregel der Go-Community lautet: erst ab einem Lese-/Schreib-Verhältnis von mindestens 10:1 zahlt sich RWMutex aus. Bei einer Configuration, die einmal pro Minute neu geladen, aber tausendfach pro Sekunde abgefragt wird, ist die Wahl klar. Bei einem Counter, der genauso oft geschrieben wie gelesen wird, ist sie falsch.
Ein zweites wichtiges Detail betrifft die Starvation: Go's Implementation verhindert, dass Writer dauerhaft hinten anstehen. Sobald ein Writer auf den Lock wartet, dürfen keine neuen Reader mehr in den RLock einsteigen — sie reihen sich hinter dem Writer ein. Das verhindert Writer-Starvation, kann aber im Lastfall zu kurzen Reader-Pausen führen. Reader-Starvation ist in der aktuellen Implementierung kein Thema, weil Reader gemeinsam ablaufen, sobald sie an der Reihe sind. Reentrant ist auch RWMutex nicht: ein RLock innerhalb eines RLock derselben Goroutine kann deadlocken, sobald zwischendurch ein Writer wartet.
RWMutex ist nicht automatisch schneller
Die intuitive Annahme „mehr Reader gleichzeitig = schneller" stimmt nur, wenn der kritische Abschnitt lang genug ist, damit die zusätzliche Parallelität den höheren Overhead pro Operation überwiegt. RWMutex muss intern mehr Zustand verwalten als Mutex — Reader-Counter, Writer-Wartestand, Semaphoren — und jede RLock/RUnlock-Operation ist messbar teurer als ein Mutex-Lock/Unlock.
package syncbench
import (
"sync"
"testing"
)
type MStore struct {
mu sync.Mutex
v int
}
func (s *MStore) Get() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.v
}
type RWStore struct {
mu sync.RWMutex
v int
}
func (s *RWStore) Get() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.v
}
func BenchmarkMutex(b *testing.B) {
s := &MStore{v: 42}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = s.Get()
}
})
}
func BenchmarkRWMutex(b *testing.B) {
s := &RWStore{v: 42}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = s.Get()
}
})
}BenchmarkMutex-8 38912415 30.4 ns/op
BenchmarkRWMutex-8 21043110 56.8 ns/opFür eine triviale Get-Operation auf einem int ist RWMutex hier rund doppelt so teuer pro Aufruf. Erst wenn der kritische Abschnitt nennenswerte Arbeit enthält (eine Map-Suche mit hundert Einträgen, eine Sortierung, eine kleine Berechnung), kippt das Bild und RWMutex gewinnt durch echte Parallelität. Die Konsequenz: vor jeder Umstellung von Mutex auf RWMutex gehört ein testing.B-Benchmark mit realistischem Lese-/Schreib-Verhältnis, ergänzt um ein Mutex-Profile via go test -mutexprofile mu.out und Auswertung mit go tool pprof.
Coarse, Fine, Sharded
Die dritte große Designentscheidung ist die Lock-Granularität. Ein einziger Mutex, der den gesamten Service umschließt, ist trivial korrekt — und skaliert nicht über mehrere CPU-Kerne, weil sich alle Goroutinen am gleichen Punkt anstellen. Pro-Eintrag-Locks (Fine Locking) lösen das Skalierungsproblem, eröffnen aber das nächste: sobald eine Operation zwei Einträge gleichzeitig sperrt, droht ein Deadlock, wenn die Lock-Reihenfolge zwischen Goroutinen inkonsistent ist. Die saubere Regel lautet: globale Sortierung über die Lock-Identitäten festlegen und immer in dieser Reihenfolge sperren, etwa nach Pointer-Adresse oder nach einem stabilen Key.
Der praktikable Mittelweg in vielen Diensten ist Sharding: man teilt den Zustand in eine feste Zahl von Buckets, jeder mit eigenem Mutex, und routet Zugriffe per Hash auf den passenden Shard. Damit wächst die Parallelität proportional zur Shard-Zahl, ohne dass Operationen mehrere Locks halten müssen.
package main
import (
"hash/fnv"
"sync"
)
const shardCount = 32
type shard struct {
mu sync.RWMutex
m map[string]int
}
type ShardedMap struct {
shards [shardCount]*shard
}
func NewShardedMap() *ShardedMap {
sm := &ShardedMap{}
for i := range sm.shards {
sm.shards[i] = &shard{m: make(map[string]int)}
}
return sm
}
func (sm *ShardedMap) shardFor(key string) *shard {
h := fnv.New32a()
_, _ = h.Write([]byte(key))
return sm.shards[h.Sum32()%shardCount]
}Mit 32 Shards laufen bei gleichmäßiger Hash-Verteilung statistisch 32 Schreiber gleichzeitig durch, ohne sich zu blockieren. Wichtig ist die Wahl der Shard-Zahl: Zweierpotenzen erlauben statt % shardCount ein deutlich schnelleres & (shardCount-1). Wer auf eine fertige Lösung zurückgreifen will, findet in sync.Map ein ähnlich gelagertes Konstrukt — allerdings mit anderen Trade-offs, die ein eigenes Kapitel verdienen.
Thread-safer LRU-Cache mit eingebettetem Mutex
Ein LRU-Cache ist das klassische Lehrbuch-Beispiel für „Mutex statt RWMutex": auch ein Get mutiert die interne Reihenfolge, weil der zugegriffene Eintrag ans vordere Ende rückt. Damit gibt es de facto keine reinen Reader, und RWMutex bringt nur Overhead ohne Parallelitätsgewinn.
package cache
import (
"container/list"
"sync"
)
type entry struct {
key string
value any
}
type LRU struct {
mu sync.Mutex // schützt 'order' und 'index'
capacity int
order *list.List // Most-Recently-Used vorn
index map[string]*list.Element // O(1)-Lookup
}
func New(capacity int) *LRU {
return &LRU{
capacity: capacity,
order: list.New(),
index: make(map[string]*list.Element),
}
}
func (c *LRU) Get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.index[key]
if !ok {
return nil, false
}
c.order.MoveToFront(el) // mutiert -> voller Lock nötig
return el.Value.(*entry).value, true
}
func (c *LRU) Set(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.index[key]; ok {
el.Value.(*entry).value = value
c.order.MoveToFront(el)
return
}
el := c.order.PushFront(&entry{key: key, value: value})
c.index[key] = el
if c.order.Len() > c.capacity {
c.evictLocked()
}
}
// evictLocked setzt voraus, dass c.mu bereits gehalten wird.
func (c *LRU) evictLocked() {
tail := c.order.Back()
if tail == nil {
return
}
c.order.Remove(tail)
delete(c.index, tail.Value.(*entry).key)
}Drei Details lohnen den genauen Blick. Der Kommentar // schützt 'order' und 'index' macht die Invariante explizit — beide Felder müssen synchron bleiben, sonst zeigt der Index auf entfernte Listenelemente. Die Methode evictLocked trägt das Suffix nicht zur Dekoration, sondern als verbindlicher Vertrag: sie sperrt nicht selbst und darf nur aus bereits gesperrten Kontexten heraus aufgerufen werden — ein zweiter Lock() würde deadlocken, weil Mutex nicht reentrant ist. Und schließlich sitzt defer c.mu.Unlock() in jeder öffentlichen Methode unmittelbar hinter dem Lock(), sodass auch ein Panic im list.MoveToFront den Lock zuverlässig freigibt.
Sharded Counter-Map gegen globalen Mutex
Eine globale Metrik-Map, die pro HTTP-Request inkrementiert wird, ist der typische Hotspot, an dem ein einzelner Mutex zur Bremse wird. Mit einer Handvoll Shards verschwindet die Contention fast vollständig — bei gleicher API.
package main
import (
"fmt"
"hash/fnv"
"sync"
)
const numShards = 16
type counterShard struct {
mu sync.Mutex
m map[string]int64
}
type ShardedCounters struct {
shards [numShards]*counterShard
}
func NewShardedCounters() *ShardedCounters {
sc := &ShardedCounters{}
for i := range sc.shards {
sc.shards[i] = &counterShard{m: make(map[string]int64)}
}
return sc
}
func (sc *ShardedCounters) shardFor(key string) *counterShard {
h := fnv.New32a()
_, _ = h.Write([]byte(key))
return sc.shards[h.Sum32()%numShards]
}
func (sc *ShardedCounters) Inc(key string) {
s := sc.shardFor(key)
s.mu.Lock()
defer s.mu.Unlock()
s.m[key]++
}
func (sc *ShardedCounters) Snapshot() map[string]int64 {
out := make(map[string]int64)
for _, s := range sc.shards {
s.mu.Lock()
for k, v := range s.m {
out[k] = v
}
s.mu.Unlock()
}
return out
}
func main() {
sc := NewShardedCounters()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
sc.Inc(fmt.Sprintf("route-%d", i%4))
}(i)
}
wg.Wait()
fmt.Println(sc.Snapshot())
}map[route-0:250 route-1:250 route-2:250 route-3:250]Drei Beobachtungen zum Vergleich mit einem globalen Mutex: erstens reduziert sich die Contention im Mittel auf 1/numShards, sodass auf einer Maschine mit 16 Kernen die Inkrementierung nahezu linear mitwächst. Zweitens kostet Snapshot mehr — er sperrt jeden Shard nacheinander und liefert deshalb keine global atomare Sicht, sondern eine „pro-Shard atomare" Sicht. Das ist für Metriken in der Regel kein Problem, für Geld-Buchhaltung aber inakzeptabel. Drittens lebt das gesamte Konstrukt davon, dass die Hash-Funktion gleichmäßig streut: ein boshaft gewählter Key kann sonst alle Zugriffe auf denselben Shard zwingen und den Effekt zunichtemachen.
Erkenntnisse aus Reviews
Häufige Stolperfallen
Copy-Lock-Falle: Mutex nie per Value weitergeben
Ein kopierter sync.Mutex ist ein zweiter Lock, der dieselben Daten meint, aber nichts mehr synchronisiert. Wert-Receiver, append von Strukturen mit Mutex und naive Snapshot-Funktionen sind die häufigsten Quellen. go vet mit dem copylocks-Check fängt die meisten Fälle ab — der Check gehört in jede CI.
sync.Mutex ist nicht reentrant
Anders als in Java oder C# deadlockt ein erneuter Lock() derselben Goroutine auf derselben Mutex. Der idiomatische Ausweg: interne Helfer mit Locked-Suffix, die voraussetzen, dass der Aufrufer den Lock bereits hält, und selbst nicht sperren.
defer mu.Unlock() direkt nach Lock
Wer Unlock ans Ende der Funktion legt, vergisst es bei jeder neuen Return-Stelle. defer mu.Unlock() unmittelbar nach mu.Lock() zu setzen, ist die einzige Variante, die auch bei Panics, frühen Returns und zukünftigen Refactorings sicher bleibt.
RWMutex nicht reflexartig wählen
Erst ab einem Lese-/Schreib-Verhältnis von rund 10:1 und nennenswerter Arbeit im kritischen Abschnitt zahlt sich sync.RWMutex aus. Für kurze Ops auf einem int ist Mutex schneller. Pflicht: Benchmark mit testing.B und Mutex-Profile.
TryLock ist kein Architektur-Werkzeug
Die Standard-Doku warnt explizit: Korrekte TryLock-Anwendungen existieren, sind aber selten. Wer TryLock zur Steuerung von Geschäftslogik einsetzt, bildet meist einen Token-/Channel-Mechanismus nach — den dann besser direkt mit Channels und select ausdrücken.
Lock-Reihenfolge global definieren
Fine-grained Locking braucht eine stabile, globale Sortierung der Lock-Identitäten — etwa nach Pointer-Adresse oder nach einem fachlichen Key. Wer das nicht festlegt, holt sich beim ersten Update mit zwei beteiligten Entitäten einen Deadlock.
atomic.Bool statt Mutex für einzelne Flags
Ein boolesches „Ist der Service gestartet?"-Flag braucht keinen Mutex. atomic.Bool mit Load/Store/CompareAndSwap ist kürzer, schneller und drückt die Absicht klarer aus. Mutex lohnt erst, wenn mehrere zusammengehörige Felder konsistent bleiben müssen.
Receiver-Pointer-Pflicht bei Mutex-Struct
Sobald ein Struct einen sync.Mutex enthält, müssen alle Methoden Pointer-Receiver verwenden — auch reine Reader. Ein Wert-Receiver erzeugt eine Kopie des Mutex und damit einen unabhängigen Lock; der Compiler bleibt stumm, go vet warnt.
Weiterführende Ressourcen
Externe Quellen
sync.Mutex— Go Documentationsync.RWMutex— Go Documentation- The Go Memory Model
go vet—copylocksCheck- Effective Go: Share by communicating