Week 7: Concurrency Patterns: select, sync & context

Week 6 covered the primitives; this week covers the patterns that turn a toy goroutine-and-channel example into concurrency you'd trust in a real service — waiting on several channels at once, coordinating a known number of workers, and cancelling work cleanly when it's no longer needed.

Module 7 of 16 Week 7 of 16 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Use select to wait on multiple channels and act on whichever is ready first
  • Coordinate a group of goroutines with sync.WaitGroup, and protect shared state with sync.Mutex
  • Use context for cancellation, timeouts, and passing request-scoped values
  • Recognize when a mutex is the right tool instead of a channel, and vice versa

1. select for Multiple Channels

select is like a switch for channel operations — it blocks until one of several channel cases is ready, then runs that one. It's how a goroutine watches multiple sources of work (or a cancellation signal) at once.

racing two channels, with a timeout
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 waiting for either channel")
}

If multiple cases are ready simultaneously, select picks one at random — deliberately, so code can't accidentally come to depend on an ordering between cases the language never promised, the same philosophy behind randomized map iteration from Week 4.

2. sync.WaitGroup & sync.Mutex

sync.WaitGroup waits for a known number of goroutines to finish — cleaner than a channel when you don't actually need to receive a value back, just to know everyone's done.

waiting for N goroutines
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Println("worker", id, "done")
    }(i)
}
wg.Wait() // blocks until all 5 have called Done()
fmt.Println("all workers finished")

sync.Mutex is for the other case — when goroutines genuinely need to share and mutate the same piece of memory (a counter, a cache) rather than communicate over a channel.

protecting shared state
type Counter struct {
    mu    sync.Mutex
    count int
}

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

Go's own proverb — "don't communicate by sharing memory, share memory by communicating" — is a preference, not a hard rule. A mutex-protected counter is often simpler and faster than a channel-based equivalent for genuinely shared, frequently-mutated state.

3. context for Cancellation & Timeouts

context.Context carries a cancellation signal (and optionally a deadline, and request-scoped values) through a call chain — the standard way to tell a tree of goroutines "stop what you're doing" from the outside.

a goroutine that respects cancellation
func worker(ctx context.Context, results chan<- int) {
    for i := 0; ; i++ {
        select {
        case <-ctx.Done():
            fmt.Println("worker stopping:", ctx.Err())
            return
        case results <- i:
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel()

    results := make(chan int)
    go worker(ctx, results)

    for {
        select {
        case n := <-results:
            fmt.Println("got", n)
        case <-ctx.Done():
            fmt.Println("main stopping:", ctx.Err())
            return
        }
    }
}

context.WithTimeout (and its sibling WithCancel) return a context that closes its own Done() channel once the timeout elapses (or cancel() is called) — every goroutine that received that context and selects on ctx.Done() sees the signal and can stop cleanly, which is exactly the mechanism that prevents the goroutine leak described back in Week 5.

4. Hands-on Exercise

Hands-on

Build a cancellable worker pool

Combine select, WaitGroup, a mutex, and context into one coordinated program.

Requirements:

  1. A worker pool of 4 goroutines, coordinated with sync.WaitGroup, each pulling simulated "jobs" from a shared channel.
  2. A shared Counter struct (protected by sync.Mutex) that every worker increments once per completed job.
  3. A context.WithTimeout passed to every worker — each worker's job loop uses select to watch both the jobs channel and ctx.Done(), stopping cleanly on either.
  4. A deliberately short timeout (shorter than all jobs would take to finish) to confirm workers actually stop early rather than running to completion regardless.
  5. After wg.Wait() returns, print the final counter value and confirm it matches the number of jobs actually completed before cancellation.
Hint

If workers seem to ignore the context timeout and keep running anyway, check that every blocking operation inside the worker's loop — not just the top-level loop condition — is expressed as a select case alongside ctx.Done(); a worker blocked on a plain (non-select) channel receive or send has no way to notice cancellation until that operation itself unblocks.

5. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does select pick randomly among multiple ready cases instead of always preferring the first one listed?

Choosing randomly is deliberate, for the same reason map iteration order is randomized — it prevents code from accidentally depending on a case-priority ordering the language never actually promised, which would be a portability and correctness trap if a future Go version (or just different timing) changed which case happened to be checked first.

Q2

When is sync.WaitGroup a better fit than a channel for coordinating goroutines?

When you only need to know that a known number of goroutines have finished, with no value to actually receive back from each one — a WaitGroup is simpler and more direct for that specific "wait for N completions" case than setting up a channel purely to signal completion.

Q3

Why does Counter.Increment need a mutex at all — what goes wrong without one if multiple goroutines call it concurrently?

c.count++ is not a single atomic operation — it reads the current value, adds one, and writes it back, as separate steps. If two goroutines interleave those steps without a mutex, both can read the same starting value before either writes back, and one increment is silently lost — a classic data race that a mutex prevents by ensuring only one goroutine executes that read-modify-write sequence at a time.

Q4

In the cancellable worker example, why does the worker's select need a case for ctx.Done() specifically, rather than just checking ctx.Err() != nil once at the top of the loop?

A plain check at the top of the loop only catches cancellation between iterations — if the worker is currently blocked inside that same iteration on a channel send or receive with no timeout of its own, a top-of-loop check never gets a chance to run again until that blocking operation happens to unblock on its own. Including ctx.Done() as a select case lets cancellation interrupt the worker immediately, even while it would otherwise be blocked indefinitely.