Concurrency Patterns - From Goroutines to Real Systems
In Phase 6 you learned the two primitives - go to start a concurrent task, channels to pass values safely. Those are the bricks; this phase is the architecture built from them over and over in real Go programs.
The mental model: production concurrency is rarely "start one goroutine and wait." It's "start a controlled number of goroutines, give them a way to stop when the work is no longer needed, spread work across them, collect results, and protect anything they share." Each section below is one piece of that - no new machinery, just go, channels, and sync arranged into shapes you'll reach for by name.
select - wait on several channels at once
You met select briefly in Phase 6 for a timeout - time to make it a tool you reach for deliberately, the control structure that turns one-channel toys into real coordination.
📝 select - a switch whose cases are channel operations (sends or receives). It blocks until one case can proceed, then runs exactly that case; if several are ready at once, it picks one at random, so no single channel can starve the others.
Non-blocking with default. Add a default case and select stops blocking: if no channel is ready right now, it runs default and moves on - how you "peek" without committing to wait.
package main
import "fmt"
func main()
$ go run main.go
got a job: 1
no job ready
What just happened: The first select found a value waiting and took the receive case; the second found the channel empty and, thanks to default, didn't block - it reported "no job ready" and moved on. Without default, that second receive would block forever (deadlock!). default is your "try, but don't wait" switch.
A timeout with time.After. The other everyday shape: "wait for a result, but give up after a while." time.After(d) returns a channel delivering one value after duration d - put it in a select beside the thing you're really waiting on, and whichever fires first wins.
package main
import (
"fmt"
"time"
)
func main()
$ go run main.go
gave up waiting
What just happened: select watched two channels - result and the time.After timer. The work needed 2 seconds; the timer fired at half a second, so the timeout case won. The slow goroutine is still out there, though - it'll eventually try to send on result with nobody listening, and leak. That dangling goroutine is exactly what context solves next.
💡 Key point. select is the join point of Go concurrency: "receive a result or time out," "send or bail if the consumer is gone," "wait on work or a cancellation signal" - all the same shape, list the channels, let whichever is ready win.
context - telling goroutines to stop
The timeout above stopped us from waiting, but did nothing to stop the worker. In a real server - thousands of requests, each spawning goroutines - work nobody needs anymore has to actually stop, or you bleed memory and CPU. That's what context is for.
📝 context.Context - a value carrying a cancellation signal (plus a deadline and request-scoped values) down a call tree, via one channel, ctx.Done(), that closes when work should stop. Goroutines select on ctx.Done() and exit when it fires; cancel a context and every context derived from it is cancelled too.
Create one from a parent (context.Background(), or a server's request context) with a helper that also gives you a way to trigger cancellation:
context.WithCancel(parent)- returns actxand acancel()function you call to stop it.context.WithTimeout(parent, d)- auto-cancels after durationd(and still gives you acancelto call early).
A real example. A worker that loops until it's told to stop:
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context)
func main()
$ go run main.go
working...
working...
working...
worker stopping: context deadline exceeded
What just happened: The worker looped, doing a little work each pass. After 500ms the timeout fired, closing ctx.Done(); the select picked that case up, the worker printed why it stopped (ctx.Err() reports context deadline exceeded) and returned cleanly - no leak. The defer cancel() is non-negotiable: it frees the timer even when the timeout already fired, and go vet warns if you forget it.
💡 Key point. By strong convention, ctx is the first parameter of any function doing cancellable work: func Fetch(ctx context.Context, url string) (...). Pass it down the call chain; never store it in a struct. When a request is cancelled or times out at the top, that signal propagates all the way down and every goroutine watching ctx.Done() unwinds - the idiom for not leaking goroutines.
Worker pool - N goroutines sharing a queue of jobs
An unbounded number of goroutines (one per job, for a million jobs) will happily exhaust memory or hammer a database into the ground. The fix is a worker pool: a fixed crew of goroutines pulling jobs off one channel and pushing results onto another - parallelism and a cap on how much runs at once.
A real example. Three workers squaring numbers:
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup)
func main()
$ go run main.go
sum of squares: 91
What just happened: Three workers each ran for n := range jobs, competing to pull from the same channel - free load balancing. close(jobs) after sending all six jobs let each worker's range loop end once the channel drained. A WaitGroup tracked the workers; once wg.Wait() returned, every sender on results was done, so it was safe to close(results) and range over it. Result order is non-deterministic, but the sum is deterministic - 1+4+9+16+25+36 = 91.
⚠️ Gotcha - close in the right order. close(results) must come after wg.Wait() - closing while a worker still sends panics with "send on closed channel." The discipline: senders finish, then whoever knows they're all done closes the channel.
Notice the directional channel types: jobs <-chan int (receive-only) and results chan<- int (send-only). The compiler now enforces that a worker can only take from jobs and put onto results - a safety rail documenting the data flow.
Fan-out / fan-in - split work, then merge results
A worker pool is fan-out/fan-in, named by its two halves:
- Fan-out - multiple goroutines reading from the same channel, dividing the work (our three workers ranging over
jobs). - Fan-in - multiple goroutines writing to the same channel, merging output into one stream (our three workers sending to
results).
The new wrinkle in a standalone merge: when several producers feed one output channel, who closes it? Same tool as above - a WaitGroup counting the producers, plus one goroutine that waits then closes:
func merge(cs ...<-chan int) <-chan int
What just happened: merge starts one goroutine per input channel, each copying values into the shared out channel - the fan-in. A separate goroutine waits on the WaitGroup and closes out only once every producer has finished, so the caller writes a plain for v := range merge(...). This "WaitGroup + closer goroutine" combo is the canonical way to safely close a channel with many writers.
The sync toolbox - when to share memory instead
Channels are the headline, but aren't always the right tool. Sometimes you genuinely have shared state - a counter, a cache, a config loaded once - and a channel is more ceremony than it's worth. That's what sync is for.
sync.Mutex - one writer at a time. A mutex is a lock: Lock() before touching shared state, Unlock() after. Only one goroutine holds the lock at a time, so the protected section can't race.
package main
import (
"fmt"
"sync"
)
type counter struct
func ()
func main()
$ go run main.go
final count: 1000
What just happened: A thousand goroutines incremented the same c.n. c.n++ looks atomic but isn't - read, add, write, three steps the scheduler can interrupt, so two goroutines can read the same value and lose an increment. The mutex serializes inc, so all 1000 land exactly. Remove the lock and you'd get something less than 1000, differently wrong each run. defer c.mu.Unlock() right after Lock() releases even if the body panics.
📝 sync.RWMutex - distinguishes readers from writers. Many goroutines can hold the read lock (RLock) at once, but a write lock (Lock) is exclusive. Reach for it when reads vastly outnumber writes (a config read on every request, written rarely) - readers run in parallel instead of queueing.
sync.WaitGroup - recap. Used throughout: Add(n) before launching, defer Done() inside each goroutine, Wait() to block until the counter hits zero.
sync.Once - run something exactly once. For one-time initialization (a config load, a singleton connection) several goroutines might trigger at once, Once guarantees the function runs a single time and every other caller blocks until it completes.
var (
once sync.Once
config map[string]string
)
func loadConfig() map[string]string
What just happened: No matter how many goroutines call loadConfig simultaneously, once.Do runs its function exactly one time; the rest wait, then see the already-populated config - race-free lazy initialization without writing your own locking.
So: channel or mutex? The Phase 6 mantra - "don't communicate by sharing memory; share memory by communicating" - is the default, not a law:
- Use a channel when you're passing ownership of data between goroutines, or coordinating who does what next.
- Use a mutex when goroutines genuinely share one piece of state and each needs brief, exclusive access (a counter, an in-memory cache) - wrapping that in channels is usually more code for no benefit.
If unsure, start with a channel - it's harder to misuse. Drop to a mutex when the channel version feels like fighting the problem.
⚠️ Gotcha - goroutine leaks, again. The silent failure of every pattern here: a goroutine blocked forever on a send or receive that never happens never gets garbage-collected, and the runtime won't warn you. The slow worker from the select/timeout example was one. The fixes: give every long-lived goroutine a ctx.Done() case, and make sure every channel you send on has a guaranteed receiver (or gets closed). Leaks don't crash - they slowly eat memory until something far away falls over.
And finally: the race detector. Some concurrency bugs cannot be found by reading at all. A data race - two goroutines touching the same memory at once, at least one writing, no synchronization - might work fine for a million runs and corrupt data on the million-and-first. Go ships a tool that finds them for you:
$ go run -race main.go # run with the race detector on
$ go test -race ./... # or, far better, run your whole test suite with it
Run with -race and Go instruments every memory access; the instant two goroutines touch the same location unsafely, it prints a WARNING: DATA RACE report naming the goroutines, variable, and stack traces - catching the lost-update bug from the mutex example, slice and map races, all of it. It's slower (don't ship a -race binary), but make go test -race part of your normal runs - the single highest-leverage habit in concurrent Go. If you write goroutines and never run it, you have bugs you haven't met yet.
Recap
selectwaits on several channel operations and runs whichever is ready; adddefaultfor a non-blocking peek, and pair withtime.Afterfor timeouts. It's the join point of all the patterns here.contextcarries a cancellation signal down a call tree. Goroutinesselectonctx.Done()to stop cleanly; create one withWithCancel/WithTimeout, alwaysdefer cancel(), and passctxas the first parameter.- Worker pool caps concurrency: a fixed set of goroutines
rangeover one jobs channel and send to one results channel. Closejobsto end the workers;WaitGroupthen tells you when it's safe to closeresults. - Fan-out / fan-in is that pool by name - many readers split the work, many writers merge results. To close a many-writer channel safely, use a
WaitGroupplus a goroutine that waits then closes. - The
synctoolbox guards shared state directly:Mutex/RWMutexfor exclusive (or read-shared) access,WaitGroupto wait for a batch,Oncefor one-time init. Channels to pass ownership; mutexes to share state. ⚠️ A goroutine blocked forever is a silent leak - give every one a way out. - The race detector (
go test -race,go run -race) finds data races that reading the code never will. Make it part of your normal test runs.
You can now build the concurrent shapes real Go services are made of - bounded, cancellable, checked for races. Next: handling what goes wrong, the Go way.
Quick check
Test yourself on the patterns that separate toy goroutines from production ones:
[
{
"q": "In a worker pool, why must `close(results)` come after `wg.Wait()` rather than before?",
"choices": [
"Because a worker still sending on `results` would panic if the channel were already closed",
"Because `close` only works on empty channels",
"Because `wg.Wait()` is what allocates the results channel",
"Because closing first would make the workers run twice"
],
"answer": 0,
"explain": "Sending on a closed channel panics. `wg.Wait()` returns only once every worker has finished (and therefore stopped sending), so it's the signal that closing `results` is now safe."
},
{
"q": "What does a goroutine actually do to respond to a context being cancelled?",
"choices": [
"It `select`s on `ctx.Done()`, which becomes ready when the context is cancelled, and returns",
"It polls `ctx.Cancelled` as a boolean every loop",
"Go automatically kills the goroutine when `cancel()` is called",
"It waits for the garbage collector to stop it"
],
"answer": 0,
"explain": "Cancellation closes the channel returned by `ctx.Done()`. A goroutine watches that channel in a `select`; once it's ready, the goroutine cleans up and returns on its own. Go never forcibly stops a goroutine - cooperation via `ctx.Done()` is the mechanism."
},
{
"q": "You have a shared in-memory counter incremented by 1000 goroutines and the final total is sometimes less than 1000. What's the right fix?",
"choices": [
"Protect the increment with a `sync.Mutex` (or run with `-race` to confirm the data race first)",
"Add a `time.Sleep` after each increment so they don't collide",
"Run the program more times until it gives 1000",
"Make the counter a global variable so all goroutines see it"
],
"answer": 0,
"explain": "`n++` is read-add-write, not atomic, so concurrent increments lose updates - a classic data race. A mutex serializes access so every increment lands. `go run -race` will pinpoint exactly this race; sleeping only hides it, and a global is still shared unsafely."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. In a worker pool, why must `close(results)` come after `wg.Wait()` rather than before?
2. What does a goroutine actually do to respond to a context being cancelled?
3. You have a shared in-memory counter incremented by 1000 goroutines and the final total is sometimes less than 1000. What's the right fix?