| name | golang-concurrency |
| description | 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.
|
| user-invocable | false |
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.
func FetchAll(urls []string) []Result {
for _, u := range urls {
go fetch(u)
}
}
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:
}
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 skill-optimizer/release-gates.md.