3.9 KiB
3.9 KiB
Channels and Select Patterns
Goroutine Lifecycle
NEVER start a goroutine without knowing how it stops. Every goroutine MUST answer: how will it stop?
// ✗ Bad — fire-and-forget, no way to stop or wait
func startWorker() {
go func() {
for {
doWork() // runs forever, leaks on shutdown
}
}()
}
// ✓ Good — goroutine respects context cancellation, caller can wait
func startWorker(ctx context.Context) *sync.WaitGroup {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
default:
doWork(ctx)
}
}
}()
return &wg
}
Panic Recovery at Goroutine Boundaries
A panic in a goroutine crashes the entire process. Always recover at goroutine boundaries in production code:
go func() {
defer func() {
if r := recover(); r != nil {
// ...
}
}()
doWork(ctx)
}()
Channel Direction
Specify direction in function signatures to prevent misuse at compile time:
// ✗ Bad — caller could accidentally close or send on a receive-only channel
func consume(ch chan int) { ... }
// ✓ Good — compiler enforces correct usage
func produce(ch chan<- int) { ... } // send-only
func consume(ch <-chan int) { ... } // receive-only
Channel Closing
Channels MUST be closed by the sender (producer), NEVER by the receiver — it causes a panic if the sender writes after close.
// ✓ Good — producer closes when done
func generate(ctx context.Context) <-chan int {
ch := make(chan int)
go func() {
defer close(ch) // sender closes
for i := 0; ; i++ {
select {
case ch <- i:
case <-ctx.Done():
return
}
}
}()
return ch
}
Buffer Size
| Size | When to use |
|---|---|
| 0 (unbuffered) | Default. Synchronizes sender and receiver — use when you need handoff guarantees |
| 1 | Signal channels (done := make(chan struct{}, 1)), or when sender must not block on a single pending item |
| N > 1 | Only with measured justification — document why N was chosen and what happens when the buffer fills |
// ✓ Good — unbuffered for synchronous handoff
ch := make(chan Result)
// ✓ Good — buffered 1 for signal
done := make(chan struct{}, 1)
// ✗ Suspicious — arbitrary large buffer hides backpressure problems
// Give explanation in comments.
ch := make(chan Task, 1000) // why 1000? what if it fills?
Select for Non-Blocking Communication
Use select to multiplex channel operations and always include ctx.Done() to prevent goroutine leaks:
func process(ctx context.Context, in <-chan Task, out chan<- Result) {
for {
select {
case <-ctx.Done():
return
case task, ok := <-in:
if !ok {
return // channel closed
}
result := handle(ctx, task)
select {
case out <- result:
case <-ctx.Done():
return
}
}
}
}
Avoid Repeated time.After in Hot Loops
// ✗ Bad — creates a new timer on every iteration
for {
select {
case msg := <-ch:
handle(msg)
case <-time.After(5 * time.Second): // repeated allocation/churn
handleTimeout()
}
}
// ✓ Good (Go 1.23+) — reuse the timer
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case msg := <-ch:
timer.Stop()
timer.Reset(5 * time.Second)
handle(msg)
case <-timer.C:
handleTimeout()
timer.Reset(5 * time.Second)
}
}
For Go <1.23, if timer.Stop() returns false, drain a possible stale value before Reset. In Go 1.23+, receiving from timer.C after Stop returns is guaranteed to block rather than receive a stale value.