Course outline

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.

About 5 min readContent reviewed
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.

go A synchronized shared map count
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)
}
Expected output
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.

go An exact atomic counter
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())
}
Expected output
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.

Official references