Eine Race Condition ist jede Situation, in der das Ergebnis eines Programms davon abhängt, in welcher Reihenfolge nebenläufige Operationen ausgeführt werden. Ein Data Race ist die konkrete, technische Unterklasse davon: zwei oder mehr Goroutines greifen gleichzeitig auf dieselbe Speicherzelle zu, mindestens ein Zugriff ist ein Schreibzugriff, und es gibt keine Synchronisation zwischen den Zugriffen. Das Go Memory Model erklärt Data Races zu undefiniertem Verhalten — die Sprache macht ab diesem Punkt keine Aussagen mehr darüber, was passiert. Das ist keine theoretische Klausel: ein zerrissener 64-Bit-Wert, ein halb geschriebener Pointer oder eine Map, deren interne Buckets korrupt sind, sind reale Konsequenzen.

Was Data Races so gefährlich macht, ist nicht ihre Häufigkeit, sondern ihre Unsichtbarkeit. Auf einer ruhigen Entwicklermaschine mit wenig Last laufen sie Hunderte Male durch, ohne aufzufallen. In der Testsuite mit einem Goroutine-Paar gehen sie unter. In Produktion, unter Last, auf einer anderen CPU-Architektur, mit anderem Scheduling — dann erscheint plötzlich der inkonsistente State, der Crash im Map-Lookup, die NaN-Metrik. Dieser Artikel zeigt, wie du Data Races mit go test -race zuverlässig findest, wie du das Happens-Before-Konzept des Memory Models liest, und welches der drei Werkzeuge — sync.Mutex, sync/atomic, Channels — für welchen Fall das richtige ist.

Zwei Goroutines, ein Zähler

Bevor wir über Theorie reden, schauen wir uns den prototypischen Data Race an. Zwei Goroutines erhöhen denselben int-Zähler jeweils tausend Mal. Intuitiv erwartet man am Ende den Wert 2000. Tatsächlich kann fast jeder Wert zwischen 1 und 2000 herauskommen — und welchen man sieht, hängt von Scheduler-Laune, CPU-Cache-Zustand und Mondphase ab.

Go race_counter.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var counter int
	var wg sync.WaitGroup
	wg.Add(2)

	for i := 0; i < 2; i++ {
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				counter++ // Data Race!
			}
		}()
	}

	wg.Wait()
	fmt.Println("counter =", counter)
}
Output
$ go run race_counter.go
counter = 1374
$ go run race_counter.go
counter = 1892
$ go run race_counter.go
counter = 2000

Der Schlüssel zum Verständnis ist, dass counter++ keine atomare Operation ist. Auf Maschinenebene zerfällt der Ausdruck in drei Schritte: Load (lies den aktuellen Wert aus dem Speicher in ein Register), Modify (addiere 1) und Store (schreibe das Register zurück). Wenn zwei Goroutines diese Sequenz verschränkt ausführen — Goroutine A liest 42, Goroutine B liest 42, beide addieren 1, beide schreiben 43 — geht eine der Inkrementierungen verloren. Auf modernen CPUs kommt dazu, dass Schreibvorgänge nicht sofort für alle Cores sichtbar werden: ohne Synchronisation kann Goroutine B den Wert von A nie sehen, selbst wenn A längst fertig ist.

Happens-Before — was ist eine Synchronisation?

Das Go Memory Model definiert eine Relation namens Happens-Before zwischen Speicheroperationen. Wenn Operation A Happens-Before Operation B, dann garantiert die Sprache, dass B alle Effekte von A sieht. Innerhalb einer einzelnen Goroutine ist das trivial: Programmreihenfolge ist Happens-Before. Über Goroutine-Grenzen hinweg gilt das nur, wenn eine explizite Synchronisationsoperation dazwischenliegt. Ohne diese Synchronisation ist es legitim, dass der Compiler oder die CPU Schreibvorgänge umordnet, im Cache hält oder gleich ganz wegoptimiert.

