# Go Concurrency Goroutines

> Guides goroutine lifetime and ownership — never start a goroutine you can't stop, give every goroutine a defined exit (context cancellation, a closed channel, or bounded work), avoid the blocked-forever leak, use sync.WaitGroup correctly (Add before the go, or wg.Go in 1.25) and golang.org/x/sync/errgroup for fallible fan-out and SetLimit worker pools, and keep library functions synchronous so the caller owns concurrency. Auto-invokes when writing or editing `go` statements, goroutines, sync.WaitGroup, errgroup, worker pools, or on "is this goroutine leaking" / "how do I wait for these" requests. The depth behind the policy root's "never start a goroutine you can't stop."

- Skill: `ctoth/go-concurrency-goroutines` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-concurrency-goroutines`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-concurrency-goroutines/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-concurrency-goroutines

---


# 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](https://google.github.io/styleguide/go/decisions#goroutine-lifetimes)

> "Goroutines are not garbage collected; they must exit on their own."
> — [Go Blog: Pipelines and cancellation](https://go.dev/blog/pipelines)

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](https://google.github.io/styleguide/go/decisions#goroutine-lifetimes)). If you cannot point to the line that makes a goroutine return, you have written a leak.

```go
// 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](https://google.github.io/styleguide/go/decisions#goroutine-lifetimes)). *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](https://go.dev/blog/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](https://go.dev/blog/pipelines)).

```go
// 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](https://go.dev/blog/pipelines)) — e.g. closing a `done` channel, a broadcast because "a receive operation on a closed channel can always proceed immediately" ([Pipelines](https://go.dev/blog/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](https://pkg.go.dev/sync#WaitGroup.Add)). `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](https://go.dev/doc/go1.25)).

```go
// 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](https://pkg.go.dev/sync#WaitGroup.Go)) — it does the `Add`/`Done` for you, so the misplaced-`Add` bug becomes unwritable. "Callers should prefer `WaitGroup.Go`" ([pkg.go.dev/sync](https://pkg.go.dev/sync#WaitGroup.Add)):

```go
// 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](https://pkg.go.dev/sync#WaitGroup)) — 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](https://pkg.go.dev/golang.org/x/sync/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](https://pkg.go.dev/golang.org/x/sync/errgroup)). Workers must select on that context to actually stop on the first failure.

```go
// 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](https://pkg.go.dev/golang.org/x/sync/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:

```go
// 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](https://pkg.go.dev/golang.org/x/sync/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](https://go.dev/wiki/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](https://go.dev/wiki/CodeReviewComments#synchronous-functions)).

```go
// 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](https://go.dev/doc/go1.22)). Whether it is safe depends entirely on the module's `go` directive (`go-version-feature-map` owns that gate).

```go
// 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](https://pkg.go.dev/sync#WaitGroup.Go)). A goroutine that runs caller-supplied or fallible work must recover at its own top frame and turn the panic into an error.

```go
// 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](https://go.dev/doc/effective_go#sharing)); "Don't communicate by sharing memory, share memory by communicating" ([Go Proverbs](https://go-proverbs.github.io/)). 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](https://go-proverbs.github.io/)). 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](https://google.github.io/styleguide/go/decisions#goroutine-lifetimes)) |
| 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](https://go.dev/blog/pipelines)) |
| Add before go | `WaitGroup.Add` before the `go`, never inside it | "calls to Add should execute before the statement creating the goroutine" ([sync](https://pkg.go.dev/sync#WaitGroup.Add)) |
| Prefer wg.Go (1.25) | Let `wg.Go` do Add/Done | "Callers should prefer `WaitGroup.Go`" ([sync](https://pkg.go.dev/sync#WaitGroup.Add)) |
| 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](https://pkg.go.dev/golang.org/x/sync/errgroup)) |
| Bound the pool | `SetLimit(n)` instead of unbounded goroutine-per-item | "limits the number of active goroutines ... to at most n" ([errgroup](https://pkg.go.dev/golang.org/x/sync/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](https://go.dev/wiki/CodeReviewComments#synchronous-functions)) |
| Share by communicating | Channel to hand off; mutex to serialize shared state | "share memory by communicating" ([Proverbs](https://go-proverbs.github.io/)) |

---

## 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`, no `context`, 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 a `SetLimit(N)` pool.

"Goroutines can leak by blocking on channel sends or receives" ([Google Decisions](https://google.github.io/styleguide/go/decisions#goroutine-lifetimes)) 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()`, propagating `ctx` to 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`/`Mutex` internals, 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's `go` directive allows: loop-var-per-iteration (1.22), `wg.Go` and the `waitgroup` vet 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](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

