skip to content

In Go, which writes actually go through the GC write barrier, and when is it switched on?

level: middleimportance: nice to knowfreq 26%

answer

  1. not every assignment is a pointer
  2. your own stack slots are exempt
  3. a flag decides whether it does anything
  4. the flag flips inside the two pauses
  5. off means load, test, plain store

basics

~20 s

Only pointer-typed stores into memory the collector traces — heap object fields and package-level variables. Writes of pointer-free data and writes to locals on a goroutine's own stack get nothing. The barrier does real work only during the mark phase, between the cycle's two pauses.

solid answer

~50 s

The compiler emits a barrier only where a pointer is stored into memory the collector traces: a pointer field of a heap object, a pointer element of a heap slice or map, a package-level pointer variable. Storing an `int`, a `float64` or a struct with no pointer fields emits nothing, and writes to locals on the goroutine's own stack are deliberately unbarriered. The barrier is also only *active* during marking: at every barriered store site the compiled code first tests a runtime flag, and when no collection is marking it falls through to a plain store. So the always-present cost is a predictable load and branch, while the real cost — recording the old and new pointers into a per-P buffer for the marker — is paid only between the cycle's two stop-the-world points. That is why a pointer-mutation-heavy loop can measurably slow down while a cycle is in flight and speed back up between cycles.

code

go · 9 lines
go
type node struct {
	next *node
	n    int
}

func update(x, y *node) {
	x.n = 42   // pointer-free field: no write barrier
	x.next = y // pointer into a heap object: barriered store
}

go deeper

for a junior

It is enough to know that the collector needs to see pointer writes, so the compiler adds a little extra code at pointer stores, and that plain numeric fields cost nothing extra.

for a middle

Be precise about the two axes: which stores are barriered (pointers into traced memory, not locals, not pointer-free data) and when the barrier is active (only during the mark phase). Both halves are usually probed.

for a senior

Demonstrate you can turn this into measurement — count barriered store sites in a hot function from a disassembly and connect their number to throughput that sags only while cycles are marking.

for a principal

Be able to argue about data representation as a performance lever: how pointer-dense your core structures are decides how much of the collector's cost your own hot paths absorb, and that is a design decision, not a tuning knob.

## Which stores get a barrier The write barrier exists so the collector can observe changes to the object graph. Only stores that can change that graph need one, which narrows it a lot: **Barriered:** - Storing a pointer into a field of a heap-allocated struct (`n.next = other`). - Storing a pointer into a heap slice or array element, or a map value that contains pointers. - Assigning to a package-level variable of pointer type. - Assigning an interface value, a slice header, a string or a `map`/`chan`/`func` value — these are multi-word values that *contain* pointers, so the pointer words go through barrier-aware runtime copy routines rather than a naked store. **Not barriered:** - Any store of a pointer-free type: `int`, `float64`, `bool`, an array of numbers, a struct made only of such fields. There is no reference for the collector to lose. - Writes to local variables living on the current goroutine's stack. Stack slots are written constantly, and barriering them would be ruinous; the collector's design accounts for this by scanning each stack once and by preserving overwritten pointers elsewhere. ## When it is switched on A barrier is not a permanent tax on every pointer store. The runtime keeps a flag saying whether marking is in progress, and: - **Outside the mark phase** — including while sweeping, and during the long stretches when no collection is running at all — the flag is off. A barriered store site loads the flag, tests it, and takes the plain-store fast path. - **During the mark phase** — the flag is on, and each barriered store also records the pointer being overwritten and the pointer being stored into a small per-P write-barrier buffer. The runtime drains that buffer into marking work in batches. The flag is flipped inside the cycle's stop-the-world points precisely so that no goroutine can be writing without a barrier while another is already marking. ## What that means for cost There are two costs, and it helps to keep them apart: 1. **Always paid:** the load, the test and the branch in front of every barriered store, plus the code-size and register pressure of having a slow path there at all. The branch is perfectly predicted in steady state, so on a modern CPU this is small — but it is not zero, and it is one reason a pointer field write is never quite as cheap as an `int` field write. 2. **Paid during marking:** the buffered recording of two pointer words per store, and eventually the marking work those pointers generate. This is proportional to how many pointer stores your hot path performs while a cycle is in flight. The practical fingerprint of cost 2 is a workload whose throughput sags while collections are marking and recovers between them, with the sag scaling with how pointer-dense the mutated structure is. ## Seeing it for yourself Disassembling the function makes the shape obvious. `go tool objdump` on a built binary, or `go build -gcflags=-S` at compile time, shows a barriered pointer store as a test of the runtime's barrier flag, a branch, and a slow path alongside the plain store — while the neighbouring `int` field store in the same function is a single instruction. Counting barriered store sites in a hot function is a legitimate, cheap way to reason about how much barrier work one logical update performs. ## Things people get wrong - *Every assignment in Go goes through the barrier.* No — only pointer-carrying stores to traced memory. - *The barrier is always doing work.* No — outside marking it is a predicted branch that falls through. - *Local variables are barriered.* No, and that omission is a load-bearing part of the collector's design. - *Reads are barriered too.* Go has no read barrier; the collector is non-moving, so a read never needs to be intercepted. - *You can turn it off.* You cannot. Bypassing it with `unsafe` tricks does not make your program faster in any way you can rely on; it makes it wrong.

  • Does a Go program pay anything for the write barrier when no collection is running?
    A little. The barrier's fast-path check is compiled into every barriered store site, so each such store loads and tests a runtime flag before doing a plain store, and the function carries a slow path it may never take. The branch is well predicted, so the steady-state cost is small — but a pointer field write is still never as cheap as an int field write.
  • Does Go have a read barrier as well?
    No. The collector never moves a live object, so a pointer read always yields a valid address and needs no interception. Collectors that relocate objects concurrently do need read or load barriers to redirect stale references; Go trades away compaction and gets to keep reads free.
  • How would you check whether a particular function performs barriered stores?
    Disassemble it. `go tool objdump` on the built binary, or `go build -gcflags=-S`, shows a pointer store into heap memory as a test of the runtime's barrier flag plus a branch to a slow path, next to the single-instruction store for a pointer-free field. Counting those sites tells you how much barrier work one update performs.

saying these in an interview costs you the question

  • Says every assignment in Go goes through a write barrier
  • Thinks the barrier is doing work even when no cycle is marking
  • Claims writes to local variables are barriered
  • Believes Go also has a read barrier
  • Says the barrier can be disabled with a flag or build option