Was zählt als Synchronisation? Im Wesentlichen vier Mechanismen: ein Send auf einen Channel Happens-Before dem korrespondierenden Receive; ein Unlock auf einem Mutex Happens-Before dem nächsten Lock; ein atomarer Store Happens-Before dem korrespondierenden atomaren Load; und sync.Once.Do synchronisiert seinen Body mit allen späteren Aufrufen. Alles andere — gewöhnliche Variablenzuweisungen, time.Sleep, runtime.Gosched — etabliert keine Happens-Before-Beziehung. Das ist der häufigste Anfängerfehler: zu glauben, ein time.Sleep(time.Second) reiche aus, damit eine andere Goroutine „den Wert schon sieht". Tut es nicht. Die autoritative Quelle ist go.dev/ref/mem — eine kurze, aber dichte Spezifikation, die jeder Go-Entwickler einmal durchgelesen haben sollte.

go test -race und Verwandte

Go liefert seit Version 1.1 einen eingebauten Race Detector mit, der auf Googles ThreadSanitizer aufbaut. Er ist über das -race-Flag in allen drei Build-Modi verfügbar und instrumentiert den erzeugten Binärcode so, dass jeder Speicherzugriff zur Laufzeit protokolliert und mit anderen Zugriffen verglichen wird. Findet er zwei Zugriffe auf dieselbe Adresse, die nicht durch eine Happens-Before-Beziehung getrennt sind und von denen mindestens einer schreibt, meldet er einen Data Race.

Bash race-modi.sh
# Einmaliger Lauf eines Programms
go run -race ./cmd/server

# Die gesamte Testsuite mit Race-Erkennung
go test -race ./...

# Ein Binary mit Race-Instrumentation bauen (z.B. für Staging)
go build -race -o server-race ./cmd/server

Die Instrumentation hat Kosten: instrumentierte Programme laufen typischerweise 5- bis 10-mal langsamer und verbrauchen 5- bis 10-mal mehr Speicher. Aus diesem Grund schaltet man -race nicht in Produktions-Builds für reale Last, sondern in CI-Pipelines, Integrationstests und gelegentlich in Staging-Umgebungen mit reduzierter Last. Eine bewährte Regel: jeder CI-Lauf führt go test -race ./... aus. Die Kosten sind im CI vernachlässigbar, der Gewinn — frühe Erkennung schleichender Bugs — ist enorm. Wer das nicht tut, wird Data Races zwangsläufig in Produktion entdecken.

Was der Race Detector dir sagt

Wenn der Detector zuschlägt, gibt er einen mehrteiligen Report aus: oben die Warnung selbst, danach Stack-Trace und Goroutine-ID des lesenden Zugriffs, dann dasselbe für den schreibenden Zugriff, und schließlich Informationen, wo die beteiligten Goroutines gestartet wurden. Diese Information ist Gold wert, weil sie nicht nur sagt „irgendwo gibt es einen Race", sondern exakt zwei Code-Stellen identifiziert.

Bash race-output.txt
$ go run -race race_counter.go
==================
WARNING: DATA RACE
Read at 0x00c0000180a0 by goroutine 8:
  main.main.func1()
      /home/dev/race_counter.go:17 +0x4e

Previous write at 0x00c0000180a0 by goroutine 7:
  main.main.func1()
      /home/dev/race_counter.go:17 +0x64

Goroutine 8 (running) created at:
  main.main()
      /home/dev/race_counter.go:14 +0x7e

Goroutine 7 (finished) created at:
  main.main()
      /home/dev/race_counter.go:14 +0x7e
==================
counter = 2000
Found 1 data race(s)
exit status 66

Lies den Report von oben nach unten: Adresse 0x...80a0 ist unser counter. Goroutine 8 liest an Zeile 17 (das ist das counter++, denn ++ ist ein Read-Modify-Write), Goroutine 7 hat zuvor an derselben Zeile geschrieben. Beide wurden in Zeile 14 gestartet — der go func()-Aufruf. Der Exit-Code 66 ist der Standardwert, mit dem ein instrumentiertes Programm beendet wird, wenn Races gefunden wurden; in CI bedeutet das automatisch ein fehlgeschlagener Build.

Den kritischen Abschnitt schützen

