Designing Safe Concurrent Systems in Go
Go makes concurrency easy to start and hard to get right. The race detector catches obvious bugs, but subtle concurrency issues can hide for months. Here’s how I design concurrent systems that don’t break.
The Decision: Mutex vs Channel
The Go proverb says “share memory by communicating.” But sometimes a mutex is simpler.
Use channels when:
- Transferring ownership of data between goroutines
- Coordinating multiple goroutines (fan-out, pipeline)
- Signaling events (done, ready, shutdown)
Use mutexes when:
- Protecting a shared data structure from concurrent access
- Simple read/write synchronization
- The critical section is small and fast
// Mutex: simple shared counter
type Counter struct {
mu sync.Mutex
value int64
}
func (c *Counter) Inc() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
// Channel: transferring work between stages
jobs := make(chan Job, 100)
go producer(jobs)
go consumer(jobs)
Don’t use channels where a mutex is clearer. A channel-based counter is clever but confusing.
RWMutex for Read-Heavy Workloads
If reads vastly outnumber writes, sync.RWMutex allows concurrent readers:
type ConfigStore struct {
mu sync.RWMutex
config map[string]string
}
func (s *ConfigStore) Get(key string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
val, ok := s.config[key]
return val, ok
}
func (s *ConfigStore) Set(key, value string) {
s.mu.Lock()
defer s.mu.Unlock()
s.config[key] = value
}
Multiple goroutines can call Get simultaneously. Set blocks until all readers finish and holds exclusive access.
Atomic Operations for Simple Values
For simple counters and flags, sync/atomic avoids mutex overhead entirely:
type Stats struct {
requestCount atomic.Int64
errorCount atomic.Int64
isHealthy atomic.Bool
}
func (s *Stats) RecordRequest(err error) {
s.requestCount.Add(1)
if err != nil {
s.errorCount.Add(1)
}
}
func (s *Stats) GetErrorRate() float64 {
total := s.requestCount.Load()
if total == 0 {
return 0
}
return float64(s.errorCount.Load()) / float64(total)
}
The Race Detector
Run tests with -race. Always. In CI. No exceptions.
go test -race ./...
The race detector instruments memory accesses and reports concurrent unsynchronized access. It has ~10x overhead, so don’t use it in production — but it should be in every CI pipeline.
Common Race Conditions
Race #1: Shared slice append
// RACE: append is not thread-safe
var results []Result
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(item Item) {
defer wg.Done()
results = append(results, process(item)) // RACE!
}(item)
}
Fix: use a mutex or collect via channel:
var mu sync.Mutex
var results []Result
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(item Item) {
defer wg.Done()
result := process(item)
mu.Lock()
results = append(results, result)
mu.Unlock()
}(item)
}
Race #2: Map concurrent access
// RACE: maps are not safe for concurrent use
cache := map[string]int{}
go func() { cache["a"] = 1 }()
go func() { _ = cache["a"] }()
Fix: use sync.Map or a mutex-protected map:
var cache sync.Map
cache.Store("a", 1)
val, ok := cache.Load("a")
sync.Map is optimized for two patterns: keys written once and read many times, or disjoint key sets per goroutine. For other patterns, a mutex-protected map is faster.
Race #3: Closure variable capture
// BUG: all goroutines share the same loop variable
for _, item := range items {
go func() {
process(item) // Always processes the last item!
}()
}
// Fix: pass as parameter
for _, item := range items {
go func(item Item) {
process(item)
}(item)
}
// Note: Go 1.22+ fixes this — loop variables are per-iteration
Immutability as Safety
The safest concurrent data is data that never changes:
type Config struct {
DatabaseURL string
MaxConns int
Features map[string]bool
}
// Store an immutable config behind an atomic pointer
var currentConfig atomic.Pointer[Config]
func UpdateConfig(new *Config) {
// Create a deep copy — the old config is still safe to read
currentConfig.Store(new)
}
func GetConfig() *Config {
return currentConfig.Load()
}
Readers always get a consistent snapshot. Writers create a new Config and atomically swap the pointer. No locks needed.
Testing Concurrent Code
Use -count to run tests multiple times — races are non-deterministic:
go test -race -count=100 ./...
For specific race scenarios, use runtime.Gosched() to increase the chance of interleaving:
func TestConcurrentAccess(t *testing.T) {
store := NewStore()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)
go func(val int) {
defer wg.Done()
store.Set("key", val)
}(i)
go func() {
defer wg.Done()
runtime.Gosched() // Encourage interleaving
store.Get("key")
}()
}
wg.Wait()
}
Concurrency safety isn’t about clever tricks. It’s about clear ownership: who can read this data? Who can write it? How is access synchronized? Answer those questions for every shared variable, and your concurrent code will be correct.