| name | go-concurrency |
| description | Disciplined Go concurrency. ALWAYS use this skill when writing or reviewing Go code that touches goroutines, channels, `context.Context`, `sync.WaitGroup`, `sync.Mutex` / `sync.RWMutex`, `sync.Once`, `errgroup.Group`, worker pools, fan-out/fan-in pipelines, request cancellation, timeouts, deadlines, or any "run this in the background" pattern. Also use when debugging deadlocks, goroutine leaks, race conditions, or channel-related bugs. Pair with go-errors for error propagation across goroutines. |
| when_to_use | TRIGGER WHEN the user touches Go concurrency — `go func()`, channels (buffered / unbuffered / directional), `context.Context` for cancellation/timeouts/deadlines, `sync.WaitGroup`, `sync.Mutex`, `sync.RWMutex`, `sync.Once`, `sync.Pool`, `errgroup.Group`, worker pools, fan-out / fan-in pipelines, semaphores, rate limiting, pipelines that "run in the background". ALSO TRIGGER on debugging scenarios: goroutine leaks, deadlocks, channel send on closed, race-detector hits (`go test -race`), data races, "why is my program hanging?". ALSO TRIGGER on indirect phrasings: "run this in parallel", "process N items concurrently", "limit concurrency to N", "cancel this if it takes too long". SKIP when the task is purely sequential. |
| version | 1.1.0 |
| tags | ["go","golang","concurrency","goroutines","channels","context","errgroup"] |
| paths | ["**/*.go"] |
Go Concurrency
Goroutines are cheap; goroutine lifecycle is the hard part. Every
goroutine you start owns resources (memory, locks, file descriptors) until
it exits.
For the comprehensive reference, see references/concurrency.md.
The single most important rule
Never start a goroutine without knowing when it will stop.
go func() {
for {
process(<-workChan)
}
}()
go func() {
for {
select {
case <-ctx.Done():
return
case work := <-workChan:
process(work)
}
}
}()
If you can't answer "when does this goroutine exit?", don't write it.
Context
context.Context is the first parameter on every function that may
block, do I/O, or call something that does.
- Never store a context in a struct.
- Cancel contexts you create:
defer cancel().
- Pass
ctx through; don't replace it with context.Background()
partway down the call stack.
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := db.QueryRowContext(ctx, q, id).Scan(&u); err != nil {
return fmt.Errorf("load user: %w", err)
}
Leave concurrency to the caller
Library functions should be synchronous. Let the caller decide whether
to launch a goroutine.
func ListFiles(dir string) <-chan string { ... }
func ListFiles(dir string, fn func(string) error) error {
return filepath.Walk(dir, func(p string, _ os.FileInfo, err error) error {
if err != nil {
return err
}
return fn(p)
})
}
WaitGroup for fan-out
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(item Item) {
defer wg.Done()
process(item)
}(item)
}
wg.Wait()
errgroup for fan-out with errors
When goroutines can fail, golang.org/x/sync/errgroup handles
cancellation and the first-error collection:
g, ctx := errgroup.WithContext(ctx)
for _, item := range items {
item := item
g.Go(func() error {
return process(ctx, item)
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("processing batch: %w", err)
}
The shared ctx cancels as soon as one goroutine returns an error, so
the others can short-circuit.
Channels
- Buffer size 0 or 1, anything larger needs justification — large
buffers mask synchronization bugs.
- The sender closes the channel; never the receiver.
- Closing a closed channel panics. So does sending on a closed channel.
- Receiving from a closed channel returns the zero value immediately.
ch := make(chan Result)
go func() {
defer close(ch)
for _, x := range inputs {
select {
case ch <- compute(x):
case <-ctx.Done():
return
}
}
}()
for r := range ch { ... }
Mutex patterns
- Keep critical sections small. Compute outside the lock, write inside.
sync.RWMutex only when reads dominate (10:1 or more); otherwise the
write-lock fairness penalty negates the benefit.
- Don't copy a struct that contains a
sync.Mutex (the linter copylocks
catches this).
type Cache struct {
mu sync.RWMutex
items map[string]Item
}
func (c *Cache) Get(k string) (Item, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
i, ok := c.items[k]
return i, ok
}
Common bugs
- Loop variable capture — pass to the goroutine as an argument or
shadow with
item := item before go func().
- Forgetting
defer cancel() — leaks the context's resources.
- Map writes from multiple goroutines — Go's runtime detects and
panics. Use a mutex or
sync.Map.
time.After in a loop — leaks a timer per iteration; use
time.NewTimer and Reset.
When to load a sibling skill
| Task | Skill |
|---|
| Returning errors out of goroutines, wrapping with context | go-errors |
| Logging from concurrent code with request-scoped attrs | go-logging |
| Per-request cancellation in HTTP handlers | go-http |
| General Go idioms and naming | go-style |