Die direkteste Lösung ist ein Mutex — eine Sperre, die garantiert, dass immer nur eine Goroutine zur Zeit den geschützten Codeabschnitt betritt. In Go heißt der Typ sync.Mutex, die Methoden sind Lock und Unlock. Das idiomatische Muster nutzt defer mu.Unlock() direkt nach mu.Lock(), damit das Entsperren auch dann sicher geschieht, wenn die Funktion einen Fehler zurückgibt oder paniciert.

Go counter_mutex.go
package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

func main() {
	var c Counter
	var wg sync.WaitGroup
	for i := 0; i < 2; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				c.Inc()
			}
		}()
	}
	wg.Wait()
	fmt.Println("counter =", c.Value())
}
Output
$ go run -race counter_mutex.go
counter = 2000

Die Wahl der Granularität ist nicht trivial. Ein einziger globaler Mutex um den gesamten Request-Handler ist einfach zu programmieren, führt aber zu Lock-Contention: Goroutines warten in Reihe, der Server skaliert nicht über die Kerne. Ein eigener Mutex pro Cache-Eintrag ist hochparallel, aber speicherintensiv und fehleranfällig (Lock-Reihenfolge bei mehreren Locks pro Operation). Eine gute Heuristik: schütze die kleinste Datenstruktur, die als Einheit Sinn ergibt — typischerweise ein Struct mit seinen Feldern. Wenn deutlich mehr gelesen als geschrieben wird, bietet sync.RWMutex eine Variante mit RLock/RUnlock: viele Reader dürfen parallel laufen, ein Writer hat exklusiven Zugriff. Der Overhead pro Operation ist höher als bei einem normalen Mutex, lohnt sich aber ab etwa zehn Lesern pro Schreiber.

Lock-freie Primitive

Für einzelne primitive Werte — Zähler, Flags, Pointer-Swaps — gibt es eine schnellere Alternative: das Paket sync/atomic stellt Operationen bereit, die von der CPU als unteilbare Instruktion ausgeführt werden. Seit Go 1.19 gibt es typisierte Wrapper wie atomic.Int64, atomic.Bool und atomic.Pointer[T], die das alte Funktions-Interface (atomic.AddInt64(&amp;x, 1)) angenehm objektorientiert verpacken.

Go counter_atomic.go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var counter atomic.Int64
	var wg sync.WaitGroup

	for i := 0; i < 2; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				counter.Add(1)
			}
		}()
	}
	wg.Wait()
	fmt.Println("counter =", counter.Load())
}
Output
$ go run -race counter_atomic.go
counter = 2000

Atomare Operationen sind typischerweise eine Größenordnung schneller als ein Mutex-Paar, weil sie ohne Kernel-Aufruf und ohne Scheduler-Beteiligung auskommen. Sie sind die richtige Wahl für hot paths: Request-Zähler, Statistiken, einfache Flags, Pointer-Swaps für Copy-on-Write-Strukturen. Ihre Limitation ist fundamental: sie schützen genau eine Speicherzelle. Sobald du zwei Felder konsistent halten musst — etwa Zähler und Zeitstempel im selben Update — brauchst du einen Mutex oder einen komplexen Lock-freien Algorithmus. Faustregel: ein Wert, ein Hot Path, primitiver Typ — atomic. Mehrere Werte, zusammengesetzte Invariante — Mutex.

Don't communicate by sharing memory

Das berühmte Go-Mantra dreht die Perspektive um: statt einen geteilten Wert mit einem Lock zu schützen, übergibt man Besitz des Werts per Channel an genau eine Goroutine, die ihn exklusiv besitzt. Andere Goroutines sprechen mit ihr per Message. Damit verschwindet das Race-Problem strukturell — wenn nur eine Goroutine auf den State zugreift, gibt es keinen Race.

Go counter_channel.go
package main

import "fmt"

type counterOp struct {
	delta int
	reply chan int // optional: aktueller Wert zurück
}

func runCounter(ops <-chan counterOp, done chan<- int) {
	n := 0
	for op := range ops {
		n += op.delta
		if op.reply != nil {
			op.reply <- n
		}
	}
	done <- n
}

func main() {
	ops := make(chan counterOp)
	done := make(chan int)
	go runCounter(ops, done)

	for i := 0; i < 2000; i++ {
		ops <- counterOp{delta: 1}
	}
	close(ops)
	fmt.Println("counter =", <-done)
}
Output
$ go run -race counter_channel.go
counter = 2000

