Zwei Primitive, zwei klare Aufgaben

sync.WaitGroup ist der Goroutine-Counter: man zählt hoch, bevor eine Goroutine startet, jede Goroutine zählt am Ende um eins runter, und ein zentrales Wait() blockt so lange, bis der Counter wieder auf null steht. Das ist die kanonische Antwort auf die Frage „wann sind alle Worker fertig?" — präziser als Sleeps und einfacher als Channel-Choreographie für reines Fan-out/Join.

sync.Once löst ein verwandtes, aber anderes Problem: Eine Aktion soll exakt einmal laufen, egal wie viele Goroutines gleichzeitig danach fragen. Klassischer Anwendungsfall ist Lazy-Initialisierung — Connection-Pool, Konfiguration, kompiliertes Regex. Alle Aufrufer warten, bis die Initialisierung durch ist, danach kostet Once.Do praktisch nichts mehr (eine atomare Load-Operation).

Seit Go 1.21 ergänzt das sync-Paket diese Grundlagen um OnceFunc, OnceValue und OnceValues — Hilfsfunktionen, die den klassischen Singleton-Boilerplate vollständig eliminieren. Wer ältere Codebases liest, sieht oft noch var once sync.Once; var instance *T — neuer Code sollte direkt zu OnceValue greifen.

Beide Typen sind im offiziellen Paket dokumentiert: pkg.go.dev/sync. Wer Concurrency in Go ernst nimmt, kommt an beiden nicht vorbei.

Mechanik: Add, Done, Wait

Eine WaitGroup ist intern ein Zähler. Drei Methoden steuern ihn: Add(delta int) erhöht den Zähler um delta (negativ erlaubt, aber unüblich außerhalb von Done); Done() ist syntaktischer Zucker für Add(-1); Wait() blockiert den aufrufenden Goroutine, bis der Zähler null erreicht.

Der Zero-Value ist sofort einsatzbereit — kein New, kein Konstruktor. Wichtig ist die Invariante: Der Counter darf nie negativ werden. Ein Done() ohne passendes vorheriges Add() führt zu einer Panic mit der Meldung sync: negative WaitGroup counter. Das ist Absicht: Negative Counter sind immer Bugs, und Go macht sie laut statt sie zu verstecken.

Go waitgroup_basics.go
var wg sync.WaitGroup

for _, url := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        fetch(u)
    }(url)
}

wg.Wait() // blockt bis alle fetch() returnt haben

Beachte das defer wg.Done() als allererste Zeile der Goroutine: So wird Done() auch dann aufgerufen, wenn fetch panickt — sonst würde die Panic die Goroutine beenden, ohne den Counter zu dekrementieren, und Wait() würde für immer blocken. Diese Konvention ist nicht verhandelbar; sie macht den Code robust gegen jede Form von vorzeitigem Goroutine-Ende.

Add() gehört VOR go, nicht hinein

Ein klassischer Anfängerfehler: Add(1) als erste Zeile innerhalb der Goroutine. Das sieht aus, als gehöre es logisch zum Worker — ist aber ein Race, weil der Scheduler den Wait()-Aufruf erreichen kann, bevor auch nur eine der gestarteten Goroutines Add(1) ausgeführt hat. Der Counter ist null, Wait() returnt sofort — und das Programm beendet sich, bevor irgendein Fetch fertig ist.

Go waitgroup_falsch_richtig.go
// FALSCH — Race zwischen Add und Wait
var wg sync.WaitGroup
for _, url := range urls {
    go func(u string) {
        wg.Add(1)         // zu spät!
        defer wg.Done()
        fetch(u)
    }(url)
}
wg.Wait() // kann sofort returnen

// RICHTIG — Add() vor dem go
var wg2 sync.WaitGroup
for _, url := range urls {
    wg2.Add(1)            // synchron im Caller, vor dem Start
    go func(u string) {
        defer wg2.Done()
        fetch(u)
    }(url)
}
wg2.Wait()

Die Faustregel lautet: Add() läuft im gleichen Goroutine-Kontext wie Wait(), niemals in der Worker-Goroutine. Wer das einmal verinnerlicht hat, vermeidet eine ganze Klasse subtiler Bugs, die im Test-Setup oft nicht auffallen, weil dort nur ein oder zwei Worker laufen — der Race tritt erst unter Last sichtbar zutage.

WaitGroup darf nicht kopiert werden

