skip to content

A tick goroutine writes Player fields through a *Player while another does snap := *p — is that safe?

level: seniorimportance: nice to knowfreq 34%

answer

  1. A copy is several loads, not one
  2. Ask what happens mid-copy
  3. Fields from two different ticks
  4. Value semantics are not synchronisation
  5. Freeze it before you publish the pointer

basics

~20 s

No. Copying a struct is a field-by-field read, not an atomic operation, so the snapshot can mix new and old fields while the writer is mid-update. It is a data race, the race detector reports it, and the fix is a lock or publishing whole immutable snapshots.

solid answer

~50 s

It is a data race. `snap := *p` compiles to a sequence of loads — one per word of the struct — with no synchronisation, so a reader can observe `X` from tick 41 and `Y` from tick 40 and produce an entity that never existed. "I took a copy, so it is mine" is true only *after* the copy completes; the copying itself is a concurrent read of memory another goroutine is writing. Build with `-race` and the detector prints both goroutine stacks and the field's address, though only for interleavings it actually observes at run time — a clean run is not a proof. Two fixes fit a tick loop. Guard reads and writes with a `sync.Mutex` or `sync.RWMutex`. Or, if you want readers to stay lock-free, make the tick goroutine the only writer and publish: build the next `Player` privately, then `atomic.Pointer[Player].Store(&next)`, and readers `Load()` a pointer to a struct that is never mutated after publication.

code

go · 17 lines
go
type Player struct {
	X, Y int
}

var cur atomic.Pointer[Player]

// the tick goroutine is the only writer
func tick(nx, ny int) {
	next := *cur.Load() // copy the frozen published value
	next.X, next.Y = nx, ny
	cur.Store(&next) // publish; next is never mutated again
}

// any number of reader goroutines
func snapshot() Player {
	return *cur.Load()
}

go deeper

for a junior

Remember the headline: copying a struct is not atomic, so reading one while another goroutine writes it is a data race. Know that -race exists and how to turn it on for tests.

for a middle

Explain why the copy tears — several loads with no ordering — and describe what a race report tells you, including that the detector is dynamic rather than a static check.

for a senior

Demonstrate the operational judgment: confirm with -race under the real workload, then choose between a mutex and single-writer copy-on-write publication, and be able to say what each costs in latency, allocation and staleness.

for a principal

Own the concurrency posture for the whole service: who may hold a pointer to a mutable entity, whether entities are shared-with-locking or published-immutable, and whether -race in CI is a gate or an advisory.

