Never Launch a Goroutine Without an Owner
The invisible leak in AI-generated Go services is a goroutine launched with no owner. AI assistants love the go keyword — it's one word, it compiles, and the demo works. But goroutines are not garbage collected. The Go team's own pipelines article is blunt: "goroutines consume memory and runtime resources, and heap references in goroutine stacks keep data from being garbage collected. Goroutines are not garbage collected; they must exit on their own." A goroutine with no cancellation signal and no bounded worker model never exits. It leaks quietly — a few KB of stack each, plus everything its stack references — until your service falls over.
Why AI-generated Go leaks
Generated code optimizes for "compiles and passes the happy path." The classic pattern looks harmless:
go func() {
for msg := range ch {
process(msg)
}
}()
Who closes ch? Who waits for this goroutine on shutdown? What stops it if the caller's request is cancelled? If you can't answer all three by reading the launch site, you have a leak. Under load, blocked senders pile up behind a receiver that stopped reading, and each one pins its message in memory forever.
The ownership contract
Every goroutine needs an owner whose lifetime is visible where it's launched. Three mechanisms cover it:
| Concern | Mechanism | Rule of thumb |
|---|---|---|
| How does it stop? | context.Context cancellation | Pass ctx in; select on ctx.Done() in every blocking loop |
| Who closes the channel? | Sender-side close | Only the goroutine that sends on a channel closes it — never the receiver |
| Who waits for it? | sync.WaitGroup | wg.Add(1) before go, defer wg.Done() inside, wg.Wait() on shutdown |
Put together, an owned goroutine reads like this:
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
process(msg)
}
}
}()
Cancellation, channel ownership, and cleanup are all obvious at the launch site — which is exactly what makes generated code reviewable.
Red flags to hunt in a review
- Channels closed by receivers. Closing a channel is a sender's job; a receiver closing it makes the next send panic. If a receiver "cleans up" a channel, ownership is inverted.
- Shared channels with no ownership rules. A channel passed to three goroutines where any of them might close it, or none does, will either panic or leak.
- Bare
gowith noctxand noWaitGroup. Fire-and-forget is a leak with a delay. The Uber Go style guide names this directly: don't fire-and-forget goroutines, wait for goroutines to exit, and never spawn goroutines ininit(). panicfor normal errors. Generated code sometimes reaches forpanicon a failed lookup or bad input. Panics belong to truly unrecoverable states; normal failures return anerrorso the owner can decide. A panic inside an unowned goroutine crashes the whole process — there is no recovery at a distance.
Power-user moves
- Bound your workers. Instead of one goroutine per item, launch a fixed pool of N workers reading from one channel, all owned by one WaitGroup. Memory becomes O(N), not O(traffic).
- Prompt for ownership up front. Tell your assistant: "Every goroutine must select on ctx.Done(), only senders close channels, and shutdown must wg.Wait()." Generated code follows the contract you state.
- Test the leak. Compare
runtime.NumGoroutine()before and after a load test, or usegoleak(Uber's leak detector) in tests — it fails any test that leaves goroutines behind. - Watch it in production. The
/debug/pprof/goroutineprofile shows every live goroutine's stack. A count that only ever climbs is your smoking gun.
Resources
- Go Concurrency Patterns: Pipelines and cancellation — The Go Blog
- context package — Go standard library
- sync.WaitGroup — Go standard library
- Don't fire-and-forget goroutines — Uber Go Style Guide
- Don't Panic — Uber Go Style Guide
- goleak — goroutine leak detector
Building an AI feature? Yeda AI designs, audits, and ships production LLM systems.