Problem: Your Go Channels Have Hidden Race Conditions
Your Go program works perfectly in development, but crashes unpredictably in production with panics like send on closed channel or silent data corruption.
You'll learn:
- How to detect races using Go's built-in race detector
- Three patterns that cause channel race conditions
- Production-safe channel synchronization techniques
Time: 12 min | Level: Intermediate
Why This Happens
Race conditions in Go channels occur when multiple goroutines access shared channel state without proper synchronization. The Go scheduler's non-deterministic behavior hides these bugs during testing.
Common symptoms:
panic: send on closed channelin production- Missing messages that were "definitely sent"
- Goroutines blocking forever (deadlock)
- Tests pass but production fails intermittently
Solution
Step 1: Enable the Race Detector
Run your tests and application with Go's race detector:
# Run tests with race detection
go test -race ./...
# Run application with race detection
go run -race main.go
# Build with race detection (slower, use for staging)
go build -race -o app-debug
Expected: If races exist, you'll see detailed output showing exactly where they occur.
If it fails:
- No races detected but still crashing: The race might only trigger under load. Use
go test -race -count=100to run tests repeatedly.
Step 2: Identify the Race Pattern
Go channel races fall into three categories:
Pattern 1: Close-After-Send Race
// ❌ RACE: Sender and closer compete
func badPattern() {
ch := make(chan int)
go func() {
ch <- 42 // Might send after close
}()
close(ch) // Closes immediately
}
// ✅ SAFE: Use WaitGroup to coordinate
func goodPattern() {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
ch <- 42 // Guaranteed to send first
}()
wg.Wait() // Wait for sender to finish
close(ch) // Now safe to close
}
Why this works: sync.WaitGroup ensures senders complete before the channel closes.
Pattern 2: Multiple Closers
// ❌ RACE: Multiple goroutines try to close
func badMultiClose(ch chan int) {
go func() { close(ch) }()
go func() { close(ch) }() // panic: close of closed channel
}
// ✅ SAFE: Use sync.Once to close exactly once
type SafeChannel struct {
ch chan int
closed sync.Once
}
func (s *SafeChannel) Close() {
s.closed.Do(func() {
close(s.ch) // Only runs once, even from multiple goroutines
})
}
Why this works: sync.Once guarantees the close operation executes exactly once, regardless of concurrent calls.
Pattern 3: Read-After-Close Check
// ❌ RACE: Checking closed state before reading
func badCloseCheck(ch chan int, done chan struct{}) {
select {
case <-done:
return // Think we're safe
default:
}
ch <- 42 // But channel might close here
}
// ✅ SAFE: Check and send in single select
func goodCloseCheck(ch chan int, done chan struct{}) {
select {
case <-done:
return // Channel closed, exit safely
case ch <- 42:
// Send only if done isn't closed
}
}
Why this works: The select statement atomically checks all cases, preventing time-of-check-to-time-of-use bugs.
Step 3: Apply the Producer-Consumer Pattern
For complex pipelines, use this race-free pattern:
package main
import (
"context"
"sync"
)
// Producer sends work items
func producer(ctx context.Context, n int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // Producer owns closing
for i := 0; i < n; i++ {
select {
case <-ctx.Done():
return // Respect cancellation
case out <- i:
// Send successful
}
}
}()
return out
}
// Consumer processes work items
func consumer(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // Consumer owns its output
for val := range in { // Safe: range closes when in closes
select {
case <-ctx.Done():
return
case out <- val * 2:
}
}
}()
return out
}
// Orchestrate with context for clean shutdown
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Build pipeline
nums := producer(ctx, 100)
doubled := consumer(ctx, nums)
// Fan-out to multiple consumers
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for result := range doubled {
// Process result
_ = result
}
}()
}
wg.Wait() // All consumers finished
}
Key principles:
- Each goroutine that creates a channel is responsible for closing it
- Use
context.Contextfor cancellation signals, not channel closes rangeover channels to safely handle closessync.WaitGroupcoordinates goroutine completion
Verification
Test Your Fix
# Run race detector 100 times to catch intermittent races
go test -race -count=100 -timeout=30s ./...
You should see:
PASS
ok yourpackage 12.345s
If races remain:
==================
WARNING: DATA RACE
Read at 0x00c000016088 by goroutine 7:
main.badPattern()
/path/to/file.go:42 +0x44
...
The output shows exact line numbers where races occur.
Load Test in Production
// Add this to catch races in production (with minimal overhead)
import _ "net/http/pprof"
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Your application code
}
Monitor with:
# Check for goroutine leaks
curl http://localhost:6060/debug/pprof/goroutine?debug=1
# Profile for race conditions under load
go tool pprof http://localhost:6060/debug/pprof/goroutine
What You Learned
- Race detector catches bugs tests miss because they only appear under timing conditions
- Close channels from exactly one place using
sync.Onceor clear ownership - Use
selectfor atomic channel operations, not separate checks context.Context+sync.WaitGroup= race-free shutdown
Limitations:
- Race detector adds ~10x overhead (use in testing/staging, not production)
- Buffered channels can hide races temporarily - they still exist
- AI-generated code often misses these patterns (verify with
-race)
Common Race Detector Output
==================
WARNING: DATA RACE
Write at 0x00c00001a088 by goroutine 6:
main.worker()
/app/main.go:23 +0x44
Previous read at 0x00c00001a088 by main goroutine:
main.main()
/app/main.go:15 +0x88
==================
Read this as:
- Write at... - One goroutine modified shared state
- Previous read at... - Another goroutine read that state
- Line numbers - Exact locations of the race
Fix by adding proper synchronization (mutex, channel, or atomic) between those lines.
AI Detection Note
Why AI struggles with this: Language models trained on code datasets learn syntactically correct Go, but race conditions are semantic bugs that only appear at runtime under specific timing. The code "looks right" but behaves wrong.
How to verify AI-generated concurrent code:
- Always run
-raceon AI-written goroutine code - Look for close-after-send patterns (AI often generates these)
- Check that exactly one goroutine closes each channel
- Verify
sync.WaitGroupor context usage for cleanup
Example of AI-generated race:
// AI often generates this pattern
func aiGeneratedBug() {
ch := make(chan int, 1)
go func() { ch <- 1 }()
close(ch) // Race: might close before send
}
The code compiles and often works, but fails randomly in production.
Tested on Go 1.23.5, Linux & macOS. Race detector available since Go 1.1.