Goroutines & Channels
Channels, select, worker pools, goroutine leaks, errgroup
- 01
A
mainthat fires off threego process(job)calls and then returns prints nothing at all. Where did the work go?EasyWhen
mainreturns the process exits immediately — the runtime never waits for goroutines, and there is no way to detach one so that it outlives the program.func process(job string) { /* ... */ } func main() { jobs := []string{"a", "b", "c"} // ❌ main returns before the scheduler has run any of them. The process exits, … - 02
ch := make(chan int)followed bych <- 1inmaindies withfatal error: all goroutines are asleep - deadlock!, and adding one buffer slot fixes it. What changed?EasyAn unbuffered channel is a rendezvous: the send does not complete until another goroutine is sitting at the matching receive, and in a single-goroutine program nobody ever arrives.
func deadlock() { ch := make(chan int) ch <- 1 // ❌ fatal error: all goroutines are asleep - deadlock! // goroutine 1 [chan send] fmt.Println(<-ch) } … - 03
A consumer goroutine pegs a core and logs
handled 0forever once the producer finishes. The producer's last statement wasclose(jobs). What is the loop missing?EasyA receive from a closed channel returns immediately with the zero value, forever — the loop has to read the second result,
j, ok := <-jobs, or userange, to notice that the channel is done.// ❌ a closed channel is always ready and always yields the zero value: // this spins at 100% of a core the moment close(jobs) runs. func consumeBad(jobs <-chan Job) { for { j := <-jobs handle(j) … - 04
A pipeline stage is declared
func square(in chan int, out chan int)and the reviewer asks for<-chanandchan<-instead. What do the direction types buy you?EasyThey make each stage's contract checkable at compile time: a
<-chan Tcannot be sent on or closed, achan<- Tcannot be received from, and the narrowing conversion is implicit, one-way and free at runtime.// Each stage owns its output: it creates it, fills it, and closes it. func square(in <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) // the only close, by the only goroutine that sends for v := range in { … - 05
A worker's
selectlistscase <-quit:first andcase j := <-jobs:second, yet afterclose(quit)it still handles a dozen more jobs. Why doesn't the first case win?Mediumselectmakes a uniform pseudo-random choice among the cases that are ready — source order carries no priority whatsoever, and a closedquitis merely one more ready case.// ❌ source order is not priority: select draws uniformly among the ready cases, // so roughly half the iterations keep taking jobs after close(quit). func workerBad(jobs <-chan Job, quit <-chan struct{}) { for { select { case <-quit: … - 06
Metrics are recorded with a plain
c.events <- efrom inside the request path, and when the collector stalls every handler stalls with it. How do you make that send unable to block?MediumWrap it in a
selectwith adefault— thedefaultcase runs when no other case is ready, so the send either fits in the buffer right now or is dropped right now, and the caller never parks.type Collector struct { events chan Event dropped atomic.Uint64 } // Called from the request path, so it must never park the caller. … - 07
A fan-in loop merges two producers with
select. The moment the first one closes, the loop starts spinning and emitting zeros instead of waiting for the second. What is the one-line fix?MediumSet the drained channel's variable to
nil— a receive from a nil channel blocks forever, so a nil case can never be ready andselectsilently stops considering it.func merge(a, b <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) for a != nil || b != nil { // both nil means both drained select { … - 08
The goroutine profile grows by one every time a request times out, and every leaked stack ends in
runtime.chansend. The handler itself returns cleanly. What is still running?MediumThe worker goroutine is still trying to deliver its result to a receiver that walked away — an unbuffered send with no receiver parks forever, so every send made by a goroutine whose consumer can leave needs an escape.
// ❌ the handler returns on timeout, but the worker is still holding the result // and parks in chansend forever: one leaked goroutine per timed-out request. func handleBad(w http.ResponseWriter, r *http.Request) { out := make(chan Result) go func() { out <- compute(r) }() … - 09
A relay loop with
case <-time.After(5 * time.Minute):in itsselectclimbs steadily in RSS on the production service, while the same code in a colleague's newer service is flat. Both are built by the same toolchain. What differs?Mediumtime.Afterallocates a fresh timer on every pass of the loop, and before Go 1.23 the runtime kept each one alive until it fired — five minutes' worth of timers, at loop speed, pinned in the timer heap.// ❌ a new Timer on every message. On a module whose go.mod still says go 1.22, // the runtime holds each one until it fires: 5 minutes of timers at 10k msg/s. func relayBad(in <-chan Msg) error { for { select { case m := <-in: … - 10
Eight workers write to one
resultschannel and thefor r := range resultsinmainhangs after the last result arrives. Puttingclose(results)on the line after the loop changes nothing. Where does the close belong?MediumIn its own goroutine, after
wg.Wait()—mainis inside therange, so the statement after it is unreachable, and no single worker may close a channel the other seven still send on.func run(rows []Row) { jobs := make(chan Row) results := make(chan Result) var wg sync.WaitGroup wg.Add(8) … - 11
An importer does
for _, row := range rows { go insert(db, row) }over a 200k-row file, and Postgres answerssorry, too many clients already. What replaces the loop?MediumA fixed pool of workers reading one jobs channel — the goroutine count becomes a constant you choose, instead of the size of the input file.
// ❌ one goroutine per row: 200k concurrent inserts, and the thing that breaks is // the database — "sorry, too many clients already". // for _, row := range rows { go insert(db, row) } // ✅ the pool size is the bottleneck's size, not the input's func importRows(db *sql.DB, rows []Row) { … - 12
The work already sits inside a
rangeover a channel you do not own, so a worker pool would mean restructuring the caller — but you still need at most eight concurrent uploads. What is the smallest thing that gives you that?MediumA buffered channel used as a counting semaphore:
sem := make(chan struct{}, 8), acquire with a send, release with a receive, and the send blocks precisely when eight are already in flight.func uploadAll(items <-chan Item) { sem := make(chan struct{}, 8) // 8 tokens; struct{} is zero-width var wg sync.WaitGroup for it := range items { sem <- struct{}{} // ✅ acquire BEFORE go: this loop is what waits … - 13
A three-stage pipeline is green in a test that reads it to exhaustion and leaks two goroutines in production, where the caller stops after the first ten values. What must every stage have that these do not?
HardA done channel selected on every send, because a stage nobody reads any more parks in
chansendforever and takes its upstream with it — one leaked goroutine per stage, per abandoned pipeline.func gen(done <-chan struct{}, ns ...int) <-chan int { out := make(chan int) go func() { defer close(out) for _, n := range ns { select { … - 14
Swapping a
sync.WaitGroupfor anerrgroup.Groupmade the failures visible, but callingg.SetLimit(16)inside the dispatch loop panics withmodify limit while 4 goroutines in the group are still active. What isSetLimitactually doing?HardSetLimit(n)replaces the group's internal semaphore, achan tokenof capacity n, so it may only be called while no goroutine holds a token — in practice, once, before the firstg.Go.func fetchAll(ctx context.Context, urls []string) ([]Page, error) { g, ctx := errgroup.WithContext(ctx) g.SetLimit(16) // ✅ once, before the first Go: it replaces the token channel pages := make([]Page, len(urls)) for i, u := range urls { … - 15
Sixteen workers make the enrichment stage eleven times faster and the CSV downstream comes out shuffled. Sorting afterwards is not an option — the stream does not fit in memory. How do you get the order back?
HardFix each item's position before the work fans out: push one buffered result channel per item onto an ordered queue, and have the consumer read those queued channels in sequence.
type job struct { row Row res chan Result // cap 1: the worker never waits for a consumer that is behind } // The dispatcher is single-threaded, so the order of queue is the input order. … - 16
A cache rewritten “the Go way” — one owner goroutine, a request channel, a reply channel per call — benchmarks four times slower than the
sync.RWMutexversion it replaced. Was the proverb wrong?HardThe proverb is about ownership of data in flight, not about guarding a field: a cache is state that many goroutines read, and a mutex expresses that directly while a channel adds an allocation and two scheduler handoffs to every lookup.
// ❌ "the Go way", 4x slower: an allocation plus two scheduler handoffs per Get, // and an owner goroutine that serializes readers which used to run concurrently. type request struct { key string reply chan string // allocated per call } …