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.
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.
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.
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.
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
Build a cancellable worker pool
Combine select, WaitGroup, a mutex, and context into one coordinated program.
Requirements:
- A worker pool of 4 goroutines, coordinated with
sync.WaitGroup, each pulling simulated "jobs" from a shared channel. - A shared
Counterstruct (protected bysync.Mutex) that every worker increments once per completed job. - A
context.WithTimeoutpassed to every worker — each worker's job loop usesselectto watch both the jobs channel andctx.Done(), stopping cleanly on either. - A deliberately short timeout (shorter than all jobs would take to finish) to confirm workers actually stop early rather than running to completion regardless.
- After
wg.Wait()returns, print the final counter value and confirm it matches the number of jobs actually completed before cancellation.
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?
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 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?
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?
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.