## What `snap := *p` compiles to A struct copy in Go is not a single machine instruction and is not guaranteed to be atomic at any size beyond a single word. `snap := *p` for a `Player{X, Y int; Name string}` becomes several loads from the pointed-to memory and several stores into `snap`. If another goroutine is writing those same fields concurrently, the loads and stores interleave arbitrarily. The visible failure is a **torn value**: a snapshot whose `X` comes from the update the tick goroutine just finished and whose `Y` is still the previous tick's. No individual field is corrupt; the *combination* never existed in the program's history. In a game-server tick loop that shows up as an entity that appears to have teleported diagonally, or a position that fails an invariant check that the writer never violated. Go's memory model does not rescue you. It defines what a correctly synchronised program observes and states that a program with a data race is faulty; for races it offers only limited guarantees on word-sized accesses, and none at all for a multi-word struct copy. Nothing in the language promises the copy is a snapshot. ## Confirming it: the race detector Build or run with `-race` — `go test -race ./...`, `go run -race .`, or `go build -race` for a canary binary. The detector instruments memory accesses and maintains happens-before information at run time; when two goroutines touch the same address without an ordering between them and at least one writes, it prints a report naming the reading goroutine's stack, the writing goroutine's stack, and where each goroutine was created. Two properties matter for how you use it: - It is a **dynamic** tool. It finds races on the interleavings that actually occurred during that run. A clean run over a code path you never exercised proves nothing, which is why teams run their whole test suite under `-race` in CI rather than spot-checking. - It costs roughly an order of magnitude in memory and several times the CPU, so it is a test and staging tool, not a production build — though a single `-race` canary instance is a legitimate tactic for a race you cannot reproduce locally. If the race is in the tick loop's hot path, this is also where the "make it faster without changing behaviour" instinct gets people into trouble: removing a lock because a profile blamed it, on the argument that "the reader only reads a copy", is exactly the change this question is about. ## Fix one: synchronise The boring, correct answer is a mutex owned by the entity or by the world: ```go mu.RLock() snap := *p mu.RUnlock() ``` with the tick goroutine taking `mu.Lock()` around its writes. A `sync.RWMutex` lets many readers copy concurrently, which fits a tick loop with one writer and several readers — but `RWMutex` has more overhead per operation than a plain `Mutex` and only pays off when read critical sections are long enough or contended enough to matter. Measure rather than assume. ## Fix two: publish immutable snapshots The lock-free shape that suits a single-writer tick loop is copy-on-write publication. The tick goroutine builds the next state privately, then swaps in a pointer to it: ```go var cur atomic.Pointer[Player] // tick goroutine, the only writer next := *cur.Load() next.X, next.Y = nx, ny cur.Store(&next) // any reader snap := *cur.Load() ``` The crucial discipline is that **a published `Player` is never mutated again**. Readers only ever dereference a pointer to a frozen struct, and the atomic pointer load/store provides the ordering, so there is no race and readers never block. The cost is one allocation per tick per entity and slightly staler reads. Note also what `atomic.Pointer` does *not* do: it makes the pointer swap atomic, not the struct. Storing a pointer and then editing the struct behind it puts the race straight back. A third shape, and often the most Go-ish, is to stop sharing: let the tick goroutine own the entities outright and answer queries over a channel, so no other goroutine holds a `*Player` at all. That trades throughput for a design where the race cannot be written. ## The reasoning to demonstrate The misconception being tested is that value semantics are a synchronisation mechanism. They are not. Copying gives you an *isolated* value once you have it; it says nothing about whether the memory you read was stable while you read it. The rule to state out loud is: **the copy protects you from future writes by others, never from concurrent ones.** Any time a pointer to mutable state crosses a goroutine boundary, something — a mutex, a channel handoff, or an atomic publication of immutable data — has to establish the ordering.

  • The race detector ran clean on the test suite. Is the code race-free?
    No. It is a dynamic detector: it reports races on the interleavings that actually happened during that run, over the code paths the tests exercised. Unexercised paths, rare orderings and production-only concurrency levels are invisible to it. A clean run raises confidence; only reasoning about the synchronisation discipline, plus keeping the whole suite under -race in CI, establishes correctness.
  • Does atomic.Pointer[Player] make the Player itself safe to mutate?
    No, and this is the common misreading. It makes the pointer swap atomic and ordered; the struct behind the pointer has no protection at all. The pattern only works if a published Player is treated as immutable — the writer builds a fresh one, stores its address, and never writes through a pointer it has already published.
  • Would sync.RWMutex be a better choice than sync.Mutex for the tick loop's readers?
    Only if readers hold the lock long enough or often enough for the parallelism to pay. RWMutex costs more per lock and unlock than Mutex, and for a critical section as short as copying a small struct the extra bookkeeping can exceed the contention it removes. Benchmark both under the real reader count before choosing.
  • Is copying a single word-sized field concurrently also a race?
    Yes. Go's race detector flags it and the memory model classes it as a race regardless of width; you get no ordering guarantee with the rest of the program even if that one word cannot tear on your machine. If you need an unsynchronised single value, use the sync/atomic types, which express the intent and give you defined semantics.

saying these in an interview costs you the question

  • Says taking a copy makes concurrent access safe
  • Assumes a struct assignment is atomic
  • Thinks reads alone can never race
  • Treats a clean -race run as proof of correctness
  • Believes atomic.Pointer protects the pointed-to struct
  • Reaches for a volatile keyword, which Go does not have
  • Ships a -race build to production to be safe