Go / Advanced: Safety, Performance, and Service Design / Lesson 13
Race Safety and Synchronization
Give shared state one coherent access policy and justify observations through synchronization rather than scheduling guesses.
On this page
Lesson goals
- Identify conflicting unsynchronized accesses.
- Protect compound invariants with a mutex.
- Use atomic operations without assuming compound transactions become atomic.
A safe observation needs an ordering argument
A data race occurs when goroutines access the same memory concurrently, at least one access writes, and the accesses are not ordered by synchronization. Goβs memory model gives sequentially consistent behavior to data-race-free programs. Do not assign a convenient deterministic result to a racing program or treat a successful schedule as proof of safety.
Channels, mutexes, and atomics establish documented synchronization relationships. Waiting a little longer does not. A final join can make a later read safe after workers finish, but it does not repair conflicting writes that raced with one another while workers were active. Review each shared object and every access to it.
Protect every part of a shared map operation
The first program guards each map increment with one mutex and joins both workers before reporting. The lock covers the read-modify-write operation, not merely a single map assignment. Plain maps are not safe for overlapping writes or a write concurrent with a read. Concurrent read-only access is acceptable once construction is complete and safely published.
A mutex protects an invariant by agreement: all code touching that state must follow the same rule. Returning a raw map or slice from inside the protected object can let callers bypass the rule after unlocking. Prefer a copied snapshot or operations that keep access inside the component.
package main
import (
"fmt"
"sync"
)
func main() {
counts := map[string]int{"jobs": 0}
var mu sync.Mutex
var workers sync.WaitGroup
workers.Add(2)
for worker := 0; worker < 2; worker++ {
go func() {
defer workers.Done()
for i := 0; i < 1000; i++ {
mu.Lock()
counts["jobs"]++
mu.Unlock()
}
}()
}
workers.Wait()
mu.Lock()
result := counts["jobs"]
mu.Unlock()
fmt.Println("jobs", result)
}
jobs 2000
Both complete increments are protected, and Wait establishes that all 2000 updates finished.
Use a read-modify-write for a counter
sync/atomic offers atomic operations with sequentially consistent ordering as documented by Go. A Load followed by Store of old plus one can still lose an update, even though those individual operations are atomic. Add performs one read-modify-write. Choose a mutex when several fields must change under one invariant rather than assembling unrelated atomics without a proof.
The second example uses atomic.Int64, available before the baseline, and joins before the final Load. Typed atomic values must not be copied after first use. Atomic access also does not automatically make referenced collections immutable, release resources, or give a higher-level check-then-act transaction the semantics it needs.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var count atomic.Int64
var workers sync.WaitGroup
workers.Add(2)
for worker := 0; worker < 2; worker++ {
go func() {
defer workers.Done()
for i := 0; i < 1000; i++ {
count.Add(1)
}
}()
}
workers.Wait()
fmt.Println("count", count.Load())
}
count 2000
Each Add is one atomic update, so the joined result contains both thousand-increment loops.
Locking policy includes copying and ordering
A sync.Mutex or RWMutex must not be copied after first use. Use pointer receivers for a stateful type containing locks and avoid returning it by value after use. Add WaitGroup work before launching workers, not from inside a goroutine that might race with Wait seeing no work. This course uses the baseline Add/Done pattern rather than newer convenience APIs.
If several locks are needed, define a consistent acquisition order to avoid deadlock. Keep critical sections short but complete; moving a necessary read outside the lock to reduce contention can break correctness. RWMutex is not automatically faster, and sync.Map has specialized use cases rather than replacing every typed map-plus-mutex design.
Use the race detector as an exercised-path witness
Run go test -race or build -race on a supported platform with the required runtime/toolchain support. Reports identify conflicting accesses and creation stacks that help trace ownership. The detector only observes executed paths and schedules, so a clean run is evidence, not proof that every path is race-free.
Keep intentionally racing fragments non-runnable in teaching material. Test the repaired invariant with concurrent updates and a stable final result, then inspect lock coverage and shutdown. A runtime concurrent-map failure is a serious symptom, but its absence does not establish safety and recovering a panic is not a synchronization strategy.
Common errors and fixes
A final Wait is claimed to fix racing increments.
- Why it happens
- Completion ordering was confused with protection among workers.
- How to fix it
- Synchronize every conflicting update while workers run.
- Prevent it next time
- Draw ordering edges between the actual conflicting accesses.
A copied state object has ineffective locking.
- Why it happens
- Its mutex was copied after use or a value receiver copies it.
- How to fix it
- Use a stable object and pointer receivers.
- Prevent it next time
- Run vet and review copying of synchronization-bearing types.
Atomic code loses a counter update.
- Why it happens
- Separate Load and Store were treated as one transaction.
- How to fix it
- Use Add or protect the compound operation.
- Prevent it next time
- Review the entire invariant, not only individual instructions.
Check your understanding
1. A cache protects writes but reads without locking because reads are βfast.β Explain the defect and two valid designs.
Show worked solution
A read overlapping a write is still conflicting regardless of duration. Protect reads and writes with the same mutex policy, optionally using a justified RWMutex, or publish immutable complete snapshots with a correct synchronization mechanism and never mutate a published snapshot. Returning aliases into mutable protected storage must also be addressed. Speed alone does not establish ordering.
2. An account has an atomic balance and a separate atomic transaction count. Why may reading both not yield one coherent transaction snapshot?
Show worked solution
Each operation is atomic, but another goroutine can update between the two loads, so the pair can represent different stages. If the API requires a consistent pair, guard both fields and their updates under one mutex or design a validated snapshot protocol. Two atomic fields do not automatically make a multi-field invariant transactional.
Key takeaways
- Synchronize every conflicting access under one understandable policy.
- Completion and mutual exclusion solve different problems.
- Atomicity of individual fields does not imply a compound transaction.