← thecodex.expert · The Codex Family of Knowledge
Go

select and sync

Channels aren’t the only concurrency tool in Go — select multiplexes channels, and sync.Mutex and sync.WaitGroup handle the cases channels are the wrong fit for.

Go 1.26 sync.WaitGroup Last verified:
Where this bites you

You write a worker that calls wg.Done() as its very last line, after all its real work. It passes every test, because every test gives it valid input. Then a real call hits a validation check partway through and returns early — skipping the Done() call on the line below it — and the program just stops. Not a crash. It hangs forever, and the only clue is that it never comes back.

Gosyncbug.go
func worker(wg *sync.WaitGroup, n int) {
    if n < 0 {
        return              // skips wg.Done() below — easy to miss on review
    }
    fmt.Println("processing", n)
    wg.Done()
}

var wg sync.WaitGroup
wg.Add(1)
go worker(&wg, -1)
wg.Wait()                 // never returns
// fatal error: all goroutines are asleep - deadlock!

The WaitGroup example right below already does this correctly — the fix is simply to follow its pattern.

Canonical Definition

select is a control structure that blocks until one of several channel operations is ready, choosing randomly if more than one is ready at once — it’s how a goroutine waits on multiple channels simultaneously. The standard library’s sync package covers coordination that channels aren’t suited for: sync.Mutex guards a shared variable from concurrent access, and sync.WaitGroup blocks until a set of goroutines has finished.

select: waiting on multiple channels

Each case in a select is a channel send or receive. select blocks until one case is ready, and a default case makes the whole select non-blocking.

Goselect.go
select {
case msg := <-ch1:
    fmt.Println("from ch1:", msg)
case msg := <-ch2:
    fmt.Println("from ch2:", msg)
case <-time.After(2 * time.Second):
    fmt.Println("timed out")
default:
    fmt.Println("nothing ready right now")   // makes select non-blocking
}

sync.WaitGroup: waiting for goroutines to finish

A WaitGroup counts outstanding goroutines. Add increments the count before launching a goroutine, each goroutine calls Done when finished (typically via defer), and Wait blocks until the count returns to zero.

Gowaitgroup.go
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
    wg.Add(1)
    go func(n int) {
        defer wg.Done()
        fmt.Println("worker", n, "done")
    }(i)
}
wg.Wait()   // blocks until all 3 workers call Done()
fmt.Println("all workers finished")

That defer wg.Done() is exactly the fix for the deadlock at the top of this page. Deferring it means it runs no matter which path out of the function is taken — the early-return path included — instead of sitting on a line that an early return can skip over:

Gosyncfix.go
func worker(wg *sync.WaitGroup, n int) {
    defer wg.Done()         // runs no matter which path returns
    if n < 0 {
        return
    }
    fmt.Println("processing", n)
}

var wg sync.WaitGroup
wg.Add(1)
go worker(&wg, -1)
wg.Wait()
fmt.Println("done")          // prints — Wait() returns this time

sync.Mutex: protecting shared data

Goroutines share memory by default, which means a variable written by multiple goroutines needs protection from concurrent access (a data race). sync.Mutex provides Lock/Unlock to make a critical section run one goroutine at a time; sync.Once guarantees a function runs exactly once no matter how many goroutines call it.

Gomutex.go
var mu sync.Mutex
counter := 0

func increment() {
    mu.Lock()
    defer mu.Unlock()
    counter++   // safe: only one goroutine at a time reaches here
}

Sources

1
The Go Authors. sync package documentation, pkg.go.dev/sync — covers Mutex, WaitGroup, Once, and RWMutex.