sync.WaitGroup enthält intern atomare Counter und einen Semaphor-Zustand. Eine Kopie dieser Felder wäre ein eigener WaitGroup-Wert mit eigenem Zähler — die Synchronisation wäre futsch. Genau wie sync.Mutex muss WaitGroup deshalb per Pointer weitergegeben werden.

Go waitgroup_pointer.go
// FALSCH — Kopie der WaitGroup
func workerBad(wg sync.WaitGroup, id int) { // by value
    defer wg.Done()
    work(id)
}

// RICHTIG — Pointer-Übergabe
func workerGood(wg *sync.WaitGroup, id int) {
    defer wg.Done()
    work(id)
}

func run() {
    var wg sync.WaitGroup
    wg.Add(3)
    for i := 0; i < 3; i++ {
        go workerGood(&amp;wg, i)
    }
    wg.Wait()
}

Der gute Hinweis: go vet hat dafür den Check copylocks, der diesen Bug statisch findet. In jeder ernsthaften Pipeline sollte go vet ./... Pflicht sein — er kostet Millisekunden und fängt genau diese Klasse von Fehlern, bevor sie produktiv werden. Die Diagnose lautet beispielsweise func passes lock by value: sync.WaitGroup contains sync.noCopy.

Der neue Helper: WaitGroup.Go

Go 1.25 hat die ergonomische Lücke geschlossen, die viele Teams jahrelang mit eigenen Helper-Funktionen gefüllt haben. Die neue Methode WaitGroup.Go(f func()) kombiniert Add(1) + go + defer Done() in einem Aufruf — drei Vorteile in einer Zeile: kein vergessenes Add, kein vergessenes Done, und der häufigste Bug (Add in der falschen Goroutine) ist konstruktiv unmöglich.

Go waitgroup_go_helper.go
var wg sync.WaitGroup

for _, url := range urls {
    u := url
    wg.Go(func() {
        fetch(u) // Add/Done werden automatisch erledigt
    })
}

wg.Wait()

Die Empfehlung für neue Projekte mit Go 1.25 oder neuer: konsequent Go statt der manuellen Drei-Schritt-Choreographie. Details und Beweggründe stehen in den Go 1.25 Release Notes. Solange du eine ältere Go-Version verwendest, bleibt der manuelle Pfad — und solange dein Team Add/Done-Disziplin diszipliniert einhält, ist auch der völlig in Ordnung.

sync.Once — exakt einmal, garantiert

sync.Once hat eine einzige Methode: Do(f func()). Die Garantie ist erstaunlich stark: f wird höchstens einmal über die Lebenszeit des Once-Wertes ausgeführt — selbst wenn Do von vielen Goroutines gleichzeitig aufgerufen wird. Aufrufer, die Do betreten, während f gerade läuft, blocken, bis f fertig ist. Nach Abschluss kostet jeder weitere Do-Aufruf nur eine atomare Load-Operation — keine Sperre, keine Allokation. Es gibt eine Happens-Before-Garantie: Alles, was f an Speicher schreibt, ist für alle nachfolgenden Do-Aufrufer sichtbar.

Go once_classic.go
var (
    once     sync.Once
    instance *Service
)

func GetService() *Service {
    once.Do(func() {
        instance = newService() // läuft genau einmal
    })
    return instance
}

Hundert gleichzeitige Aufrufer von GetService() führen newService() nicht hundertmal aus, sondern genau einmal — die anderen 99 warten und sehen danach denselben Pointer. Das ist exakt das Lazy-Init-Pattern, das man in jeder größeren Codebase findet.

Warum nicht einfach if instance == nil?

Die naive Variante sieht harmlos aus und ist trotzdem falsch. Zwei Goroutines können den nil-Check gleichzeitig passieren, beide rufen newService() auf, beide schreiben in instance. Im günstigsten Fall verlierst du nur eine doppelte Initialisierung; im schlechten Fall sieht ein Reader einen teilweise konstruierten Service-Pointer, weil der Compiler die Felder erst nach der Pointer-Zuweisung vollständig setzen darf.

Go once_anti_pattern.go
// FALSCH — Race ohne Synchronisation
var instance *Service

func GetService() *Service {
    if instance == nil {
        instance = newService() // Race: zwei Goroutines sehen nil
    }
    return instance
}

