Code of the Day
AdvancedConcurrency

Sync primitives

Guard shared state with Mutex and RWMutex, coordinate goroutines with WaitGroup, run setup once with sync.Once, and detect races with -race.

GoAdvanced12 min read
Recommended first
By the end of this lesson you will be able to:
  • Protect shared state with sync.Mutex and sync.RWMutex
  • Wait for a group of goroutines with sync.WaitGroup
  • Guarantee one-time initialisation with sync.Once
  • Run the race detector and interpret its output
  • Choose between channels and mutexes for a given problem

Channels are Go's first tool for safe concurrency. But not every problem fits the channel model — sometimes multiple goroutines legitimately need read or write access to a shared data structure. The sync package provides lower-level primitives for those cases.

sync.Mutex

A (mutual exclusion lock) ensures only one goroutine accesses a critical section at a time:

type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

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

defer c.mu.Unlock() is idiomatic — it guarantees the unlock happens even if the method panics. Always defer the unlock immediately after the lock.

Never copy a sync.Mutex by value. If you embed a mutex in a struct, always pass the struct by pointer. Copying a locked mutex creates two independent copies — each thinks it is the lock — which breaks the invariant entirely.

sync.RWMutex

When reads are far more frequent than writes, RWMutex allows many concurrent readers but only one writer:

type Cache struct {
    mu    sync.RWMutex
    items map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()          // multiple goroutines can hold RLock simultaneously
    defer c.mu.RUnlock()
    v, ok := c.items[key]
    return v, ok
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()           // exclusive write lock
    defer c.mu.Unlock()
    c.items[key] = value
}

RLock/RUnlock for reads; Lock/Unlock for writes. Use RWMutex when profiling shows read contention on a standard Mutex.

sync.WaitGroup

lets one goroutine wait for a collection of goroutines to finish:

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(n int) {
        defer wg.Done()
        fmt.Println("worker", n)
    }(i)
}

wg.Wait()   // blocks until all five goroutines call Done()
fmt.Println("all workers finished")
  • wg.Add(n) — increment the counter by n before launching the goroutine.
  • wg.Done() — decrement the counter by 1 (equivalent to wg.Add(-1)).
  • wg.Wait() — block until the counter reaches zero.

Call wg.Add(1) before go func(), not inside it. If the goroutine scheduler runs wg.Wait() before the goroutine calls Add, the wait returns immediately. The pattern is: Add, then go, then Wait.

sync.Once

sync.Once guarantees that a function runs exactly once, regardless of how many goroutines call it:

var once sync.Once
var config *Config

func getConfig() *Config {
    once.Do(func() {
        config = loadConfig()   // called exactly once
    })
    return config
}

This is the idiomatic pattern for lazy, thread-safe singleton initialisation. Once once.Do has run, subsequent calls return immediately without calling the function again.

The race detector

Go ships a built-in . Enable it with the -race flag:

go test -race ./...
go run -race main.go

The race detector instruments memory accesses at compile time and reports concurrent accesses to the same memory without synchronisation:

WARNING: DATA RACE
Write at 0x00c000018090 by goroutine 7:
  main.main.func1()
      main.go:12 +0x3c
Previous read at 0x00c000018090 by goroutine 6:
  main.main.func2()
      main.go:18 +0x2c

Run -race in your test suite always. It adds latency and memory overhead, so don't use it in production builds, but every CI pipeline should run it.

Channels vs mutexes

Both solve concurrency safety. Choose based on the problem:

Use channels when...Use mutexes when...
Passing ownership of data between goroutinesProtecting shared state that multiple goroutines read/write
Coordinating goroutine lifecycle (done signals, pipelines)Simple counter or cache operations
Fan-out / fan-in patternsLow-contention, fine-grained locking

When in doubt, start with channels. Reach for sync.Mutex when you're guarding a data structure that doesn't fit the ownership-transfer model.

Check your understanding

Knowledge check

  1. 1.
    When should you call wg.Add(1) relative to launching the goroutine?
  2. 2.
    It is safe to copy a sync.Mutex by value when passing a struct to a function.
  3. 3.
    What does the -race flag do when running go test?

Do it yourself

Build a thread-safe SafeMap[K, V] using sync.RWMutex. Implement Get(key K) (V, bool) and Set(key K, val V). Then run a test with -race:

go test -race ./...

Where to go next

With sync primitives in hand, the next lesson introduces context — Go's standard way to propagate cancellation and deadlines through an entire call chain.

Finished reading? Mark it complete to track your progress.

On this page