Go Concurrency: Goroutines & Lifetime
"When you spawn goroutines, make it clear when or whether they exit. Goroutines can leak by blocking on channel sends or receives: the garbage collector will not terminate a goroutine blocked on a channel even if no other goroutine has a reference to the channel." — Google Go Style Guide — Decisions
"Goroutines are not garbage collected; they must exit on their own." — Go Blog: Pipelines and cancellation
A go statement is the cheapest thing to write and the easiest thing to leak. The runtime will not clean up after you: a goroutine that blocks forever sits in memory forever, holding everything it captured. This skill owns goroutine lifetime and ownership — when one starts, how it is guaranteed to stop, and how you wait for it; it is the depth behind go-idiomatic-discipline's axis-1 leak. The mechanics route out: cancellation to go-context, channel signaling to go-channels-select, WaitGroup/Mutex internals and shared-state locking to go-sync-primitives, race-safety to go-race-and-memory-model.
1. The Rule: Never Start a Goroutine You Can't Stop
Every goroutine you launch needs a defined exit decided at the moment you write go: it returns after bounded work, it observes a cancelled context, or it ranges over a channel that someone will close. "When you spawn goroutines, make it clear when or whether they exit" (Google Style Guide — Decisions). If you cannot point to the line that makes a goroutine return, you have written a leak.
// WRONG — no exit: this goroutine blocks on recv forever if nobody ever sends,
// and there is no way to tell it to stop
go func() {
for job := range jobs { // leaks if `jobs` is never closed
process(job)
}
}()
// RIGHT — a context gives every goroutine a guaranteed exit
go func() {
for {
select {
case <-ctx.Done(): // defined exit
return
case job := <-jobs:
process(job)
}
}
}()
"Concurrent code should be written such that the goroutine lifetimes are obvious" (Google Style Guide — Decisions). How the context is derived and cancelled belongs to go-context; this skill owns the rule that the exit must exist.
2. Goroutine Leaks: The #1 Concurrency Bug
A leak is a goroutine blocked forever on a channel send or receive that no one will service. It is invisible — the program compiles, the happy path runs, and only under load does memory climb. "This is a resource leak: goroutines consume memory and runtime resources, and heap references in goroutine stacks keep data from being garbage collected" (Pipelines). The classic shape is a sender that outlives its receiver: "if a stage fails to consume all the inbound values, the goroutines attempting to send those values will block indefinitely" (Pipelines).
// WRONG — the goroutine sends one value; if the caller returns early after the
// first result (timeout, error), the send blocks forever and the goroutine leaks
func search(queries []string) <-chan Result {
out := make(chan Result) // unbuffered
for _, q := range queries {
go func() { out <- run(q) }() // blocks until someone receives
}
return out
}
// RIGHT — a cancellable context lets a blocked sender abandon the channel
func search(ctx context.Context, queries []string) <-chan Result {
out := make(chan Result)
for _, q := range queries {
go func() {
select {
case out <- run(q):
case <-ctx.Done(): // sender can give up instead of blocking forever
}
}()
}
return out
}
The blog names the two fixes this skill teaches: give the sender enough buffer for every value, or "explicitly signal senders when the receiver may abandon the channel" (Pipelines) — e.g. closing a done channel, a broadcast because "a receive operation on a closed channel can always proceed immediately" (Pipelines). Channel-close mechanics route to go-channels-select.
3. WaitGroup: Add Before the go, or Use wg.Go (1.25)
To wait for a known set of goroutines, use sync.WaitGroup. The one rule that matters for correctness: Add must run before the goroutine starts, never inside it. "Calls with a positive delta that occur when the counter is zero must happen before a Wait ... Typically this means the calls to Add should execute before the statement creating the goroutine" (pkg.go.dev/sync). Add inside the goroutine races with Wait — Wait may observe a zero counter and return before the goroutine has even registered. Go 1.25's go vet ships a waitgroup analyzer that "reports misplaced calls to sync.WaitGroup.Add" (Go 1.25 release notes).
// WRONG — Add inside the goroutine: Wait can return before they register
var wg sync.WaitGroup
for _, t := range tasks {
go func() {
wg.Add(1) // RACE: caught by `go vet` waitgroup analyzer (1.25)
defer wg.Done()
t.run()
}()
}
wg.Wait()
The pre-1.25 fix is to move wg.Add(1) directly above the go statement and defer wg.Done() inside it. Go 1.25 removes the footgun entirely with wg.Go, which "calls f in a new goroutine and adds that task to the WaitGroup" (pkg.go.dev/sync) — it does the Add/Done for you, so the misplaced-Add bug becomes unwritable. "Callers should prefer WaitGroup.Go" (pkg.go.dev/sync):
// RIGHT (1.25+) — wg.Go does Add/Done; nothing to misplace
var wg sync.WaitGroup
for _, t := range tasks {
wg.Go(t.run)
}
wg.Wait()
A WaitGroup "must not be copied after first use" (pkg.go.dev/sync) — pass it by pointer. The copy-a-lock rule and the vet copylocks check are owned by go-sync-primitives.
4. errgroup: Fallible Fan-Out and Bounded Pools
sync.WaitGroup waits but carries no error. When the goroutines can fail, use golang.org/x/sync/errgroup, which "provides synchronization, error propagation, and Context cancellation for groups of goroutines working on subtasks of a common task" (pkg.go.dev/errgroup). With errgroup.WithContext, "the first goroutine in the group that returns a non-nil error will cancel the associated Context ... The error will be returned by Wait" (pkg.go.dev/errgroup). Workers must select on that context to actually stop on the first failure.
// RIGHT — first error cancels ctx; siblings that honor ctx.Done() stop early
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
g.Go(func() error {
return fetch(ctx, url) // returns ctx.Err() promptly when cancelled
})
}
if err := g.Wait(); err != nil { // the first non-nil error
return fmt.Errorf("fetching: %w", err)
}
For a worker pool, SetLimit caps concurrency: it "limits the number of active goroutines in this group to at most n" (pkg.go.dev/errgroup). A subsequent g.Go "blocks until it can add an active goroutine without exceeding the configured limit" — so a million-item loop runs at most N at a time instead of spawning a million goroutines:
// RIGHT — bounded fan-out: at most 8 goroutines alive at once
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range millionItems {
g.Go(func() error { return handle(ctx, item) })
}
return g.Wait()
A zero Group "is valid, has no limit ... and does not cancel on error" (pkg.go.dev/errgroup) — use WithContext for first-error cancellation. Always check g.Wait()'s result; ignoring it throws away every failure (see references/common-mistakes.md).
5. Prefer Synchronous Functions — Don't Spawn in a Library
A library function should do its work and return; it should not start background goroutines the caller cannot see or stop. "Prefer synchronous functions ... over asynchronous ones. Synchronous functions keep goroutines localized within a call, making it easier to reason about their lifetimes and avoid leaks and data races" (CodeReviewComments — Synchronous Functions). Concurrency is the caller's decision: "If callers need more concurrency, they can add it easily by calling the function from a separate goroutine. But it is quite difficult — sometimes impossible — to remove unnecessary concurrency at the caller side" (CodeReviewComments).
// WRONG — the library spawns a goroutine the caller can't stop or wait for
func (c *Client) Fetch(url string) {
go func() { c.results <- c.get(url) }() // who stops this? who waits?
}
// RIGHT — synchronous: the caller decides whether to run it concurrently
// (e.g. g.Go(func() error { r, err := c.Fetch(ctx, url); ...; return err }))
func (c *Client) Fetch(ctx context.Context, url string) (Result, error) {
return c.get(ctx, url)
}
If a library genuinely must run background work, it owns that lifetime explicitly: take a context, expose a Close/Shutdown that blocks until the goroutine exits, and document it.
6. The Loop Variable Is Per-Iteration Since Go 1.22
The classic capture bug — every goroutine seeing the last loop value — is fixed for modules on go 1.22 or later: "each iteration of the loop creates new variables, to avoid accidental sharing bugs" (Go 1.22 release notes). Whether it is safe depends entirely on the module's go directive (go-version-feature-map owns that gate).
// go 1.22+: each iteration has its own v — capturing it directly is now SAFE
for _, v := range items {
go func() { process(v) }()
}
// Pre-1.22 (module declares go 1.21 or lower): all goroutines share one v and
// likely see the final element. Editing such code still needs a `v := v` copy
// inside the loop before the `go`.
7. A Panic in a Goroutine Crashes the Whole Process
An unrecovered panic in any goroutine terminates the entire program — a recover in the goroutine that spawned it does nothing, because recover only catches panics on its own stack. This is why wg.Go's contract is blunt: "The function f must not panic" (pkg.go.dev/sync). A goroutine that runs caller-supplied or fallible work must recover at its own top frame and turn the panic into an error.
// RIGHT — recover at the goroutine boundary so one bad task can't kill the process
g.Go(func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("task panicked: %v", r)
}
}()
return riskyWork()
})
The defer/panic/recover mechanics — recover only inside a deferred func, only on the same stack — are owned by go-defer-panic-recover.
8. Don't Communicate by Sharing Memory
Go's headline slogan picks the default tool: "Do not communicate by sharing memory; instead, share memory by communicating" (Effective Go); "Don't communicate by sharing memory, share memory by communicating" (Go Proverbs). Pass ownership of a value down a channel rather than guard a shared variable with a lock — but it is a default, not an absolute: "Channels orchestrate; mutexes serialize" (Go Proverbs). Channel to hand off work and signal completion; Mutex when goroutines truly share one piece of state. Channel mechanics route to go-channels-select; the mutex side to go-sync-primitives.
9. The Headline Disciplines
| Discipline | The rule | Source |
|---|---|---|
| Defined exit | Never start a goroutine without a guaranteed stop | "make it clear when or whether they exit" (Google Decisions) |
| Leaks are silent | A goroutine blocked on a channel is never GC'd | "Goroutines are not garbage collected; they must exit on their own" (Pipelines) |
| Add before go | WaitGroup.Add before the go, never inside it |
"calls to Add should execute before the statement creating the goroutine" (sync) |
| Prefer wg.Go (1.25) | Let wg.Go do Add/Done |
"Callers should prefer WaitGroup.Go" (sync) |
| errgroup for failure | First error cancels ctx and is returned by Wait | "The first goroutine ... that returns a non-nil error will cancel the associated Context" (errgroup) |
| Bound the pool | SetLimit(n) instead of unbounded goroutine-per-item |
"limits the number of active goroutines ... to at most n" (errgroup) |
| Synchronous libraries | Don't spawn in a library; let the caller add concurrency | "it is quite difficult — sometimes impossible — to remove unnecessary concurrency at the caller side" (CodeReviewComments) |
| Share by communicating | Channel to hand off; mutex to serialize shared state | "share memory by communicating" (Proverbs) |
10. Who Suffers When This Is Done Badly
The cost of a leaked or unstoppable goroutine never lands at write time:
- The on-call engineer paged at 3am by a service whose memory climbs until the OOM killer fires — the root cause a
go func(){ out <- result }()whose receiver returned early on a timeout, leaving thousands of senders blocked forever. The happy path passed every test. - The teammate who adds a feature to a library and discovers it silently spawns background goroutines with no
Close, nocontext, no way to wait — so their own tests flake and their shutdown never completes. - The next reader of an unbounded
for _, item := range millionItems { go work(item) }, explaining to the customer why the box fell over: a million goroutines at once, instead of aSetLimit(N)pool.
"Goroutines can leak by blocking on channel sends or receives" (Google Decisions) is an operational warning, not a style nit. Every goroutine you start is a promise to stop it.
11. Routing to Related Skills
go-idiomatic-discipline— the policy root; the leak is its axis-1 "never start a goroutine you can't stop."go-context— how a goroutine is cancelled: deriving a context,defer cancel(), propagatingctxto workers,ctx.Err()in loops.go-channels-select— channel signaling mechanics: who closes,chan struct{}done signals, nil-channel disable,select/default, buffered-vs-unbuffered.go-sync-primitives—WaitGroup/Mutexinternals, never-copy-a-lock (copylocks), and locking shared state when a channel isn't the right tool.go-race-and-memory-model— whether the shared access in a goroutine is actually safe;go test -race; happens-before.go-defer-panic-recover— recovering a panic at a goroutine boundary so one task can't crash the process.go-version-feature-map— which idiom the module'sgodirective allows: loop-var-per-iteration (1.22),wg.Goand thewaitgroupvet analyzer (1.25).
12. Reference Files
High-frequency goroutine-lifetime anti-patterns in LLM-generated Go, each with wrong/right code and citations:
references/common-mistakes.md
Source provenance for every claim in this skill:
references/sources.yaml