sync: WaitGroup, Mutex, Once

Reviewed & published by Brayan K

The sync package gives you the low-level tools to coordinate goroutines: WaitGroup to wait for them to finish, Mutex and RWMutex to protect shared state, and Once to run setup exactly one time.

Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ sync.WaitGroup

A WaitGroup is a counter you wait on. Call Add(1) before launching each goroutine, have each goroutine call Done() when it finishes (put it in a defer), and call Wait() to block until the count returns to zero. Here each goroutine writes its own slice slot, so there's no shared write — and we sort only to print a stable result.

package main

import (
    "fmt"
    "sort"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    results := make([]int, 5) // each goroutine writes its OWN slot -> no race

    for i := 0; i < 5; i++ {
        wg.Add(1)            // count this goroutine BEFORE launching it
        go func(n int) {
            defer wg.Done()  // signal completion (runs even on early return)
            results[n] = n * n
        }(i)                 // pass i in so each goroutine gets its own copy
    }

    wg.Wait()                // block until all five have called Done
    sort.Ints(results)       // slots fill in any order; sort to print stably
    fmt.Println("squares:", results)
}

2️⃣ sync.Mutex and sync.RWMutex

When several goroutines update the same variable, you get a data race — c.counts[key]++ is a read-modify-write that can interleave and lose updates (and a concurrent map write actually panics). A Mutex serialises access: Lock admits one goroutine, Unlock releases it. When reads dominate, RWMutex lets many readers share an RLock while writers still get exclusive Lock.

package main

import (
    "fmt"
    "sync"
)

// SafeCounter wraps a map and a Mutex so many goroutines can update it.
type SafeCounter struct {
    mu     sync.Mutex
    counts map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()         // only one goroutine past this point at a time
    defer c.mu.Unlock() // always release, even if something panics
    c.counts[key]++
}

func (c *SafeCounter) Get(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.counts[key]
}

func main() {
    c := SafeCounter{counts: make(map[string]int)}
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() { defer wg.Done(); c.Inc("hits") }()
    }
    wg.Wait()
    fmt.Println("hits:", c.Get("hits")) // always 1000 — no lost updates

    // RWMutex allows MANY concurrent readers OR one writer. Use it when
    // reads vastly outnumber writes.
    var rw sync.RWMutex
    config := "v1"
    rw.RLock()
    fmt.Println("read under RLock:", config)
    rw.RUnlock()
    rw.Lock()
    config = "v2"
    rw.Unlock()
    fmt.Println("after write:", config)
}

3️⃣ sync.Once and atomic counters

sync.Once guarantees a function runs exactly once across all goroutines — perfect for lazy initialisation of a config, connection pool, or singleton. Every caller passes the same function to once.Do; the first runs it, the rest wait for it to finish and then return.

package main

import (
    "fmt"
    "sync"
)

var (
    once   sync.Once
    config string
)

// initConfig does expensive one-time setup. sync.Once guarantees the
// function runs EXACTLY once, no matter how many goroutines call Do.
func initConfig() {
    fmt.Println("initialising config (this prints once)")
    config = "loaded"
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            once.Do(initConfig) // only the FIRST caller actually runs it
        }()
    }
    wg.Wait()
    fmt.Println("config is:", config)
}

For a single counter or flag you often don't need a lock at all. sync/atomic updates one value as a single indivisible CPU instruction — lock-free and fast. Use it for lone counters; switch back to a Mutex the moment two related fields must change together.

package main

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

func main() {
    const goroutines = 1000

    // Approach A: a Mutex around a plain int.
    var mu sync.Mutex
    muCount := 0

    // Approach B: an atomic counter — a single hardware instruction does
    // the read-modify-write, so NO lock is needed for a simple add.
    var atomicCount atomic.Int64

    var wg sync.WaitGroup
    for i := 0; i < goroutines; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()
            muCount++
            mu.Unlock()

            atomicCount.Add(1)
        }()
    }
    wg.Wait()

    fmt.Println("mutex count: ", muCount)
    fmt.Println("atomic count:", atomicCount.Load())
}