Channels glänzen, wenn der State eine natürliche Pipeline durchläuft (Stage 1 produziert, Stage 2 transformiert, Stage 3 schreibt), wenn klar ein Producer/Consumer-Verhältnis vorliegt, oder wenn ein Wert von einer Goroutine an die nächste übergeben wird (Ownership-Transfer). Für reine Zähler im hot path sind sie meist die langsamste der drei Lösungen — Channel-Operationen sind teurer als Mutex- und atomare Operationen. Wähle Channels, wenn sie das Design klarer machen, nicht aus Pflichtgefühl gegenüber dem Mantra.

Häufige Race-Patterns

Bestimmte Bug-Muster begegnen einem in jeder größeren Go-Codebase. Wer sie erkennt, spart sich Stunden Debugging. Ich zeige hier die vier häufigsten — alle vom Race Detector zuverlässig gefunden, alle ohne ihn ein Albtraum.

Go race_patterns.go
// 1) Gemeinsame Map ohne Lock
var cache = map[string]string{}
go func() { cache["a"] = "x" }()
go func() { _ = cache["a"] }() // Race + möglicher Crash

// 2) Slice-Append aus mehreren Goroutines
var xs []int
for i := 0; i < 10; i++ {
    go func(v int) { xs = append(xs, v) }(i) // Race auf Header
}

// 3) Closure-Capture in Loop (Pre-Go-1.22)
for i := 0; i < 3; i++ {
    go func() { fmt.Println(i) }() // alle drucken evtl. 3
}
// Fix: i als Parameter übergeben oder ab Go 1.22 ohne Aufwand

// 4) Singleton ohne sync.Once
var instance *Service
func Get() *Service {
    if instance == nil { // Race: zwei Goroutines sehen nil
        instance = &amp;Service{}
    }
    return instance
}
// Fix: sync.Once.Do schützt die Initialisierung

Besonders heimtückisch ist Muster 1: Gos eingebaute map ist explizit nicht concurrency-safe, und konkurrierende Zugriffe können nicht nur falsche Werte liefern, sondern die Runtime mit einer Panic („concurrent map read and map write") abbrechen — oder, wenn der Detector nicht da ist, die internen Buckets zerstören und später unerklärliche Crashes verursachen. Muster 3 wurde in Go 1.22 entschärft: Schleifenvariablen haben jetzt per Iteration einen frischen Scope. In älteren Codebases lebt der Bug aber weiter. Muster 4 ist die Klassiker-Falle aus Java-Singleton-Patterns; die saubere Go-Lösung heißt sync.Once.

Was der Race Detector NICHT kann

Der Race Detector ist ein dynamisches Werkzeug: er sieht nur, was während der Laufzeit tatsächlich passiert. Ein Race, dessen Code-Pfad in deinem Test nie betreten wird, bleibt unentdeckt. Ein Race, der nur bei einer bestimmten Scheduling-Reihenfolge auftritt, kann verborgen bleiben, wenn deine Tests immer dieselbe Reihenfolge erzwingen. Es gibt also keine Aussage „Race Detector grün ⇒ Programm ist race-frei". Es gibt nur „Race Detector grün auf diesen Inputs und diesem Schedule ⇒ in diesem Lauf war alles sauber".

Daraus folgen zwei Konsequenzen. Erstens: Test-Coverage zählt doppelt für Concurrency-Code. Jeder Pfad, der nicht ausgeführt wird, ist ein blinder Fleck des Detectors. Zweitens: wenn du in produktionsnahen Bedingungen Verdacht auf einen Race hast, lohnt es sich, einen Lasttest unter -race laufen zu lassen — auch wenn das Programm dabei deutlich langsamer ist. Statische Analyse-Tools wie go vet finden zusätzlich ein paar Klassen offensichtlicher Fehler (Lock-Wert-Kopien etwa), ersetzen aber den Detector nicht. Defensive Praxis: kombiniere go vet, staticcheck und go test -race in jeder CI-Pipeline.

Web-Counter — drei Varianten im Vergleich

Schauen wir uns die drei Lösungen an einem realistischen Szenario an: ein HTTP-Server zählt alle eingegangenen Requests und gibt den aktuellen Stand auf /stats aus. Die naive Variante hat einen astreinen Data Race. Die Mutex- und Atomic-Varianten sind beide korrekt; der Unterschied zeigt sich erst im Benchmark.

Go web_counter.go
package main

import (
	"fmt"
	"net/http"
	"sync"
	"sync/atomic"
)

// Variante A: naiv (RACE!)
var naiveCount int

func naiveHandler(w http.ResponseWriter, r *http.Request) {
	naiveCount++ // Data Race bei mehreren Connections
	fmt.Fprintln(w, "ok")
}

// Variante B: sync.Mutex
type mutexCounter struct {
	mu sync.Mutex
	n  int64
}

func (c *mutexCounter) Inc() {
	c.mu.Lock()
	c.n++
	c.mu.Unlock()
}

func (c *mutexCounter) Value() int64 {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

// Variante C: sync/atomic
type atomicCounter struct{ n atomic.Int64 }

func (c *atomicCounter) Inc()         { c.n.Add(1) }
func (c *atomicCounter) Value() int64 { return c.n.Load() }

func main() {
	mc := &amp;mutexCounter{}
	ac := &amp;atomicCounter{}

	http.HandleFunc("/inc-mu", func(w http.ResponseWriter, r *http.Request) {
		mc.Inc()
		fmt.Fprintln(w, "ok")
	})
	http.HandleFunc("/inc-at", func(w http.ResponseWriter, r *http.Request) {
		ac.Inc()
		fmt.Fprintln(w, "ok")
	})
	http.HandleFunc("/stats", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "mu=%d atomic=%d\n", mc.Value(), ac.Value())
	})

	_ = http.ListenAndServe(":8080", nil)
}

