Concurrency Fundamentals Best Practices
Share memory by communicating - and when to use locks.
These rules keep goroutines bounded, channels deadlock-free, and shared state race-free from dev laptops through production services.
How to Use This List
- Apply during code review on any PR that adds
go, channels, orsynctypes. - Run
go test -race ./...and golangci-lint on touched packages before merge. - When a rule conflicts with benchmark data, document the exception with a profile link.
- Revisit after Go upgrades; scheduler and loop semantics evolve across releases.
A - Goroutines and lifecycle
- Know how work ends before you start it. Every
goneeds a path to completion:WaitGroup,close(ch), orcontextcancel. - Never let
mainreturn while workers still run. Exit kills the process without waiting for background goroutines. - Cap fan-out for large inputs. Use worker pools instead of one goroutine per row, request, or file.
- Pass loop variables into goroutine parameters. Defensive on all supported Go versions; documents intent in libraries.
- Watch
runtime.NumGoroutine()in soak tests. Monotonic growth signals leaks on blocked channel receives.
B - Channels and select
- Default to unbuffered channels for handoff semantics. Add buffer only when profiling shows producer/consumer skew.
- Only senders close channels. Receivers detect shutdown with
rangeorv, ok := <-ch. - Never send on a closed channel. Guard shared closers with
sync.Onceor a dedicated coordinator goroutine. - Avoid
select+defaultspin loops. Busy polls burn CPU; block or use tickers/context deadlines. - Prefer
context.WithTimeoutovertime.Afterin server loops. Reuse timers in hot paths to reduce allocations.
C - Mutexes, atomics, and shared state
- Protect maps and slices mutated by multiple goroutines. Native maps are not safe concurrent without synchronization.
- Use
defer mu.Unlock()immediately afterLock. Survives panics and early returns in handlers. - Pick one lock ordering when multiple mutexes interact. Document order in type comments to prevent deadlocks.
- Hold locks for microseconds, not milliseconds. Copy data out, release, then perform I/O or RPC under no lock.
- Reach for atomics only for simple counters/flags. Multi-field invariants still need mutexes.
D - Testing, race detector, and production
- Run
go test -raceon CI for packages using concurrency. Treat new race reports as release blockers. - Stress flaky tests with
-count=50 -race. Interleavings surface races that pass once. - Do not ship
-racebinaries to latency-sensitive prod. Use staging soak with race builds instead. - Install panic recovery at HTTP/gRPC boundaries. chi, gin, echo, and gRPC interceptors catch per-request panics.
- Thread
context.Contextthrough concurrent call trees. Cancellation stops downstream goroutines when clients disconnect.
FAQs
Channels or mutexes by default?
Channels when transferring ownership or staging pipeline work.
Mutexes when a small shared struct sees frequent updates.
What buffer size should I use?
Start at 0 or worker count.
Increase only when profiles show blocking on full buffers.
How do I avoid goroutine leaks in servers?
Tie background work to r.Context().
Return when the client disconnects and workers observe ctx.Done().
Is sync.Map the default concurrent map?
No - benchmark Mutex+map first.
Use sync.Map for proven read-mostly caches only.
Should every handler spawn a goroutine?
net/http already does per request.
Extra goroutines need justification and cancellation.
When is it OK to share memory?
When one writer owns mutation or a mutex serializes access.
Communicate first; share when simpler and measured.
How do I review concurrent PRs quickly?
Check close ownership, WaitGroup pairing, lock scope, and whether -race tests exist.
Does -race catch deadlocks?
No - only unsynchronized memory access.
Add timeouts and structured shutdown tests separately.
Related
- Concurrency in Go: Goroutines and Channels First - section overview
- Concurrency Basics - runnable intro examples
- Channels: Buffered, Unbuffered & Closing Rules - close semantics
- Race Detector: Running and Interpreting Output - CI race workflow
- sync.Mutex, RWMutex & WaitGroup - lock discipline
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).