Sequential Consistency — Find the Bug¶
Bug-finding exercises focused on memory-model violations and SC-related issues. Each exercise shows code with a subtle bug; identify it and propose a fix.
Bug 1: The Hanging Loop¶
package main
import (
"fmt"
"time"
)
var ready bool
func main() {
go func() {
time.Sleep(10 * time.Millisecond)
ready = true
}()
for !ready {
}
fmt.Println("ready!")
}
Bug: ready is a plain bool. The compiler may hoist the read out of the loop, causing the goroutine to spin forever.
Fix: Use atomic.Bool:
Bug 2: The Lost Increment¶
package main
import (
"fmt"
"sync"
)
var counter int
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println(counter)
}
Bug: counter++ is read-modify-write, not atomic. Concurrent goroutines lose increments.
Fix: Use atomic.Int64:
Bug 3: The Half-Constructed Pointer¶
package main
import (
"fmt"
"sync/atomic"
)
type Config struct {
A int
B int
}
var cfg atomic.Pointer[Config]
func init() {
c := &Config{}
cfg.Store(c)
c.A = 1
c.B = 2
}
func main() {
if c := cfg.Load(); c != nil {
fmt.Println(c.A, c.B)
}
}
Bug: Setting fields after cfg.Store(c) is a race with any reader of c.A or c.B.
Fix: Set fields before Store:
Bug 4: The Mixed Access¶
package main
import (
"fmt"
"sync"
"sync/atomic"
)
var counter int64
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
fmt.Println(counter) // plain read of atomic-written variable
}
Bug: The fmt.Println(counter) reads counter plainly. After wg.Wait(), this is technically safe (Wait synchronises with Done), but mixing atomic writes with plain reads is fragile and the race detector may flag inconsistent treatment.
Fix: Use atomic everywhere:
fmt.Println(atomic.LoadInt64(&counter))
// or, with typed API:
var counter atomic.Int64
counter.Add(1)
fmt.Println(counter.Load())
Bug 5: The Copying Atomic¶
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Counter struct {
n atomic.Int64
}
func work(c Counter) {
c.n.Add(1) // operates on a copy
}
func main() {
var c Counter
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
work(c)
}()
}
wg.Wait()
fmt.Println(c.n.Load()) // always 0
}
Bug: Passing c by value copies the atomic. The increments are on copies.
Fix: Pass by pointer:
Bug 6: The Mutated Published Pointer¶
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Item struct{ Name string }
var current atomic.Pointer[Item]
func setName(s string) {
if i := current.Load(); i != nil {
i.Name = s // RACE
}
}
func main() {
current.Store(&Item{Name: "initial"})
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
setName(fmt.Sprintf("name-%d", i))
}()
}
wg.Wait()
fmt.Println(current.Load().Name)
}
Bug: Mutating the pointed-to struct after publication races with other goroutines.
Fix: Swap pointers:
Bug 7: The Time-Based Sync¶
package main
import (
"fmt"
"time"
)
var data int
func main() {
go func() {
data = 42
}()
time.Sleep(time.Millisecond)
fmt.Println(data)
}
Bug: time.Sleep is not a memory barrier. The write may not be visible.
Fix: Use a synchronisation primitive:
Bug 8: The False-Shared Counters¶
package main
import (
"sync"
"sync/atomic"
)
type Stats struct {
A atomic.Int64
B atomic.Int64
C atomic.Int64
D atomic.Int64
}
func main() {
var s Stats
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000000; j++ {
switch i {
case 0: s.A.Add(1)
case 1: s.B.Add(1)
case 2: s.C.Add(1)
case 3: s.D.Add(1)
}
}
}()
}
wg.Wait()
}
Bug: All four fields fit in one cache line. Concurrent updates cause false sharing; performance plateaus.
Fix: Pad to cache lines:
type Stats struct {
A atomic.Int64; _ [56]byte
B atomic.Int64; _ [56]byte
C atomic.Int64; _ [56]byte
D atomic.Int64; _ [56]byte
}
Bug 9: The Singleton Race¶
package main
import (
"sync"
)
type Singleton struct{ Name string }
var instance *Singleton
var mu sync.Mutex
func Get() *Singleton {
if instance == nil { // RACE
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &Singleton{Name: "default"}
}
}
return instance
}
Bug: First if instance == nil is racy with the write instance = ....
Fix: Use atomic.Pointer[T]:
var instance atomic.Pointer[Singleton]
func Get() *Singleton {
if s := instance.Load(); s != nil {
return s
}
mu.Lock()
defer mu.Unlock()
if s := instance.Load(); s != nil {
return s
}
s := &Singleton{Name: "default"}
instance.Store(s)
return s
}
Bug 10: The Atomic-of-Plain¶
package main
import (
"fmt"
"sync"
"sync/atomic"
)
var done bool
func main() {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
atomic.StoreInt32((*int32)(unsafe.Pointer(&done)), 1) // unsafe cast
}()
go func() {
defer wg.Done()
for !done {
}
}()
wg.Wait()
fmt.Println("done")
}
Bug: Storing via atomic but reading via plain. The race detector will flag (the loop reads done plainly). Also, bool is not int32; the cast is fragile.
Fix: Use atomic.Bool:
Bug 11: The Sneaky Map Race¶
package main
import "sync"
var cache = map[string]string{}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
cache[fmt.Sprint(i)] = fmt.Sprint(i * 2)
}()
}
wg.Wait()
}
Bug: Built-in maps are not concurrent-safe.
Fix: Use sync.Map or a mutex-protected map:
Bug 12: The Wait Group Misuse¶
package main
import "sync"
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
go func() {
wg.Add(1) // BUG: Add inside goroutine
defer wg.Done()
// work
}()
}
wg.Wait()
}
Bug: Add is called inside the goroutine. Wait may run before any Add, returning immediately.
Fix: Add before go:
Bug 13: The Spin Without Yield¶
package main
import (
"fmt"
"sync/atomic"
"time"
)
func main() {
var ready atomic.Bool
go func() {
time.Sleep(time.Second)
ready.Store(true)
}()
for !ready.Load() {
// tight spin
}
fmt.Println("ready")
}
Bug: The tight spin wastes CPU and may starve the producer goroutine.
Fix: Add runtime.Gosched or use a channel:
Bug 14: The Closed-Channel Panic¶
package main
import "sync"
func main() {
ch := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
close(ch) // RACE / panic
}()
}
wg.Wait()
}
Bug: Multiple goroutines closing the same channel cause a panic.
Fix: Use sync.Once:
Bug 15: The Stale Field After Publish¶
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type State struct {
Step int
Msg string
}
var cur atomic.Pointer[State]
func main() {
s := &State{}
cur.Store(s)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
s.Step = 1
s.Msg = "hello"
}()
wg.Wait()
fmt.Println(cur.Load().Msg)
}
Bug: Storing the pointer before populating the fields. Concurrent readers may see partial state.
Fix: Build the struct first, then Store:
Bug 16: The Forgotten Mutex¶
package main
import (
"fmt"
"sync"
)
type Cache struct {
mu sync.Mutex
m map[string]string
}
func (c *Cache) Get(k string) string {
return c.m[k] // missing lock
}
func (c *Cache) Set(k, v string) {
c.mu.Lock()
c.m[k] = v
c.mu.Unlock()
}
func main() {
c := &Cache{m: map[string]string{}}
// concurrent Get/Set races
_ = c
fmt.Println("done")
}
Bug: Get doesn't lock; races with Set.
Fix: Lock in Get too, or use sync.RWMutex with RLock.
Bug 17: The Loop Variable¶
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i) // captures loop variable
}()
}
wg.Wait()
}
Bug: Pre-Go 1.22, i is shared across iterations; goroutines may all print the same value.
Fix: Use Go 1.22+ semantics (each iteration has its own variable), or shadow explicitly:
Bug 18: The Channel Reuse¶
package main
import "fmt"
func main() {
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
fmt.Println("done")
}
Bug: Sending on a closed channel panics.
Fix: Don't reuse closed channels. Use sync.Once for one-shot close. Use new channel for new use.
Bug 19: The Nil-Check Race¶
package main
import "sync/atomic"
type Service struct{}
var svc *Service
func init() {
svc = &Service{}
}
func use() {
if svc != nil { // racy if svc could be reassigned
// do something
}
}
Bug: If svc is reassigned by another goroutine, the nil-check races.
Fix: Use atomic.Pointer[Service]:
var svc atomic.Pointer[Service]
func init() { svc.Store(&Service{}) }
func use() {
if s := svc.Load(); s != nil {
// use s
}
}
Bug 20: The Order-of-Operations¶
package main
import (
"fmt"
"sync/atomic"
)
type State struct {
Counter atomic.Int64
Done atomic.Bool
}
func writer(s *State) {
s.Counter.Add(1)
s.Done.Store(true)
}
func reader(s *State) {
if s.Done.Load() {
fmt.Println(s.Counter.Load())
}
}
Bug: None — this is correct. The SC ordering ensures Counter.Add happens-before Done.Store, and the reader's Done.Load == true implies it sees the post-Add counter.
This is a "no bug" example. Important to recognise correct code.
Closing¶
These 20 bugs cover the spectrum of common SC-related issues. Each is real; each has been seen in production code.
For each bug: 1. Run with -race. 2. Identify the violation. 3. Apply the fix. 4. Re-test.
Mastering bug-finding is the practical skill of senior Go engineers.
End.