Acquire / Release — Find the Bug¶
Each section presents a buggy snippet involving acquire/release semantics. Identify the bug, explain why it's wrong, and propose a fix.
Bug 1: Plain boolean flag¶
var ready bool
var data string
go func() {
data = "hello"
ready = true
}()
for !ready { }
fmt.Println(data)
Bug: Both ready and data are accessed without synchronization. The reader's for !ready may spin forever (the compiler may hoist ready out of the loop). Even if not hoisted, the writer's ready=true may be observed before data="hello", leading to data="".
Fix: Use atomic.Bool for ready:
var ready atomic.Bool
var data string
go func() {
data = "hello"
ready.Store(true)
}()
for !ready.Load() { }
fmt.Println(data)
Now Store is a release; Load is an acquire. The write to data is visible after the reader observes ready=true.
Bug 2: Store flag before value¶
var done atomic.Bool
var value int
go func() {
done.Store(true) // RACE
value = 42
}()
for !done.Load() { }
fmt.Println(value) // may see 0
Bug: The flag is stored before the value. The release on done doesn't publish the later write to value.
Fix: Store the value first, then the flag:
Now the release on done publishes the write to value.
Bug 3: Atomic with plain read¶
var counter atomic.Int64
go func() {
counter.Add(1)
}()
go func() {
if counter > 0 { // RACE: plain read
// ...
}
}()
Bug: counter > 0 is a plain field read, not an atomic load. This is a data race.
Fix: Use Load:
Bug 4: Mutating published pointer¶
var snap atomic.Pointer[Snapshot]
go func() {
s := &Snapshot{Count: 0}
snap.Store(s)
s.Count = 1 // RACE: mutation after publish
}()
go func() {
s := snap.Load()
if s != nil {
fmt.Println(s.Count)
}
}()
Bug: After Store(s), the writer mutates s.Count. Readers that already loaded s see the mutation: race condition.
Fix: Treat published values as immutable. To update, allocate a new snapshot:
Bug 5: Double close of channel¶
type Server struct {
stop chan struct{}
}
func (s *Server) Stop() {
close(s.stop) // panic on second call
}
Bug: Calling Stop twice (e.g., from a signal handler and from main) panics.
Fix: Use sync.Once:
type Server struct {
stop chan struct{}
stopOnce sync.Once
}
func (s *Server) Stop() {
s.stopOnce.Do(func() { close(s.stop) })
}
Bug 6: WaitGroup race¶
var wg sync.WaitGroup
go func() {
wg.Add(1)
defer wg.Done()
work()
}()
wg.Wait() // may return before Add
Bug: Add is called inside the goroutine; Wait may run first, see counter=0, return immediately.
Fix: Call Add before spawning:
Bug 7: Captured loop variable (pre-Go 1.22)¶
Bug (pre-1.22): All goroutines capture the same i, possibly already incremented to 10.
Fix: Shadow inside the loop:
Or upgrade to Go 1.22+, which fixes the loop variable scoping.
Bug 8: Map concurrent access¶
Bug: Maps are not safe for concurrent access. Reads concurrent with writes can panic.
Fix: Use a mutex or sync.Map:
var mu sync.RWMutex
var users = map[int]string{}
go func() {
mu.Lock()
users[1] = "alice"
mu.Unlock()
}()
go func() {
mu.RLock()
_ = users[1]
mu.RUnlock()
}()
Bug 9: Goroutine leak via blocked channel¶
ch := make(chan int)
go func() {
result := compute()
ch <- result // blocks forever if no receiver
}()
// (forgot to receive)
Bug: Without a receiver, the goroutine blocks forever, leaking memory.
Fix: Use a buffered channel or context cancellation:
ch := make(chan int, 1)
ctx, cancel := context.WithCancel(parent)
defer cancel()
go func() {
select {
case ch <- compute():
case <-ctx.Done():
}
}()
Bug 10: Mismatched atomic types¶
var v int32
go func() {
atomic.StoreInt32(&v, 1)
}()
go func() {
if v == 1 { /* ... */ } // RACE: plain read
}()
Bug: Mixing atomic and plain access.
Fix: Use atomic everywhere:
Or, prefer atomic.Int32:
Bug 11: Once panic¶
var once sync.Once
var cfg *Config
func GetCfg() *Config {
once.Do(func() {
cfg = loadCfg() // panics on transient error
})
return cfg
}
Bug: If loadCfg panics, once considers itself done. Future callers return nil.
Fix: Recover, or use a retriable pattern:
func GetCfg() *Config {
once.Do(func() {
defer func() {
if r := recover(); r != nil {
// log; cfg remains nil
}
}()
cfg = loadCfg()
})
return cfg
}
For retry-on-error, build a custom RetriableOnce.
Bug 12: Time-based "synchronization"¶
Bug: time.Sleep doesn't establish happens-before. The read of x races with the write.
Fix: Use a channel or atomic:
Bug 13: Slow I/O under mutex¶
Bug: The mutex is held during slow I/O, serializing all callers.
Fix: Drop the lock during I/O:
mu.Lock()
if v, ok := cache[k]; ok {
mu.Unlock()
return v
}
mu.Unlock()
result := slowHTTPCall()
mu.Lock()
cache[k] = result
mu.Unlock()
return result
(But beware: this is a TOCTOU race — two goroutines may both fetch. Single-flight pattern handles it correctly.)
Bug 14: Forgotten cancellation¶
Bug: The goroutine runs forever, no cancellation.
Fix: Pass context:
func work(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doSomething()
}
}
}
ctx, cancel := context.WithCancel(parent)
defer cancel()
go work(ctx)
Bug 15: CAS on stale read¶
Bug: Concurrent writers can interleave, losing updates.
Fix: Use CompareAndSwap in a retry loop:
Bug 16: Slice header race¶
var data []int
go func() {
data = []int{1, 2, 3}
}()
go func() {
for len(data) == 0 { }
fmt.Println(data[0])
}()
Bug: A slice is a three-word header; non-atomic assignment can be observed mid-update.
Fix: Publish a pointer:
var data atomic.Pointer[[]int]
go func() {
d := []int{1, 2, 3}
data.Store(&d)
}()
go func() {
for data.Load() == nil { }
fmt.Println((*data.Load())[0])
}()
Bug 17: Lost wakeup with sync.Cond¶
Bug: if should be for. Spurious wakeups can occur; the condition must be re-checked.
Fix:
Bug 18: Mutex held during channel send¶
Bug: If ch is unbuffered or full, the send blocks while holding the mutex. Other goroutines blocked on the mutex can deadlock if they also receive from ch.
Fix: Send outside the lock, or use a select with default:
Bug 19: Read-then-CAS race¶
Bug: Between the Load and the Add, x may have changed. No atomicity for the check-then-set.
Fix: Use CAS:
Bug 20: Returning internal mutable state¶
type Cache struct {
mu sync.Mutex
m map[string]string
}
func (c *Cache) Items() map[string]string {
c.mu.Lock()
defer c.mu.Unlock()
return c.m // RACE: caller iterates after lock released
}
Bug: The returned map is the internal one. Caller iterates without lock; concurrent writes panic.
Fix: Return a copy:
func (c *Cache) Items() map[string]string {
c.mu.Lock()
defer c.mu.Unlock()
cp := make(map[string]string, len(c.m))
for k, v := range c.m {
cp[k] = v
}
return cp
}
How to Use This File¶
For each bug:
- Read the snippet.
- Identify the bug before reading the explanation.
- Compare your answer.
- Try the fix in code; verify with
go test -race.
This trains your eye to spot concurrency bugs.
End of find-bug.md.