Ohne explizite Synchronisation gibt es keine Happens-Before-Beziehung, und damit auch keine garantierte Sichtbarkeit. In Java wäre die Reflexlösung Double-Checked-Locking mit volatile — in Go braucht es das nicht. sync.Once erledigt alles korrekt: die Atomizität, die Sichtbarkeit, das blockierende Warten. Wer in Go-Code Double-Checked-Locking sieht, sieht entweder Java-Muskelgedächtnis oder ein Missverständnis des Memory Models.

OnceFunc, OnceValue, OnceValues

Seit Go 1.21 gibt es drei Hilfsfunktionen, die das Singleton-Boilerplate vollständig eliminieren. Sie kapseln sync.Once und liefern direkt eine aufrufbare Funktion zurück. sync.OnceFunc(f func()) func() gibt eine Funktion zurück, die f höchstens einmal ausführt; sync.OnceValue[T](f func() T) func() T ist das Pendant für eine Rückgabe; sync.OnceValues[T1, T2](f func() (T1, T2)) func() (T1, T2) ist die idiomatische Variante für (Value, error).

Go once_helpers.go
// OnceFunc — Aktion ohne Rückgabewert
var initLogger = sync.OnceFunc(func() {
    log.SetFlags(log.LstdFlags | log.LUTC)
    log.SetPrefix("[svc] ")
})

func handleRequest() {
    initLogger() // erste Call initialisiert, alle folgenden sind No-Op
    log.Println("request received")
}

// OnceValue — der moderne Singleton
var getConfig = sync.OnceValue(func() *Config {
    return loadConfigFromDisk()
})

func handler() {
    cfg := getConfig() // erster Aufruf lädt, alle anderen bekommen denselben Pointer
    _ = cfg
}

// OnceValues — Wert plus Fehler
var loadCert = sync.OnceValues(func() (*tls.Certificate, error) {
    return tls.LoadX509KeyPair("server.crt", "server.key")
})

func startTLS() error {
    cert, err := loadCert()
    if err != nil {
        return fmt.Errorf("cert: %w", err)
    }
    _ = cert
    return nil
}

Drei Punkte zur Erklärung: Erstens verschwindet die globale Variable für den Wert — OnceValue hält den Cache intern in einer Closure. Zweitens ist die Signatur ehrlich: man sieht der Variable an, dass sie eine Funktion ist, nicht ein Wert. Drittens ist die Initialisierungslogik direkt am Ort des Konsums — kein verstreutes init(), kein Order-of-Initialization-Drama. Für neuen Code: keine Ausreden mehr für manuelles sync.Once mit globalem Pointer.

Parallele HTTP-Fetcher mit Ergebnis-Sammlung

Ein realistisches Muster: Eine Liste von URLs parallel abfragen, alle Antworten in einem Slice sammeln, am Ende auswerten. Die WaitGroup koordiniert das Ende, ein Mutex schützt das gemeinsame Slice.

Go parallel_fetch.go
package main

import (
    "fmt"
    "io"
    "net/http"
    "sync"
    "time"
)

type Result struct {
    URL    string
    Status int
    Bytes  int
    Err    error
}

func fetchAll(urls []string) []Result {
    var (
        wg      sync.WaitGroup
        mu      sync.Mutex
        results = make([]Result, 0, len(urls))
        client  = &amp;http.Client{Timeout: 5 * time.Second}
    )

    for _, u := range urls {
        u := u // Capture pro Iteration (vor Go 1.22 zwingend)
        wg.Add(1)
        go func() {
            defer wg.Done()

            r := Result{URL: u}
            resp, err := client.Get(u)
            if err != nil {
                r.Err = err
            } else {
                defer resp.Body.Close()
                r.Status = resp.StatusCode
                b, _ := io.ReadAll(resp.Body)
                r.Bytes = len(b)
            }

            mu.Lock()
            results = append(results, r)
            mu.Unlock()
        }()
    }

    wg.Wait()
    return results
}

func main() {
    urls := []string{
        "https://example.com",
        "https://pkg.go.dev",
        "https://go.dev",
    }
    for _, r := range fetchAll(urls) {
        fmt.Printf("%-25s %d %d %v\n", r.URL, r.Status, r.Bytes, r.Err)
    }
}

Worauf zu achten ist: wg.Add(1) steht im Caller vor dem go func() — nicht in der Goroutine. defer wg.Done() ist die erste Zeile im Worker. Der Mutex schützt den append-Aufruf, weil mehrere Goroutines parallel ins Slice schreiben wollen. Alternativ könnte man jeder Goroutine einen eigenen Slot per Index zuweisen (results[i] = r) und käme ganz ohne Mutex aus — beide Varianten sind legitim, die Mutex-Variante ist robuster gegen spätere Filter-Logik.

