Concurrency Design Guidelines
Concurrency Design Guidelines
Goroutines are cheap. Leaked goroutines are not. The boring default is: write the sequential version first, start a goroutine only when you can state how it stops, and bound how much work can run at once.
Mental model
A goroutine is a leak if:
- nothing will ever receive the value it is sending
- nothing will ever send the value it is receiving
- it is in a loop that never looks at a
donechannel orctx.Done() mainor a request handler returned and the goroutine is still blocked
“I started a goroutine per ticket” is unbounded. A slow kitchen plus a busy window becomes a process that grows until it dies.
Sequential code has one stack, one error path, and an obvious end. Concurrent code needs a stop signal, a wait, and a cap. If you cannot name those three, do not type go.
Worked examples
Case 1: Sequential is enough
Save as seq_print.go. Three tickets, one printer, no goroutines. This is the version to ship until a measurement says you are waiting on something real.
// seq_print.go
package main
import "fmt"
func printTicket(id int) {
fmt.Println("printed", id)
}
func main() {
for _, id := range []int{7, 8, 9} {
printTicket(id)
}
}Run:
go run seq_print.goOutput:
printed 7
printed 8
printed 9
Same order every run. Errors would return to main on the same line. Start here.
Case 2: The leak
Save as leak.go. A worker sends a ticket. Nobody receives. The goroutine blocks forever. main returns, so this process exits — in a server, main would not return, and the goroutine would stay.
We keep the process alive long enough to show the hang using a second channel main never hears from the worker on.
// leak.go
package main
import (
"fmt"
"time"
)
func leak(out chan<- int) {
out <- 7
fmt.Println("sent")
}
func main() {
ch := make(chan int)
go leak(ch)
time.Sleep(30 * time.Millisecond)
fmt.Println("main leaving; worker still blocked on send")
}Run:
go run leak.goOutput:
main leaving; worker still blocked on send
sent never prints. The goroutine is stuck on out <- 7. In a server that calls leak per request, that is a goroutine leak. Sleep here is only a window to observe the bug, not a fix.
Case 3: Fix the leak
Save as no_leak.go. The worker selects on send and on ctx.Done(). Cancel stops the send. Wait proves the goroutine returned.
// no_leak.go
package main
import (
"context"
"fmt"
"sync"
)
func worker(ctx context.Context, out chan<- int) {
select {
case out <- 7:
fmt.Println("sent")
case <-ctx.Done():
fmt.Println("abandoned:", ctx.Err())
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan int)
var wg sync.WaitGroup
wg.Go(func() { worker(ctx, ch) })
cancel()
wg.Wait()
}Run:
go run no_leak.goOutput:
abandoned: context canceled
Nobody received. That is fine: the worker gave up because the context said the ticket is no longer wanted. The goroutine returned. Wait ended. No leak.
If main still wanted the value, the fix would be to receive: fmt.Println(<-ch) and skip cancel. Stopping and delivering are different designs. Pick one.
Case 4: Bound the work
Save as bound.go. At most two printers run at once. Extra tickets wait on a semaphore channel.
// bound.go
package main
import (
"fmt"
"sync"
)
func main() {
tickets := []int{7, 8, 9, 10}
sem := make(chan struct{}, 2)
var wg sync.WaitGroup
for _, id := range tickets {
sem <- struct{}{}
wg.Go(func() {
defer func() { <-sem }()
fmt.Println("printed", id)
})
}
wg.Wait()
fmt.Println("done")
}Run:
go run bound.goOutput (print order among the four tickets may vary; done is last):
printed 8
printed 9
printed 10
printed 7
done
sem has capacity 2. The third sem <- struct{}{} waits until a printer receives from sem in defer. You can name the cap (const maxPrinters = 2). You cannot name “one goroutine per incoming request forever.”
To make the listing stable for tests, collect ids and sort:
// bound_sorted.go
package main
import (
"fmt"
"slices"
"sync"
)
func main() {
tickets := []int{7, 8, 9, 10}
sem := make(chan struct{}, 2)
got := make([]int, len(tickets))
var wg sync.WaitGroup
for i, id := range tickets {
sem <- struct{}{}
wg.Go(func() {
defer func() { <-sem }()
got[i] = id
})
}
wg.Wait()
slices.Sort(got)
fmt.Println(got)
fmt.Println("done")
}Run:
go run bound_sorted.goOutput:
[7 8 9 10]
done
Same bound, stable print.
The trap
Save as per_ticket.go. A goroutine per ticket, no cap, no stop, no wait except WaitGroup. The WaitGroup prevents a process leak on this tiny list. The design still has no bound and no cancel. Replace tickets with a million ids from a live queue and you have a million goroutines.
// per_ticket.go
package main
import (
"fmt"
"sync"
)
func main() {
tickets := []int{7, 8, 9}
var wg sync.WaitGroup
for _, id := range tickets {
wg.Go(func() {
fmt.Println("printed", id)
})
}
wg.Wait()
}Run:
go run per_ticket.goPossible output (order varies):
printed 7
printed 8
printed 9
It “works” for three. That is the trap. The sequential program (Case 1) was clearer. The bounded program (Case 4) is what you graduate to when printing is actually slow. The leaked program (Case 2) is what you get when you add go and forget the receive.
Fix on a live desk:
- cap with a semaphore or a fixed worker pool
- pass
ctxandselectonDone Wait(or return errors on a channel) before the handler returns
The boring rule
- Sequential first. Concurrent when a clock or a profiler says so.
- Do not start a goroutine you cannot stop.
- Every
gohas a matching wait (WaitGroupor a receive) on the path that cares. - Bound in-flight work with a number you can explain.
- Close or cancel from the owner. Do not leak senders or receivers.
- Never use
time.Sleepas the stop policy.
Try this
- In
leak.go, addfmt.Println(<-ch)inmainand delete the sleep. Confirmsentand7print. - In
no_leak.go, receive fromchinmaininstead of callingcancel(). Confirmsentprints andWaitstill returns. - In
bound.go, set the semaphore capacity to 1. The program still finishes; it is just a queue of one printer. - Add a
context.WithCanceltobound_sorted.go. Cancel after starting. Without aselectonctx.Done(), workers still run. Add theselect(or a check at the start of the goroutine) so cancel actually stops new prints.