Ein einfacher Benchmark mit wrk -t4 -c100 -d10s http://localhost:8080/inc-at zeigt typische Ergebnisse: die Atomic-Variante schafft auf einem Standard-Laptop rund 20-30 Prozent mehr Requests pro Sekunde als die Mutex-Variante, weil sie keinen Kernel-Sprung und keine Goroutine-Park-Operation auslöst, wenn keine Contention besteht. Bei hoher Contention (vielen Cores, vielen Connections) wächst der Vorsprung auf das Zwei- bis Dreifache. Solange du nur einen einzelnen Zähler hast, ist atomic hier eindeutig die richtige Wahl. Sobald du aber zusätzlich Latenz-Histogramme, letzte-Request-Zeitpunkte und Pfad-Statistiken konsistent fortschreiben willst, kippt die Empfehlung zurück zum Mutex — denn dann brauchst du einen kritischen Abschnitt, nicht eine atomare Zelle.

Cache-Map ohne Race

Ein In-Memory-Cache ist der Klassiker, an dem Map-Races sichtbar werden. Drei sinnvolle Bauarten existieren, jede mit eigenem Profil. Ich zeige sie nebeneinander, weil die Wahl stark vom Lese/Schreibverhältnis und der Schlüssel-Dynamik abhängt.

Go cache_variants.go
package cache

import (
	"sync"
)

// A) RWMutex + map — lesefreundlich, einfache Semantik
type RWCache struct {
	mu sync.RWMutex
	m  map[string]string
}

func NewRW() *RWCache { return &amp;RWCache{m: map[string]string{}} }

func (c *RWCache) Get(k string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.m[k]
	return v, ok
}

func (c *RWCache) Set(k, v string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[k] = v
}

// B) sync.Map — für stabile Schlüssel, write-once-read-many
type SyncMapCache struct{ m sync.Map }

func (c *SyncMapCache) Get(k string) (string, bool) {
	v, ok := c.m.Load(k)
	if !ok {
		return "", false
	}
	return v.(string), true
}
func (c *SyncMapCache) Set(k, v string) { c.m.Store(k, v) }

// C) Aktor-Goroutine — serialisiert Zugriffe per Channel
type ActorCache struct {
	ops chan func(map[string]string)
}