🎯 Your Turn

Wire up the WaitGroup yourself: Add before launching, Done when finishing, Wait at the end. The Mutex around done++ is already wired for you.

package main

import (
    "fmt"
    "sync"
)

func main() {
    // 🎯 YOUR TURN — fill in the blanks marked ___
    var wg sync.WaitGroup
    done := 0
    var mu sync.Mutex

    for i := 0; i < 3; i++ {
        ___                     // 👉 wg.Add(1)
        go func() {
            defer ___           // 👉 wg.Done()
            mu.Lock()
            done++
            mu.Unlock()
        }()
    }

    ___                          // 👉 wg.Wait()
    fmt.Println("workers done:", done)

    // ✅ Expected output:
    //    workers done: 3
}

Common Errors (and the fix)

❌ wg.Add(1) inside the goroutine — Wait() can run before the Add and return too early.

✅ Call Add in the launching goroutine, before go ....

❌ Forgot Done() on one path — Wait() hangs forever because the count never hits zero.

✅ Put defer wg.Done() as the first line of the goroutine.

❌ Locked and never unlocked (returned early before Unlock) — deadlock.

✅ Use defer mu.Unlock() right after mu.Lock().

❌ Copied a Mutex or WaitGroup by value — the copy has separate state and stops protecting anything.

✅ Pass them by pointer (*sync.Mutex) or embed in a struct used by pointer; run go vet.

🧠 Quick Recall

once.Do(f); once.Do(f) // f prints "hi"

hi prints once. After the first Do, the Once is "used up" and later calls skip f entirely.

1000 goroutines do count++ with no lock

The result is unpredictable (often less than 1000) — a data race. go run -race reports it; a Mutex or atomic.Int64 fixes it.

🎯 Mini-Challenge: Thread-Safe Word Counter

Count word occurrences across the slice using many goroutines. Protect a shared map[string]int with a Mutex and wait with a WaitGroup. Print the counts for go and rust.

package main

import (
    "fmt"
    "sync"
)

func main() {
    // 🎯 MINI-CHALLENGE: thread-safe word counter
    // Count how many times each word appears across the slice below using
    // many goroutines. Protect a shared map[string]int with a sync.Mutex
    // and wait for everyone with a sync.WaitGroup. Print go then rust.
    //
    // ✅ Expected output:
    //    go: 3
    //    rust: 2

    words := []string{"go", "rust", "go", "rust", "go"}
    // your code here: counts map, mutex, WaitGroup, then print counts["go"], counts["rust"]
}

🎉 Lesson Complete!

Practice quiz

What is sync.WaitGroup used for?

  • Locking shared state
  • Running setup once
  • Waiting for a set of goroutines to finish
  • Counting atomically without goroutines

Answer: Waiting for a set of goroutines to finish. A WaitGroup is a counter you Wait on until all goroutines call Done.

Where should you call wg.Add(1)?

  • Before launching the goroutine (before the go statement)
  • Inside the goroutine
  • After wg.Wait()
  • It does not matter

Answer: Before launching the goroutine (before the go statement). Add before the go statement, or Wait can race the Add and return too early.

Where is it idiomatic to put wg.Done()?

  • At the end of main
  • Before Add
  • Inside Wait
  • In a defer as the first line of the goroutine

Answer: In a defer as the first line of the goroutine. defer wg.Done() guarantees the count drops even on early return or panic.

What does sync.Mutex provide?

  • Many concurrent writers
  • Mutual exclusion — one goroutine in the critical section at a time
  • Lock-free counting
  • One-time initialisation

Answer: Mutual exclusion — one goroutine in the critical section at a time. A Mutex serialises access so only one goroutine holds the lock at a time.

