Added 'When to Use' subsections with concrete decision triggers and before/after Go code examples to patterns across all directories: - patterns/error-handling.md (3 patterns: sentinels, wrapping, Join) - patterns/concurrency.md (4 patterns: Mutex, Once, done channels, pipelines) - patterns/interfaces.md (4 patterns: small interfaces, accept/return, adapter, optional) - patterns/structs.md (3 patterns: zero-value, constructors, config structs) - patterns/package-design.md (3 patterns: internal/, init(), context keys) - patterns/style.md (3 patterns: interface checks, iota constants, named types) - patterns/testing-advanced.md (3 patterns: table tests, golden files, httptest) - patterns/api-conventions.md (3 patterns: Must, layered API, graceful shutdown) - patterns/documentation.md (2 patterns: examples, deprecated) - kubernetes/patterns.md (3 patterns: controller, workqueue, leader election) - kubernetes/production-go.md (2 patterns: codegen, HandleCrash) - smells/anti-patterns.md (2 anti-patterns: cache mutation, edge-triggered)
16 KiB
Go Concurrency Patterns
Patterns extracted from the Go standard library source code.
1. sync.Mutex — The Basic Lock
Source: src/sync/mutex.go:18-34, src/sync/mutex.go:42-67
// src/sync/mutex.go:18-34
// A Mutex is a mutual exclusion lock.
// The zero value for a Mutex is an unlocked mutex.
//
// A Mutex must not be copied after first use.
type Mutex struct {
_ noCopy
mu isync.Mutex
}
// src/sync/mutex.go:36-39
type Locker interface {
Lock()
Unlock()
}
// src/sync/mutex.go:43-46
func (m *Mutex) Lock() {
m.mu.Lock()
}
Why
- Zero value is ready to use — no constructor needed
- Must not be copied — enforced by
noCopyfield (go vet detects copies) - Not associated with a goroutine — one goroutine can Lock, another can Unlock
- Locker interface — abstracts over Mutex and RWMutex
When to Use
Triggers:
- Multiple goroutines read AND write the same data structure
- You need to protect a small critical section (a few field accesses)
- A channel-based solution would add complexity without benefit (no coordination needed, just protection)
Example — before:
type Stats struct {
hits int
misses int
}
func (s *Stats) RecordHit() { s.hits++ } // DATA RACE when called from multiple goroutines
func (s *Stats) RecordMiss() { s.misses++ } // DATA RACE
Example — after:
type Stats struct {
mu sync.Mutex
hits int
misses int
}
func (s *Stats) RecordHit() {
s.mu.Lock()
defer s.mu.Unlock()
s.hits++
}
Idiomatic Usage
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
// ... critical section ...
Anti-pattern
// DON'T: Copy a mutex
type Config struct {
mu sync.Mutex
data map[string]string
}
c2 := *c1 // COPIES the mutex — data race
// DON'T: Forget defer
mu.Lock()
doSomething() // if this panics, mutex stays locked forever
mu.Unlock()
2. sync.Once — Exactly-Once Initialization
Source: src/sync/once.go:12-36, src/sync/once.go:56-79
// src/sync/once.go:12-23
type Once struct {
_ noCopy
done atomic.Bool
m Mutex
}
// src/sync/once.go:56-63
func (o *Once) Do(f func()) {
if !o.done.Load() {
o.doSlow(f)
}
}
// src/sync/once.go:65-72
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if !o.done.Load() {
defer o.done.Store(true)
f()
}
}
Why
The implementation reveals a subtle guarantee: when Do returns, f has finished. The naive CAS-only approach (documented in comment at line 56-63) would let the second caller return before f completes. The mutex ensures all callers wait.
The done field is first in the struct for hot-path performance on amd64/386 (noted in comment at line 24-27).
When to Use
Triggers:
- You have expensive initialization that should happen exactly once (DB connection, config parse, compiled regex)
- Multiple goroutines may trigger the initialization concurrently
- You're using
var+if instance == nilchecks that aren't goroutine-safe
Example — before:
var db *sql.DB
func GetDB() *sql.DB {
if db == nil { // RACE: two goroutines can both see nil
db, _ = sql.Open("postgres", connStr)
}
return db
}
Example — after:
var (
db *sql.DB
once sync.Once
)
func GetDB() *sql.DB {
once.Do(func() {
db, _ = sql.Open("postgres", connStr)
})
return db
}
Idiomatic Usage
var (
instance *DB
once sync.Once
)
func GetDB() *DB {
once.Do(func() {
instance = connectToDB()
})
return instance
}
Anti-pattern
// DON'T: Call Do recursively (deadlocks)
var once sync.Once
once.Do(func() {
once.Do(func() { /* deadlock */ })
})
3. sync.WaitGroup — Waiting for Goroutine Completion
Source: src/sync/waitgroup.go:14-43, src/sync/waitgroup.go:236-260
// src/sync/waitgroup.go:14-43
// Typically, a main goroutine will start tasks by calling WaitGroup.Go
// and then wait for all tasks to complete by calling WaitGroup.Wait:
//
// var wg sync.WaitGroup
// wg.Go(task1)
// wg.Go(task2)
// wg.Wait()
type WaitGroup struct {
noCopy noCopy
state atomic.Uint64
sema uint32
}
Go 1.25+: WaitGroup.Go
// src/sync/waitgroup.go:236-260
func (wg *WaitGroup) Go(f func()) {
wg.Add(1)
go func() {
defer func() {
if x := recover(); x != nil {
// Don't call Done — let panic propagate fatally.
panic(x)
}
wg.Done()
}()
f()
}()
}
Why
WaitGroup.Go encapsulates the Add/go/Done pattern. Key design: if f panics, it re-panics without calling Done, preventing the main goroutine from racing to exit before the panic stack trace prints.
Classic Pattern (pre-Go 1.25)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func() {
defer wg.Done()
process(item)
}()
}
wg.Wait()
Anti-pattern
// DON'T: Add inside the goroutine (race with Wait)
for _, item := range items {
go func() {
wg.Add(1) // might run after Wait is called!
defer wg.Done()
process(item)
}()
}
wg.Wait()
4. sync.Pool — Object Reuse for GC Pressure
Source: src/sync/pool.go:44-63
// src/sync/pool.go:44-63
// Pool's purpose is to cache allocated but unused items for later reuse,
// relieving pressure on the garbage collector. That is, it makes it easy to
// build efficient, thread-safe free lists.
//
// An appropriate use of a Pool is to manage a group of temporary items
// silently shared among and potentially reused by concurrent independent
// clients of a package. Pool provides a way to amortize allocation overhead
// across many clients.
//
// An example of good use of a Pool is in the fmt package, which maintains a
// dynamically-sized store of temporary output buffers.
type Pool struct {
noCopy noCopy
local unsafe.Pointer
localSize uintptr
victim unsafe.Pointer
victimSize uintptr
New func() any
}
Why
Pool is not a general cache. Items can vanish between GC cycles. Use for reducing allocation pressure on hot paths.
Idiomatic Usage (from fmt package)
var ppFree = sync.Pool{
New: func() any { return new(pp) },
}
func newPrinter() *pp {
p := ppFree.Get().(*pp)
p.reset()
return p
}
func (p *pp) free() {
p.buf = p.buf[:0] // reset before returning
ppFree.Put(p)
}
Anti-pattern
// DON'T: Use Pool for connection pooling (items disappear!)
var connPool = sync.Pool{
New: func() any { return connectToDB() },
}
// Connections may be GC'd — use database/sql's pool instead
// DON'T: Put dirty objects back without resetting
pool.Put(buf) // still has data from last use
5. Channel as Done Signal (Context Pattern)
Source: src/context/context.go:83-100 (Done channel), src/io/pipe.go:42-45
// src/context/context.go:83-100
// Done returns a channel that's closed when work done on behalf of this
// context should be canceled.
Done() <-chan struct{}
// src/io/pipe.go:42-45
type pipe struct {
once sync.Once
done chan struct{} // closed on pipe close
}
Why
A chan struct{} costs zero bytes per send and closing it broadcasts to all receivers simultaneously. This is the canonical "done" signal in Go.
select {
case <-ctx.Done():
return ctx.Err()
case result := <-work:
return result, nil
}
When to Use
Triggers:
- You need to broadcast "stop" to multiple goroutines simultaneously
- A goroutine needs to select between work and cancellation
- You're implementing graceful shutdown for a long-running service
Example — before:
type Server struct {
stopped bool // RACE: no synchronization
}
func (s *Server) worker() {
for {
if s.stopped { return } // busy-polls, racy
doWork()
}
}
Example — after:
type Server struct {
done chan struct{}
}
func NewServer() *Server {
return &Server{done: make(chan struct{})}
}
func (s *Server) worker() {
for {
select {
case <-s.done:
return
case work := <-s.workCh:
process(work)
}
}
}
func (s *Server) Stop() { close(s.done) } // broadcasts to ALL workers
Anti-pattern
// DON'T: Use chan bool for done signals
done := make(chan bool) // wastes 1 byte, true/false meaningless
// DON'T: Send to done (only unblocks one receiver)
done <- struct{}{}
// DO: Close the channel (broadcasts to all)
close(done)
6. Context Propagation Rules
Source: src/context/context.go:37-48
// src/context/context.go:37-48
// Do not store Contexts inside a struct type; instead, pass a Context
// explicitly to each function that needs it. The Context should be the first
// parameter, typically named ctx:
//
// func DoSomething(ctx context.Context, arg Arg) error {
// // ... use ctx ...
// }
//
// Do not pass a nil Context, even if a function permits it. Pass context.TODO
// if you are unsure about which Context to use.
Why
Context flows down the call chain, never stored in structs. It carries deadlines and cancellation signals for the current request, not persistent state.
Anti-pattern
// DON'T: Store context in a struct
type Server struct {
ctx context.Context // stale context persists beyond request
}
// DON'T: Pass nil
doWork(nil, data) // use context.TODO() if unsure
// DON'T: Put context anywhere other than first parameter
func doWork(data Data, ctx context.Context) // wrong position
7. Context Cancellation with Timeout
Source: src/net/http/server.go:4007-4050 (TimeoutHandler)
// src/net/http/server.go:4011-4050
func (h *timeoutHandler) ServeHTTP(w ResponseWriter, r *Request) {
ctx, cancelCtx := context.WithTimeout(r.Context(), h.dt)
defer cancelCtx()
r = r.WithContext(ctx)
done := make(chan struct{})
panicChan := make(chan any, 1)
go func() {
defer func() {
if p := recover(); p != nil {
panicChan <- p
}
}()
h.handler.ServeHTTP(tw, r)
close(done)
}()
select {
case p := <-panicChan:
panic(p)
case <-done:
// handler completed — copy response
case <-ctx.Done():
// timeout — write 503
}
}
Why
This is the full pattern: context with timeout + goroutine + select on done/timeout/panic. Key details:
defer cancelCtx()— always release resources- Panic propagation via dedicated channel
- Select on three outcomes: success, timeout, panic
Anti-pattern
// DON'T: Forget to call cancel (leaks timer goroutines)
ctx, _ := context.WithTimeout(parent, 5*time.Second)
// DON'T: Ignore context in long operations
func longWork(ctx context.Context) {
time.Sleep(10 * time.Minute) // ignores cancellation
}
8. Select with Non-Blocking Check
Source: src/io/pipe.go:51-60
// src/io/pipe.go:51-60
func (p *pipe) read(b []byte) (n int, err error) {
select {
case <-p.done:
return 0, p.readCloseError()
default:
}
select {
case bw := <-p.wrCh:
nr := copy(b, bw)
p.rdCh <- nr
return nr, nil
case <-p.done:
return 0, p.readCloseError()
}
}
Why
The double-select pattern: first a non-blocking check (with default), then a blocking wait. The non-blocking check prevents a race where done was closed between the last operation and entering the blocking select.
Anti-pattern
// DON'T: Check ctx.Done() in a busy loop
for {
select {
case <-ctx.Done():
return
default:
}
// busy-spins CPU at 100%!
}
9. Channel Pipeline (io.Pipe)
Source: src/io/pipe.go:38-45, src/io/pipe.go:195-205
// src/io/pipe.go:38-45
type pipe struct {
wrCh chan []byte // writer sends data slices
rdCh chan int // reader returns bytes consumed
done chan struct{}
}
// src/io/pipe.go:195-205
func Pipe() (*PipeReader, *PipeWriter) {
pw := &PipeWriter{r: PipeReader{pipe: pipe{
wrCh: make(chan []byte), // unbuffered
rdCh: make(chan int), // unbuffered
done: make(chan struct{}),
}}}
return &pw.r, pw
}
Why
io.Pipe uses unbuffered channels — each Write blocks until Read consumes. Backpressure is automatic. The done channel signals shutdown.
Pipeline Pattern Template
func generate(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; ; i++ {
select {
case out <- i:
case <-ctx.Done():
return
}
}
}()
return out
}
When to Use
Triggers:
- You have a producer-consumer flow where the consumer's speed should limit the producer (backpressure)
- Data flows through multiple transformation stages
- You want to decouple stages that can run concurrently
Example — before:
func processAll(items []string) []Result {
var results []Result
for _, item := range items {
fetched := fetch(item) // sequential: fetch then transform
results = append(results, transform(fetched))
}
return results
}
Example — after:
func processAll(ctx context.Context, items []string) []Result {
fetched := make(chan Fetched)
go func() {
defer close(fetched)
for _, item := range items {
select {
case fetched <- fetch(item): // backpressure: blocks if transform is slow
case <-ctx.Done():
return
}
}
}()
var results []Result
for f := range fetched {
results = append(results, transform(f))
}
return results
}
Anti-pattern
// DON'T: Forget to close channels (receivers block forever)
func produce() <-chan int {
ch := make(chan int)
go func() {
for i := 0; i < 10; i++ {
ch <- i
}
// forgot close(ch) — range receivers hang
}()
return ch
}
10. Background Worker with Context Shutdown
Source: src/database/sql/sql.go:836-843
// src/database/sql/sql.go:836-843
func OpenDB(c driver.Connector) *DB {
ctx, cancel := context.WithCancel(context.Background())
db := &DB{
connector: c,
openerCh: make(chan struct{}, connectionRequestQueueSize),
stop: cancel,
}
go db.connectionOpener(ctx)
return db
}
Why
A dedicated background goroutine processes work from a buffered channel. It's controlled by a context — calling cancel() (stored as db.stop) shuts it down. This is the standard "long-lived worker goroutine with graceful shutdown" pattern.
Anti-pattern
// DON'T: Start goroutines without shutdown mechanism
go func() {
for {
processWork() // runs forever, no way to stop
}
}()
11. noCopy — Preventing Value Copies
Source: src/sync/cond.go:120-126
// src/sync/cond.go:120-126
type noCopy struct{}
// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
Why
Embedding noCopy makes go vet report errors when the struct is copied by value. All sync primitives use this because copying a locked mutex or active WaitGroup is always a bug.
Anti-pattern
// DON'T: Pass sync types by value
func doWork(wg sync.WaitGroup) { // copies!
defer wg.Done() // operates on copy, not original
}
// DO: Pass by pointer
func doWork(wg *sync.WaitGroup) {
defer wg.Done()
}
Summary: Concurrency Decision Guide
| Need | Use |
|---|---|
| Protect shared state | sync.Mutex + defer Unlock() |
| One-time initialization | sync.Once |
| Wait for N goroutines | sync.WaitGroup (prefer .Go() in 1.25+) |
| Reduce allocation pressure | sync.Pool (not for connections!) |
| Signal completion/cancellation | chan struct{} + close() |
| Deadline/timeout propagation | context.WithTimeout / context.WithCancel |
| Backpressure between producer/consumer | Unbuffered channels |
| Long-lived background worker | Goroutine + context cancellation |
| Prevent struct copying | Embed noCopy field |