go-concurrency
Go Concurrency Skill
Guide implementation of correct, leak-free concurrent Go code using goroutines, channels, sync primitives, and context propagation. Works by assessing whether concurrency is justified, selecting the right primitive, enforcing context propagation, implementing the pattern, and verifying with the race detector.
Instructions
Phase 1: Assess Concurrency Need
Goal: Determine whether concurrency is justified before adding complexity.
Read and follow the repository's CLAUDE.md before writing any concurrent code, because project-specific conventions (naming, package structure, error handling) override general patterns.
Before writing concurrent code, answer these questions:
- Is the work I/O-bound? (network, database, filesystem) -- concurrency likely helps
- Is the work CPU-bound? -- concurrency helps only if parallelizable
- Is there a measured bottleneck? -- if not measured, don't assume
If none apply, write sequential code. Sequential code is correct by default -- concurrency adds goroutine lifecycle management, synchronization, and race risk. Only introduce it when I/O, CPU parallelism, or a measured bottleneck justifies the complexity. Assuming "sequential is too slow" without profiling is a common mistake; profile first, then add concurrency.
Gate: At least one of the three conditions (I/O-bound, CPU-bound, measured bottleneck) is met. Proceed only when gate passes.
Phase 2: Choose the Right Primitive
Goal: Select the minimal primitive that solves the concurrency need.
| Need | Primitive | When |
|---|---|---|
| Communicate between goroutines | Channel | Data flows from producer to consumer |
| Protect shared state | sync.Mutex |
Multiple goroutines read/write same data |
| Read-heavy shared state | sync.RWMutex |
Many readers, few writers |
| Wait for goroutines to finish | errgroup.Group |
Need error collection + context cancel |
| Wait without error collection | sync.WaitGroup |
Fire-and-forget goroutines |
| One-time initialization | sync.Once |
Lazy singleton, config loading |
| Simple shared counter | atomic.Int64 |
Increment/read without mutex overhead |
Selection guidance:
- Prefer
errgroup.Groupoversync.WaitGroupbecause errgroup collects errors and cancels remaining goroutines on first failure, which is what you want in most production scenarios. - Use
atomic.Int64oratomic.Valuefor simple shared counters instead of mutex because atomic operations avoid lock contention and are sufficient when the shared state is a single value. - Return
<-chan T(receive-only) from producer functions because it prevents callers from accidentally closing or sending on a channel they don't own. - Size buffered channels to match expected backpressure, not arbitrary large numbers, because oversized buffers hide flow-control bugs that surface under production load.
- When you need a custom rate limiter instead of
golang.org/x/time/rate, build a token-bucket implementation -- but only when the standard library doesn't meet your needs.
Gate: Primitive selected with clear justification. Proceed only when gate passes.
Phase 3: Context Propagation
Goal: Wire context through all cancellable operations so goroutines respond to cancellation.
Accept context.Context as the first parameter for all I/O or cancellable operations because a function that's fast today may become slow under load tomorrow, and retrofitting context is harder than passing it from the start.
func FetchData(ctx context.Context, id string) (*Data, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resultCh := make(chan *Data, 1)
errCh := make(chan error, 1)
go func() {
data, err := slowOperation(id)
if err != nil {
errCh <- err
return
}
resultCh <- data
}()
select {
case data := <-resultCh:
return data, nil
case err := <-errCh:
return nil, fmt.Errorf("fetch failed: %w", err)
case <-ctx.Done():
return nil, fmt.Errorf("fetch cancelled: %w", ctx.Err())
}
}
Every select statement in concurrent code must include a case <-ctx.Done() because without it, a goroutine blocks forever if the channel never receives and the upstream context is cancelled -- this is the most common source of goroutine leaks.
When to use context vs not:
// USE context: I/O, cancellable operations, request-scoped values
func FetchUserData(ctx context.Context, userID string) (*User, error) { ... }
// NO context needed: pure computation
func CalculateTotal(prices []float64) float64 { ... }
When gopls MCP tools are available, use go_symbol_references to trace channel flow and go_file_context to understand goroutine spawn sites -- this helps verify context propagation through concurrent call chains.
Gate: All I/O operations accept context, all select statements include <-ctx.Done(). Proceed only when gate passes.
Phase 4: Implement the Pattern
Goal: Write the concurrent code using the selected primitive with correct lifecycle management.
Sync Primitives
Lock only the critical section and use defer mu.Unlock() immediately after mu.Lock() because early returns or panics between Lock and Unlock cause deadlocks that are extremely difficult to diagnose in production.
// Mutex for state protection
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
// RWMutex for read-heavy workloads
type Cache struct {
mu sync.RWMutex
items map[string]any
}
func (c *Cache) Get(key string) (any, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, ok := c.items[key]
return item, ok
}
func (c *Cache) Set(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
errgroup for concurrent work with error handling
Prefer errgroup over sync.WaitGroup because it collects errors and cancels remaining goroutines on first failure:
import "golang.org/x/sync/errgroup"
func ProcessAll(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
for _, item := range items {
g.Go(func() error {
return process(ctx, item) // Go 1.22+: item captured correctly
})
}
return g.Wait()
}
Use Go 1.22+ loop variable semantics -- the item variable is per-iteration, so legacy item := item shadows are unnecessary in new code.
sync.Once for one-time initialization
type Config struct {
once sync.Once
config *AppConfig
err error
}
func (c *Config) Load() (*AppConfig, error) {
c.once.Do(func() {
c.config, c.err = loadConfigFromFile()
})
return c.config, c.err
}
Channel patterns: buffered vs unbuffered
Only the sender closes a channel because closing from the receiver side causes panics if the sender is still sending, and multiple receivers may double-close. Use defer close(ch) in the goroutine that writes.
// Unbuffered: synchronous, sender blocks until receiver ready
ch := make(chan int)
// Buffered: async up to buffer size
ch := make(chan int, 100)
// Guidelines:
// - Use unbuffered when you need synchronization
// - Use buffered to decouple sender/receiver timing
// - Buffer size should match expected backpressure
Graceful Shutdown
Workers and servers must implement clean shutdown with a drain timeout because abrupt termination can lose in-flight work and corrupt state. See references/concurrency-patterns.md for the full graceful shutdown pattern.
For worker pool, fan-out/fan-in, pipeline, and rate limiter patterns, see references/concurrency-patterns.md.
When profiling goroutine counts and channel contention under load, use runtime.NumGoroutine() and pprof to identify bottlenecks. For container deployments, configure GOMAXPROCS to match container CPU limits when needed.
Gate: Code compiles, follows the selected pattern, channels closed by sender only, mutexes use defer Unlock. Proceed only when gate passes.
Phase 5: Run Race Detector
Goal: Verify no data races exist in the concurrent code.
Run go test -race on all concurrent code because race conditions are silent until production -- they don't cause compile errors, often don't cause test failures, and manifest as rare, non-reproducible bugs under load.
# Run with race detector during development
go test -race -count=1 -v ./...
# Run specific test with race detection
go test -race -run TestConcurrentOperation ./...
After editing concurrent code, use go_diagnostics (when gopls MCP tools are available) to catch errors before running tests.
Gate: go test -race passes clean with no race conditions detected. Proceed only when gate passes.
Phase 6: Verify Completeness
Goal: Confirm all concurrent code is correct and leak-free.
Every goroutine must have a guaranteed exit path via context cancellation, channel close, or explicit shutdown signal because goroutine leaks compound over time and lead to OOM in production -- a single leaked goroutine in a request handler means unbounded memory growth.
Before declaring concurrent code complete, verify:
- Context propagation -- All I/O operations accept context as first parameter
- Goroutine exit paths -- Every goroutine can terminate (via ctx.Done, channel close, or stop signal)
- Channel closure -- Channels closed by sender only, using
defer close(ch) - Select with context -- All
selectstatements includecase <-ctx.Done() - Proper synchronization -- Shared state protected by mutex or atomic
- Mutex discipline --
defer mu.Unlock()immediately aftermu.Lock() - Race detector passes --
go test -raceclean - Graceful shutdown -- Workers and servers stop cleanly with drain timeout
- No goroutine leaks -- All goroutines tracked and have exit paths
Gate: All checklist items verified. Concurrent code is complete.
Error Handling
Error: "DATA RACE detected by race detector"
Cause: Multiple goroutines access shared variable without synchronization Solution:
- Identify the variable from the race detector output (it shows goroutine stacks)
- Protect with
sync.Mutexfor complex state, oratomicfor simple counters - If using channels, ensure the variable is only accessed by one goroutine at a time
- Re-run
go test -raceto confirm fix
Error: "all goroutines are asleep - deadlock!"
Cause: Circular wait on channels or mutexes; no goroutine can make progress Solution:
- Check for unbuffered channel sends with no receiver ready
- Check for mutex lock ordering inconsistencies
- Ensure channels are closed when done to unblock
rangeloops - Add buffering to channels where appropriate
Error: "context deadline exceeded" in concurrent operations
Cause: Operations not completing within timeout, or context cancelled upstream Solution:
- Check if timeout is realistic for the operation
- Verify context is propagated correctly (not using
context.Background()when a parent context exists) - Ensure goroutines check
ctx.Done()in their select loops - Consider increasing timeout or adding per-operation timeouts with
context.WithTimeout
References
Reference Files
${CLAUDE_SKILL_DIR}/references/concurrency-patterns.md: Worker pool, fan-out/fan-in, pipeline, rate limiter, and graceful shutdown patterns with full code examples
More from notque/claude-code-toolkit
generate-claudemd
Generate project-specific CLAUDE.md from repo analysis.
12fish-shell-config
Fish shell configuration and PATH management.
12pptx-generator
PPTX presentation generation with visual QA: slides, pitch decks.
12codebase-overview
Systematic codebase exploration and architecture mapping.
10data-analysis
Decision-first data analysis with statistical rigor gates.
9spec-writer
Structured specification: user stories, acceptance criteria, scope.
8