Why pair mu.Lock() with defer mu.Unlock()?

  • It guarantees the lock is released even on early return or panic, avoiding deadlock
  • It is faster
  • It allows two holders
  • It is required to compile

Answer: It guarantees the lock is released even on early return or panic, avoiding deadlock. defer Unlock ensures the lock is always released, preventing deadlock.

How does sync.RWMutex differ from sync.Mutex?

  • It is always faster
  • It never blocks
  • It allows many concurrent readers OR one exclusive writer
  • It only works on maps

Answer: It allows many concurrent readers OR one exclusive writer. RWMutex lets multiple RLock readers proceed together, or one Lock writer exclusively.

What does sync.Once guarantee?

  • A function runs on every goroutine
  • A function runs exactly once across all goroutines
  • A function never runs twice in a row
  • Locking for one goroutine

Answer: A function runs exactly once across all goroutines. once.Do(f) runs f exactly once; later callers wait then skip f.

When is sync/atomic (e.g. atomic.Int64) preferable to a Mutex?

  • For updating two related fields together
  • For waiting on goroutines
  • For one-time setup
  • For a single value updated with simple operations like add or load

Answer: For a single value updated with simple operations like add or load. Atomics are lock-free for a lone counter or flag; use a Mutex once fields must change together.

What does the -race flag do?

  • Speeds up the program
  • Builds with the race detector to report concurrent unsynchronised access
  • Disables goroutines
  • Forces single-threaded execution

Answer: Builds with the race detector to report concurrent unsynchronised access. go run -race instruments memory access and reports data races.

Can you copy a sync.Mutex or sync.WaitGroup after first use?

  • Yes, freely
  • Only WaitGroup
  • No — copying duplicates internal state and breaks it; pass by pointer
  • Only when unlocked

Answer: No — copying duplicates internal state and breaks it; pass by pointer. These sync types must not be copied; pass them by pointer. go vet flags copies.

Continue this course

Frequently asked questions

When should I use a channel instead of a Mutex?

Use a channel when you are passing ownership of data from one goroutine to another — Go's motto is 'share memory by communicating'. Reach for a sync.Mutex when several goroutines must read and update the SAME shared variable in place, like a counter, cache, or map, where shuttling it through a channel would be clumsy. Both are correct tools; pick the one that makes the data flow clearest.

Why must I always pair Lock with Unlock, and why use defer?

A Mutex that is locked and never unlocked blocks every other goroutine forever — a deadlock. Writing defer mu.Unlock() right after mu.Lock() guarantees the lock is released when the function returns, even if it returns early or panics, so you can't forget it on one of several code paths.

What is the difference between Mutex and RWMutex?

A sync.Mutex allows exactly one goroutine into the critical section at a time, for both reads and writes. A sync.RWMutex lets many readers hold RLock concurrently OR one writer hold Lock exclusively. RWMutex pays off when reads vastly outnumber writes; for write-heavy or very short critical sections a plain Mutex is usually faster because RWMutex has more bookkeeping.

When is sync/atomic better than a Mutex?

Use sync/atomic (atomic.Int64, atomic.Bool, etc.) for a single value updated with simple operations like add, load, store, or compare-and-swap. The CPU does it as one indivisible instruction, so it is lock-free and typically faster than a Mutex for a lone counter or flag. As soon as you need to update two related fields together, go back to a Mutex so they change as a unit.

What does the -race flag actually do?

go run -race (and go test -race) builds your program with the race detector, which instruments memory access and reports when two goroutines touch the same variable concurrently with at least one write and no synchronisation. It catches data races that are otherwise invisible until they corrupt data in production. Run your tests with -race in CI; the only cost is slower, heavier binaries you don't ship.

Can I copy a sync.WaitGroup or sync.Mutex?

No. The sync types must not be copied after first use — copying a Mutex duplicates its internal state and breaks mutual exclusion. Always pass them by pointer (for example *sync.WaitGroup), or embed them in a struct and pass that struct by pointer. go vet flags accidental copies for you.