Go Concurrency
"Don't communicate by sharing memory; share memory by communicating."
Concurrency is not parallelism. Goroutines structure concurrent code; the runtime decides parallelism.
Reviewing concurrent Go — check every time
Before approving any goroutine-spawning code, confirm all of these:
- Exit path — every goroutine can stop (ctx cancel, done channel, or input channel closing). No exit path = leak.
- Cancellation — a slow/hung call is bounded by
context (ctx.Done() / WithTimeout). Without it, one stuck call leaks the goroutine and can block its collector forever.
- Bounded fan-out — never one goroutine per item unbounded. Use a worker pool with fixed concurrency under load.
- Channel coupling — an unbuffered channel blocks the sender until a receiver is ready; buffer it or use a pool when you don't want that coupling.
- Errors — propagate goroutine errors and cancel siblings with
errgroup.Group; don't silently drop them.
- Loop-variable capture — pre-Go 1.22, a goroutine closing over a
range variable captures the shared var. See Closures for the pin fix and Go 1.22+ per-iteration scoping.
- No shared mutable state without a
sync.Mutex/atomic — and prove it with go test -race ./....
Channel vs Mutex Decision Table
| Need |
Tool |
Why |
| Transfer ownership of data |
Channel |
Data flows, no shared state |
| Protect shared state (cache, counter) |
sync.Mutex |
Simpler than channel for guarding |
| Wait for N goroutines to finish |
sync.WaitGroup |
Counting semaphore |
| One-time initialization |
sync.Once |
Thread-safe lazy init |
| Cancellation / deadline propagation |
context.Context |
Hierarchical cancellation |
| Collect errors from goroutines |
errgroup.Group |
WaitGroup + first error capture |
| Rate limiting |
time.Ticker + channel |
Ticker feeds a channel at fixed intervals |
Goroutine Lifecycle Rules
Always know how a goroutine ends. Every goroutine must have an exit path — a done channel, context cancellation, or input channel closing.
Never fire and forget. If you can't answer "how does this goroutine stop?", you have a leak.
The caller decides concurrency. Don't start goroutines inside library functions — let the caller choose.
// Bad: library starts goroutine — caller can't control lifecycle
func FetchAll(urls []string) []Result {
for _, u := range urls {
go fetch(u) // who waits? who cancels?
}
}
// Good: caller controls concurrency
func Fetch(ctx context.Context, url string) (Result, error) { ... }
Channel Direction Annotations
Annotate direction in function signatures for compile-time safety: chan<- T is send-only, <-chan T is receive-only, plain chan T is bidirectional. The producer owns and closes the channel; consumers range over it.
Buffered vs Unbuffered
| Type |
Behavior |
Use when |
Unbuffered make(chan T) |
Send blocks until receiver is ready |
Synchronization — both sides must be present |
Buffered make(chan T, n) |
Send blocks only when buffer is full |
Decoupling producer/consumer speeds, known bound |
Default to unbuffered. Add a buffer only when you can justify the size.
Select Statement
Multiplexes across multiple channel operations:
select {
case msg := <-msgCh:
handle(msg)
case err := <-errCh:
log.Error(err)
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
return errors.New("timeout")
}
select with default makes a non-blocking check:
select {
case msg := <-ch:
handle(msg)
default:
// ch not ready, do something else
}
Anti-patterns
| Anti-pattern |
Problem |
Fix |
| Goroutine leak (no exit path) |
Memory/CPU grows forever |
Use ctx.Done(), done channel, or close input channel |
| Unbounded goroutine creation |
OOM under load |
Use worker pool with bounded concurrency |
| Channel as mutex |
Overcomplicates simple state protection |
Use sync.Mutex for shared state |
| Ignoring context cancellation |
Goroutine runs long after caller gave up |
Check ctx.Done() in loops and before expensive ops |
Missing -race in tests |
Data races go undetected |
Always run go test -race ./... in CI |
Read On Demand
| Read When |
File |
| Goroutine lifecycle, channel patterns, fan-in/fan-out, pipeline, worker pool |
Goroutines & Channels |
| sync.Mutex, WaitGroup, Once, sync.Map, context, errgroup, -race flag |
Sync & Context |
Benchmark
Scenario: .benchmarks/scenarios/golang-concurrency-001-goroutine-leak.md
| Model |
Without |
With |
Delta |
| claude-opus-4-8 |
78% |
94% |
+16% |
| claude-sonnet-4-6 |
61% |
72% |
+11% |
| claude-haiku-4-5 |
50% |
78% |
+28% |
PASS (run 2026-06-25, N=3 averages). First pass was NEUTRAL (zero lift) — the leak/race/errgroup guidance was buried in tables. After front-loading the "Reviewing concurrent Go — check every time" checklist, the skill lifts every model — haiku 50→78 (+28). Imperative top-level checklist > buried table for salience (per activation-design). Gate per .agents/skills/skill-optimizer/rules/release-gates.md.
1---2name: concurrency3description: Go concurrency — goroutines, channels, select, sync primitives, context, and data race prevention. TRIGGER when: user asks about goroutines, Go channels, Go select, sync.Mutex, sync.WaitGroup, sync.Once, Go context, context.WithCancel, context.WithTimeout, Go data race, race condition in Go, Go concurrency patterns, fan-in fan-out, worker pool, Go channel direction, buffered channel, Go deadlock, -race flag, errgroup, Go concurrent map, Go goroutine leak. DO NOT USE when: user needs general concurrency theory unrelated to Go, or is asking about goroutines only as background context with no code to write or review.4---56# Go Concurrency78"Don't communicate by sharing memory; share memory by communicating."910Concurrency is not parallelism. Goroutines structure concurrent code; the runtime decides parallelism.1112---1314## Reviewing concurrent Go — check every time1516Before approving any goroutine-spawning code, confirm all of these:17181. **Exit path** — every goroutine can stop (ctx cancel, done channel, or input channel closing). No exit path = leak.192. **Cancellation** — a slow/hung call is bounded by `context` (`ctx.Done()` / `WithTimeout`). Without it, one stuck call leaks the goroutine and can block its collector forever.203. **Bounded fan-out** — never one goroutine per item unbounded. Use a **worker pool** with fixed concurrency under load.214. **Channel coupling** — an **unbuffered** channel blocks the sender until a receiver is ready; buffer it or use a pool when you don't want that coupling.225. **Errors** — propagate goroutine errors and cancel siblings with **`errgroup.Group`**; don't silently drop them.236. **Loop-variable capture** — pre-Go 1.22, a goroutine closing over a `range` variable captures the _shared_ var. See [Closures](../references/functions-methods-pointers.md#closures) for the pin fix and Go 1.22+ per-iteration scoping.247. **No shared mutable state** without a `sync.Mutex`/atomic — and prove it with **`go test -race ./...`**.2526---2728## Channel vs Mutex Decision Table2930| Need | Tool | Why |31| ------------------------------------- | ----------------------- | ----------------------------------------- |32| Transfer ownership of data | Channel | Data flows, no shared state |33| Protect shared state (cache, counter) | `sync.Mutex` | Simpler than channel for guarding |34| Wait for N goroutines to finish | `sync.WaitGroup` | Counting semaphore |35| One-time initialization | `sync.Once` | Thread-safe lazy init |36| Cancellation / deadline propagation | `context.Context` | Hierarchical cancellation |37| Collect errors from goroutines | `errgroup.Group` | WaitGroup + first error capture |38| Rate limiting | `time.Ticker` + channel | Ticker feeds a channel at fixed intervals |3940---4142## Goroutine Lifecycle Rules43441. **Always know how a goroutine ends.** Every goroutine must have an exit path — a done channel, context cancellation, or input channel closing.45462. **Never fire and forget.** If you can't answer "how does this goroutine stop?", you have a leak.47483. **The caller decides concurrency.** Don't start goroutines inside library functions — let the caller choose.4950```go51// Bad: library starts goroutine — caller can't control lifecycle52func FetchAll(urls []string) []Result {53 for _, u := range urls {54 go fetch(u) // who waits? who cancels?55 }56}5758// Good: caller controls concurrency59func Fetch(ctx context.Context, url string) (Result, error) { ... }60```6162---6364## Channel Direction Annotations6566Annotate direction in function signatures for compile-time safety: `chan<- T` is send-only, `<-chan T` is receive-only, plain `chan T` is bidirectional. The producer owns and `close`s the channel; consumers `range` over it.6768---6970## Buffered vs Unbuffered7172| Type | Behavior | Use when |73| -------------------------- | ------------------------------------ | ------------------------------------------------ |74| Unbuffered `make(chan T)` | Send blocks until receiver is ready | Synchronization — both sides must be present |75| Buffered `make(chan T, n)` | Send blocks only when buffer is full | Decoupling producer/consumer speeds, known bound |7677**Default to unbuffered.** Add a buffer only when you can justify the size.7879---8081## Select Statement8283Multiplexes across multiple channel operations:8485```go86select {87case msg := <-msgCh:88 handle(msg)89case err := <-errCh:90 log.Error(err)91case <-ctx.Done():92 return ctx.Err()93case <-time.After(5 * time.Second):94 return errors.New("timeout")95}96```9798`select` with `default` makes a non-blocking check:99100```go101select {102case msg := <-ch:103 handle(msg)104default:105 // ch not ready, do something else106}107```108109---110111## Anti-patterns112113| Anti-pattern | Problem | Fix |114| ----------------------------- | ---------------------------------------- | ------------------------------------------------------ |115| Goroutine leak (no exit path) | Memory/CPU grows forever | Use `ctx.Done()`, done channel, or close input channel |116| Unbounded goroutine creation | OOM under load | Use worker pool with bounded concurrency |117| Channel as mutex | Overcomplicates simple state protection | Use `sync.Mutex` for shared state |118| Ignoring context cancellation | Goroutine runs long after caller gave up | Check `ctx.Done()` in loops and before expensive ops |119| Missing `-race` in tests | Data races go undetected | Always run `go test -race ./...` in CI |120121---122123## Read On Demand124125| Read When | File |126| ---------------------------------------------------------------------------- | ---------------------------------------------------------- |127| Goroutine lifecycle, channel patterns, fan-in/fan-out, pipeline, worker pool | [Goroutines & Channels](references/goroutines-channels.md) |128| sync.Mutex, WaitGroup, Once, sync.Map, context, errgroup, -race flag | [Sync & Context](references/sync-context.md) |129130---131132## Benchmark133134Scenario: `.benchmarks/scenarios/golang-concurrency-001-goroutine-leak.md`135136| Model | Without | With | Delta |137| ----------------- | ------- | ---- | ----- |138| claude-opus-4-8 | 78% | 94% | +16% |139| claude-sonnet-4-6 | 61% | 72% | +11% |140| claude-haiku-4-5 | 50% | 78% | +28% |141142> **PASS** (run 2026-06-25, N=3 averages). First pass was NEUTRAL (zero lift) — the leak/race/errgroup guidance was buried in tables. After front-loading the **"Reviewing concurrent Go — check every time"** checklist, the skill lifts every model — haiku 50→78 (+28). Imperative top-level checklist > buried table for salience (per `activation-design`). Gate per `.agents/skills/skill-optimizer/rules/release-gates.md`.