Concurrency Design Guidelines
Overview
Concurrency can introduce subtle bugs. Follow these guidelines to write safe concurrent code.
Avoid Goroutine Leaks
// Bad: goroutine leaks if nobody receives
go func() {
ch <- result // Blocks forever
}()
// Good: use context cancellation
go func() {
select {
case ch <- result:
case <-ctx.Done():
}
}()Always Close Channels from Sender
// Producer closes
func producer(out chan<- int) {
for i := 0; i < 10; i++ {
out <- i
}
close(out) // Only sender closes
}
// Consumer ranges
for v := range in {
process(v)
}Race Detection
go test -race ./...
go run -race main.goCommon Patterns
Worker Pool
jobs := make(chan Job)
for i := 0; i < workers; i++ {
go worker(jobs)
}Bounded Concurrency
sem := make(chan struct{}, maxConcurrent)
for _, task := range tasks {
sem <- struct{}{}
go func(t Task) {
defer func() { <-sem }()
process(t)
}(task)
}Deadlock Prevention
// Deadlock: circular wait
ch := make(chan int)
ch <- 1 // Blocks: no receiver
<-ch // Never reached
// Fix: buffer or separate goroutine
ch := make(chan int, 1)
ch <- 1
<-chSummary
| Guideline | Reason |
|---|---|
| Share by communicating | Clearer ownership |
| Use context for cancellation | Prevent leaks |
| Only sender closes | Avoid panic |
| Run race detector | Catch data races |
Worked example
Ownership: only the sender closes; consumers range safely.
Save as main.go. Then:
go mod init example
go run .
go run -race .package main
import (
"fmt"
"sync"
)
func produce(n int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // sender closes
for i := 1; i <= n; i++ {
out <- i
}
}()
return out
}
func main() {
in := produce(5)
var wg sync.WaitGroup
sum := 0
var mu sync.Mutex
// Fan-out consumers; channel close ends all ranges.
for c := 0; c < 2; c++ {
wg.Add(1)
go func() {
defer wg.Done()
for v := range in {
mu.Lock()
sum += v
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Println("sum 1..5:", sum)
}Expected output:
sum 1..5: 15
More examples
Detect a deliberate data race (educational—expect FAIL under -race).
package main
import (
"fmt"
"sync"
)
func main() {
// Correct version first.
var (
mu sync.Mutex
count int
wg sync.WaitGroup
)
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("safe count:", count)
}Expected output:
safe count: 100
Runnable example
Save as main.go. Then:
go mod init example
go run .package main
import (
"context"
"fmt"
"sync"
"time"
)
// safeSend never leaks a goroutine when the consumer walks away.
func safeSend(ctx context.Context, ch chan<- int, v int) {
select {
case ch <- v:
case <-ctx.Done():
// drop result; exit without blocking forever
}
}
func main() {
// Bounded concurrency with a semaphore channel
const maxConcurrent = 2
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
tasks := []string{"a", "b", "c", "d", "e"}
var mu sync.Mutex
var order []string
for _, t := range tasks {
wg.Add(1)
go func(task string) {
defer wg.Done()
sem <- struct{}{} // acquire
defer func() { <-sem }() // release
// critical/limited work
mu.Lock()
order = append(order, task)
mu.Unlock()
}(t)
}
wg.Wait()
fmt.Println("processed tasks:", len(order))
// Avoid goroutine leak: context cancels a blocked send
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
ch := make(chan int) // unbuffered, no receiver → would block
var senderWG sync.WaitGroup
senderWG.Add(1)
go func() {
defer senderWG.Done()
safeSend(ctx, ch, 99)
fmt.Println("sender exited without leak")
}()
senderWG.Wait()
// Only the sender closes; consumer ranges until closed
out := make(chan int, 3)
go func() {
for i := 1; i <= 3; i++ {
out <- i
}
close(out) // sender closes
}()
sum := 0
for v := range out {
sum += v
}
fmt.Println("ranged sum:", sum)
}Expected output:
processed tasks: 5
sender exited without leak
ranged sum: 6
What to notice: The semaphore bounds execution, not just intention. select on ctx.Done() is the standard way to stop a blocked send/receive. Closing from the receiver (or twice) panics—ownership stays with the sender.
Try next: Run go run -race .. Temporarily remove the ctx.Done() case and see the program hang until you kill it.