11 · Goroutines, Channels and Select¶
Objectives: Understand how Go expresses concurrency with goroutines and channels, learn how buffered channels differ from unbuffered ones, and see how
selectlets a worker react to multiple events at once. Estimated time: 25 minutes.
What this actually means (plain English)¶
No jargon — here's what the ideas in this lesson actually mean, and why they matter.
- Goroutine = "a worker you hire on a sticky note — costs almost nothing to write, runs in the background, and you can hire thousands." A goroutine launched with
go f()runs concurrently with the caller for only a few kilobytes of stack, unlike a full OS thread. - Channel = "a pneumatic tube connecting two rooms — one side posts a value in, the other pulls it out, and no one needs to reach into the other room's drawers." Channels let goroutines exchange typed values without shared memory.
- Buffered channel = "a tube with a small waiting room at the end — the sender can drop several items in the queue before anyone picks them up."
make(chan T, N)holds up to N values so the sender does not block until the buffer is full; an unbufferedmake(chan T)forces sender and receiver to be present at exactly the same moment. select= "a dispatcher watching several inboxes at once — whichever tray has mail first gets handled, and if two arrive together it picks one at random."selectlets a worker react to whichever of its channels is ready, preventing starvation through that built-in randomness.close(ch)= "hanging a 'no more deliveries today' sign on the tube — anyone still listening can drain what's left and then leave." Producers callclose(ch)to signal completion; receivers detect it withrange chor thev, ok := <-chidiom.
Why it matters: every hardening problem in this repo — concurrency, cancellation,
deadlines — is solved with this handful of primitives. If you can read a select
loop, you can read all of it.
See it — goroutines, a buffered jobs channel, and a select worker loop.
The simplest goroutine¶
In plain terms: this line starts a tiny background task that prints a message, and does not make the rest of the program wait for it to finish.
go before any function call (a named, reusable chunk of instructions you can run — "call" or "invoke" a function means "run it") spins it into the background. The caller (the code that started it running) keeps running
immediately. If main returns first — that is, if the program's entry-point function finishes and hands control back before the background task is done — the goroutine (a lightweight, independently-running task; Go's version of a "worker" that costs almost nothing to start, as opposed to a full operating-system thread) is silently killed, so production
code always synchronises with sync.WaitGroup (a counter object that lets one part of the program wait until a group of background tasks has finished) or channels (typed pipes that let two goroutines pass values safely between them).
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("working…")
}()
wg.Wait() // block until the goroutine calls Done()
In plain terms: wg.Add(1) says "I'm about to start one background task," the goroutine runs and calls Done() when it finishes (defer means "run this line automatically right before the surrounding function exits, no matter how it exits"), and wg.Wait() makes the main program pause — "block" means the line simply waits there and does nothing else — until that task reports it is done.
Channels: the pipe metaphor¶
Unbuffered — rendezvous¶
ch := make(chan int) // no buffer
go func() {
ch <- 42 // blocks until receiver is ready
}()
v := <-ch // blocks until sender sends
fmt.Println(v) // 42
In plain terms: make(chan int) creates a pipe that can carry whole numbers between goroutines. The background task tries to send the number 42 through it, but that send simply waits until something on the other end is ready to receive; the main code's v := <-ch line does that receiving, and both sides unblock together the instant they meet.
Sender and receiver must both be ready at the same moment — a rendezvous. Use this when timing matters: if one side isn't there yet, you want to wait, not race ahead.
Buffered — mailbox¶
ch := make(chan int, 4) // room for 4 items
ch <- 1 // returns immediately, item sits in the buffer
ch <- 2
v := <-ch // 1
The sender is not blocked until the mailbox is full. This is the right choice when you know the upper bound on outstanding work up front — and you often do.
Closing and ranging¶
ch := make(chan int, 5)
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // tell readers "that's all"
for v := range ch { // drains until closed
fmt.Println(v)
}
In plain terms: the code drops five numbers into the pipe, then closes it to mean "nothing more is coming." The for v := range ch loop pulls values out one at a time and stops automatically once the pipe is both empty and closed — no explicit "are we done?" check needed.
range (a loop keyword that steps through every item in something, one at a time) on a channel drains it and exits the loop when the channel is closed.
Sending to a closed channel panics (the program hits an unrecoverable error and stops, unless something explicitly catches it), so only the producer should call close.
select — listening to multiple channels at once¶
In plain terms: select waits on two pipes at once. If a message arrives on inbox first, it gets processed; if instead the cancellation signal ctx.Done() fires first, the function returns (finishes and hands its result — here, an error — back to whoever called it) immediately. Whichever pipe has something ready first wins.
If both channels are ready simultaneously, Go picks one uniformly at random. That randomness is a feature: it prevents starvation (a situation where one task never gets its turn because something else is always chosen ahead of it).
Priority matters sometimes
If you need a strict priority check (e.g. "always honour cancellation
first"), don't rely on select's random choice. Instead, check the
high-priority channel before entering select:
if err := ctx.Err(); err != nil {
return err
}
select {
case idx := <-jobs: ...
case <-ctx.Done(): ...
}
The real LoadBatchConcurrent worker does exactly this — see below.
Reading the real worker loop — stb-image-go/stb_image.go¶
LoadBatchConcurrent decodes a batch of images in parallel. It uses every
primitive from above, so it is a perfect reading exercise.
Step 1 — decide the worker count¶
// stb-image-go/stb_image.go
numWorkers := runtime.NumCPU()
if len(datas) < numWorkers {
numWorkers = len(datas)
}
In plain terms: figure out how many CPU cores the machine has, then cap the number of workers at however many images there actually are (a slice — an ordered, resizable list of values, here datas — holds those images; len(datas) counts how many items are in it).
No point spawning more workers than there are images.
Step 2 — pre-fill a buffered jobs channel, then close it¶
jobs := make(chan int, len(datas))
for i := 0; i < len(datas); i++ {
jobs <- i // send the image *index*, not the image itself
}
close(jobs) // signal: no more work will arrive
In plain terms: create a buffered pipe big enough to hold one number per image, drop in each image's position number (its "index" — its position, counting from 0, in the list) rather than the image data itself, then close the pipe to announce that all the work has been posted.
Closing immediately after filling is the classic "fan-out" pattern. Workers
read indices from jobs; when the channel is drained and closed, range jobs
(or the ok check in select) exits the loop automatically.
Step 3 — size the error channel to avoid a deadlock¶
// Buffer the worst case so no worker blocks on send: up to len(datas) decode
// failures plus up to numWorkers cancellation sends. An under-sized buffer
// deadlocks wg.Wait when cancellation coincides with decode failures.
errs := make(chan error, len(datas)+numWorkers)
In plain terms: make one more buffered pipe, this time for errors, sized large enough to hold a failure message from every single image PLUS a cancellation message from every worker — the worst case where everything goes wrong at once.
This comment is the most important line in the file. If the buffer were only
len(datas), a cancelled context (Go's built-in mechanism for signalling "stop now" — a deadline or a cancel — across goroutines) could cause every worker to also try to
send ctx.Err() on the same channel. With N workers all trying to send and
nobody reading yet, the Nth send blocks. The goroutine is stuck. wg.Wait
waits for it. Deadlock (every remaining goroutine is permanently stuck waiting on something that will never happen, so the program hangs forever). The fix is to reserve space for both failure kinds.
This was a real bug
The original buffer was len(datas). A cancellation during a full-error
batch deadlocked wg.Wait indefinitely. The fix — len(datas)+numWorkers
— was added after the 2026-06 audit. The jsmn-go parallel worker had the
identical bug at the same time. See Lesson 14
for the full post-mortem.
Step 4 — launch workers¶
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
// Priority check — don't even enter select if already cancelled.
if err := ctx.Err(); err != nil {
errs <- err
return
}
select {
case idx, ok := <-jobs:
if !ok {
return // jobs channel drained and closed
}
img, err := Load(datas[idx])
if err != nil {
errs <- fmt.Errorf("failed to decode image at index %d: %w", idx, err)
} else {
results[idx] = img // safe: each goroutine writes a distinct index
}
case <-ctx.Done():
errs <- ctx.Err()
return
}
}
}()
}
In plain terms: launch numWorkers background tasks. Each one loops forever until it decides to stop: first it checks whether cancellation has already happened; then it waits on select for either a job index to pull from jobs, or a cancellation signal. If it gets a job, it loads that image and records either the result or an error; if jobs is closed and empty, or cancellation fires, it exits the loop and reports it is done.
Key observations:
defer wg.Done()— fires however the goroutine exits (normal, cancellation, error). Thedeferremoves the need for multipleDone()calls scattered across every return path.- Priority check before
select— ifctxis already cancelled when the goroutine starts,selectwould randomly pick betweenjobsandctx.Done(). The explicitctx.Err()check beforeselectguarantees cancellation is honoured immediately. oksentinel —case idx, ok := <-jobsdetects a closed channel. Whenokis false the worker returns cleanly.- Write to
results[idx]— no mutex (a lock that lets only one goroutine touch a shared piece of data at a time, preventing two workers from corrupting it by writing at once) needed because each goroutine owns a distinct index; they never write the same slot.
Step 5 — drain, then collect errors¶
wg.Wait()
close(errs) // safe now: all senders are done
for err := range errs {
multiErr = append(multiErr, err)
}
close(errs) is called only after wg.Wait() confirms every worker has
returned. Once the channel is closed, the range loop drains whatever errors
accumulated and then exits.
Buffered vs unbuffered — when to choose which¶
| Situation | Recommendation |
|---|---|
| You know the exact max number of sends before any receive | Buffered (len(items)+headroom) |
| Sender and receiver should synchronise step-by-step | Unbuffered |
| Fan-out: pre-fill all work, then let workers pull | Buffered, closed after fill |
| Fan-in: collect results from N workers | Buffered (N) or merge with select |
| Signal "done" with no data | close(ch) on a chan struct{} |
A zero-capacity channel is not broken
make(chan T) is deliberately strict — it forces you to think about who is
waiting for whom. Unbuffered channels are excellent for one-shot signals and
unit tests where you want to assert ordering.
Try it¶
Try it
Run the stb-image-go tests, including the race detector, from the repo root:
Expected outcome: all tests pass with zero data-race warnings. The test suite includes a cancellation test that sends a context whose deadline expires mid-batch; before the buffer-size fix, that test would hang indefinitely. If it exits within a few seconds the fix is working.
To watch the worker pool in action with a short benchmark:
You should see throughput scale with the number of CPUs on your machine.
Key takeaways¶
- Launch a goroutine with
go f(). Synchronise withsync.WaitGroupor a channel — never assume timing. - Buffered channels (
make(chan T, N)) decouple sender and receiver up to N items. Choose N carefully: too small and you deadlock; too large and you hide backpressure. selectreacts to whichever channel is ready first. When two cases are simultaneously ready it picks at random — add a priority check beforeselectwhen ordering is critical.close(ch)is the producer's signal that no more values will arrive. Receivers detect it withrangeor the_, ok := <-chidiom.- The buffer size
len(datas)+numWorkersinLoadBatchConcurrentis not arbitrary — it is the exact worst-case count of sends that can racewg.Wait, and getting it wrong causes a deadlock that only manifests under cancellation.