Lazy-Init eines DB-Connection-Pools

Connection-Pools sind teuer aufzubauen (TCP-Handshakes, TLS, initiale Probes). Man möchte den Pool einmal erzeugen, beim ersten echten Bedarf, und ihn dann teamweit teilen. OnceValue ist hier der eleganteste Weg.

Go db_pool_oncevalue.go
package db

import (
    "database/sql"
    "log"
    "os"
    "sync"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"
)

var getPool = sync.OnceValue(buildPool)

func buildPool() *sql.DB {
    dsn := os.Getenv("DATABASE_URL")
    pool, err := sql.Open("pgx", dsn)
    if err != nil {
        log.Fatalf("db open: %v", err)
    }
    pool.SetMaxOpenConns(25)
    pool.SetMaxIdleConns(10)
    pool.SetConnMaxLifetime(30 * time.Minute)

    // Probe vor erstem Request — Fail-Fast bei kaputter Konfiguration.
    if err := pool.Ping(); err != nil {
        log.Fatalf("db ping: %v", err)
    }
    return pool
}

// Pool liefert den prozessweiten Connection-Pool.
// Erster Aufruf initialisiert; alle weiteren Aufrufer bekommen denselben Pool.
func Pool() *sql.DB {
    return getPool()
}

Was diese Variante so angenehm macht: buildPool ist eine ganz normale Funktion, die man testen kann (sofern man die Env-Variable kontrolliert). Die Singleton-Eigenschaft steckt im Modul-Level-getPool, nicht in der Logik. Hundert Goroutines, die beim Start des Servers gleichzeitig db.Pool() aufrufen, lösen genau einen buildPool-Aufruf aus — die anderen warten, bekommen anschließend denselben *sql.DB zurück und ab da kostet jeder Aufruf einen atomaren Load.

Ein Hinweis zum Fail-Fast: log.Fatalf in buildPool ist eine bewusste Entscheidung. Eine kaputte DB-Konfiguration beim Start ist kein Fehler, von dem sich der Prozess sinnvoll erholen kann — also lieber laut sterben als stumm halbfunktional weiterlaufen. Wer Recovery braucht, nimmt OnceValues und gibt den Fehler an den Caller zurück.

Häufige Stolperfallen

Add() VOR go aufrufen

wg.Add(1) muss synchron im Caller stehen, bevor go func() startet — sonst kann wg.Wait() returnen, bevor die Goroutine ihr eigenes Add erreicht hat.

WaitGroup per Pointer (kein Copy)

sync.WaitGroup hat interne atomare Felder; eine Kopie ist ein eigener Counter. Immer *sync.WaitGroup übergeben — go vet mit copylocks findet Verstöße automatisch.

WaitGroup nicht reused, bevor Wait() returnt

Solange ein Wait() aktiv ist, darf der Counter nicht für eine neue Phase hochgezählt werden — sonst Race und unvorhersehbares Verhalten. Erst nach Wait() ist die WaitGroup wieder frei für die nächste Runde.

defer wg.Done() als erste Zeile

defer wg.Done() direkt an den Goroutine-Anfang stellen. So wird der Counter auch bei Panic dekrementiert — sonst hängt Wait() für immer.

Once.Do blockiert ALLE Caller, bis f fertig ist

Endlosschleifen oder dauerblockierende Operationen in der Do-Funktion blockieren das ganze Programm an dieser Stelle. f soll schnell und terminierend sein; lange Aufgaben gehören woanders hin.

Panic in f markiert Once als done

Wenn f panickt, gilt Once trotzdem als „erledigt" — nachfolgende Do-Calls führen f nicht mehr aus. Wer Retry-Verhalten will, muss die Panic in f selbst fangen oder ein anderes Muster wählen.

OnceFunc/OnceValue (Go 1.21+) statt manueller Singleton

Neuer Code sollte sync.OnceValue für Lazy-Singletons nutzen — keine globale Instanz-Variable, keine sync.Once-Boilerplate, klare Signatur am Aufrufpunkt.

WaitGroup.Go in Go 1.25

Ab Go 1.25 kombiniert wg.Go(f) Add+go+defer Done in einer Zeile — der häufigste WaitGroup-Bug (Add an der falschen Stelle) wird damit konstruktiv unmöglich.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Sync & Context

Zur Übersicht