func NewActor() *ActorCache {
	c := &amp;ActorCache{ops: make(chan func(map[string]string))}
	go func() {
		m := map[string]string{}
		for op := range c.ops {
			op(m)
		}
	}()
	return c
}

func (c *ActorCache) Get(k string) (string, bool) {
	type result struct {
		v  string
		ok bool
	}
	reply := make(chan result, 1)
	c.ops <- func(m map[string]string) {
		v, ok := m[k]
		reply <- result{v, ok}
	}
	r := <-reply
	return r.v, r.ok
}

func (c *ActorCache) Set(k, v string) {
	c.ops <- func(m map[string]string) { m[k] = v }
}

Die RWMutex-Variante ist der pragmatische Default: einfach zu verstehen, einfach zu erweitern (Eviction, TTL, Statistik), gut für jedes Lese/Schreibverhältnis ab etwa 5:1 zugunsten der Leser. Die sync.Map ist spezialisiert auf zwei Workloads — write-once-read-many oder disjunkte Schreibmengen pro Goroutine. Außerhalb dieser Nischen ist sie meist langsamer als RWMutex+map, und ihre API (Load, Store, LoadOrStore, Range) ist deutlich umständlicher. Die Aktor-Variante mit Goroutine und Channel ist die strukturell sauberste: kein Lock, kein gemeinsamer State, der Cache hat einen klaren Owner. Sie kostet aber pro Operation einen Channel-Send und damit einen Scheduler-Kontakt — in hot paths spürbar. Wähle Aktor, wenn der Cache komplexe Operationen ausführt (z.B. asynchrone Refreshes), Mutex, wenn er klassisch performant sein soll.

Häufige Stolperfallen

counter++ ist nicht atomar

Die Operation zerfällt in Load, Modify, Store — drei separate Maschineninstruktionen. Zwei Goroutines können denselben Wert lesen, beide inkrementieren, beide schreiben dasselbe Ergebnis zurück: ein Update geht verloren. Benutze sync.Mutex oder atomic.Int64.Add, nie nacktes ++.

Race Detector gehört NICHT in Produktion

Instrumentierte Binaries laufen 5-10x langsamer und brauchen 5-10x mehr Speicher. -race ist ein CI- und Staging-Werkzeug. In Produktion-Builds bleibt das Flag weg — finde Races vorher.

append auf geteiltem Slice ist ein Race

append modifiziert Länge und ggf. die Backing-Array-Adresse des Slice-Headers. Mehrere Goroutines, die in dieselbe Slice-Variable appenden, racen auf dem Header. Sammle Werte über einen Channel ein oder schütze den Append-Schritt mit einem Mutex.

map ist nicht concurrency-safe

Die eingebaute Map verbietet konkurrierende Zugriffe explizit; die Runtime kann mit concurrent map read and map write paniken. Schütze sie mit sync.RWMutex oder benutze sync.Map, wenn das Profil passt.

defer mu.Unlock() direkt nach mu.Lock()

Ohne defer riskierst du, dass eine frühe Rückgabe oder ein Panic den Lock nie freigibt — Deadlock. Schreibe das defer unmittelbar nach dem Lock, bevor du an Logik denkst.

Lock-Reihenfolge muss konsistent sein

Wenn Goroutine A erst mu1 dann mu2 nimmt, Goroutine B aber mu2 dann mu1, entsteht ein Deadlock. Definiere eine globale Reihenfolge (z.B. alphabetisch nach Variablenname) und halte sie überall ein.

atomic.Value / atomic.Pointer für Struct-Swaps

Wer einen ganzen Struct lock-frei tauschen will (Configuration-Hot-Reload, Snapshot-Pattern), nimmt nicht mehrere atomic.Int64, sondern atomic.Pointer[T]. Der gesamte Pointer-Swap ist eine einzige Operation, die Konsistenz aller Felder ist garantiert.

sync.Map ist nicht der Allzweck-Map-Ersatz

Sie ist optimiert für zwei Workloads: write-once-read-many und disjunkte Key-Sets pro Goroutine. Für gemischte Lese/Schreiblasten ist RWMutex+map meist schneller — und immer lesbarer. Nicht reflexartig auf sync.Map umstellen.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Goroutines & Channels

Zur Übersicht