Write Barriers in Go
A write barrier is a tiny snippet of code the Go compiler injects in front of every pointer-valued store to a heap object, active only while a garbage collection is in progress. Its job is to keep the collector’s bookkeeping correct even though the program — the mutator — keeps reshuffling pointers concurrently with marking. Since Go 1.8 the runtime uses a hybrid write barrier that fuses a Dijkstra-style insertion barrier with a Yuasa-style deletion barrier. The pairing lets the collector keep goroutine stacks permanently black after a single scan, which eliminated the end-of-cycle stop-the-world stack rescan that had been the dominant remaining pause source — dropping worst-case stop-the-world time from tens-to-hundreds of milliseconds into the sub-millisecond range (Go 1.8 hybrid barrier proposal; Hudson, ISMM keynote).
Why a Write Barrier Exists at All
Go’s collector is a concurrent tricolor mark-and-sweep collector (see Tricolor Mark and Sweep). During the mark phase it partitions every heap object into three logical colors. White objects are unproven — presumed garbage until reached. Gray objects are proven reachable but not yet scanned (their outgoing pointers have not been followed). Black objects are proven reachable and fully scanned. Marking is just the process of moving objects from white through gray to black until no gray objects remain; whatever is still white at that point is unreachable and can be swept.
This works trivially if the program is frozen. The problem is that Go’s collector runs concurrently — user goroutines (the mutator) keep executing and keep mutating pointers while marking is underway. Concurrent mutation can defeat the collector by violating the property that makes the algorithm sound. That property is the tricolor invariant, and it comes in two strengths:
- Strong tricolor invariant — no black object holds a pointer to a white object. If this holds, every white object is reachable only through gray objects, so the marker is guaranteed to eventually find every reachable object before it runs out of gray work.
- Weak tricolor invariant — any white object pointed to by a black object is also reachable from some gray object through a chain of white pointers. This is weaker but still sound: the gray object will eventually be scanned, the chain followed, and the white object shaded before marking ends.
A mutator can break the strong invariant with two pointer operations executed in the wrong order. Imagine object A is already black (scanned), object C is still white, and the only pointer to C currently lives in a gray object B. The mutator does: (1) copy the pointer B.f → C into A.g, so now a black object points to C; (2) overwrite B.f with nil, destroying the last gray reference to C. Now C is white, reachable only from black A, and the marker — which never re-scans black objects — will never find it. C gets swept while still live: a use-after-free. This is the classic “lost object” race, and the write barrier exists precisely to make it impossible.
Mental Model
flowchart TD subgraph Mutator["Mutator goroutine executing *slot = ptr"] WB["gcWriteBarrier (assembly fast path)"] end WB -->|"GC off"| Direct["plain store: *slot = ptr"] WB -->|"GC mark phase on"| Enq["enqueue old (*slot) and new (ptr)<br/>into per-P wbBuf, then store"] Enq --> Buf["per-P write barrier buffer<br/>wbBuf — 512 slots"] Buf -->|"buffer full / GC transition"| Flush["wbBufFlush: shade each pointer,<br/>push white ones onto GC work queue"] Flush --> Gray["object becomes gray<br/>(marked + queued for scanning)"]
The diagram shows the two-tier write barrier. The compiler emits a call to the assembly gcWriteBarrier before every heap pointer store. When GC is off it is a single predict-taken branch around a plain store — near-zero cost. When GC is marking, the barrier records both the overwritten pointer and the newly written pointer into a small per-P buffer; when that buffer fills, wbBufFlush shades the recorded pointers gray. The insight: the barrier is cheap precisely because it does not shade inline — it just appends to a buffer, and the expensive shading is batched and amortized.
The Hybrid Barrier — Insertion Plus Deletion
The barrier Go uses today is documented verbatim in the runtime source (mbarrier.go). In pseudocode:
writePointer(slot, ptr):
shade(*slot) // Yuasa deletion barrier
if current stack is grey: // Dijkstra insertion barrier,
shade(ptr) // conditional on the writer's stack
*slot = ptrslot is the destination field, *slot is the pointer being overwritten (the old value), and ptr is the pointer being installed (the new value). shade(x) means: if x points at a white object, mark that object and push it onto the gray work queue.
The two halves descend from two classical barrier designs:
The Dijkstra insertion barrier — shade(ptr) — was Go’s original Go 1.5/1.7 barrier. It shades whatever pointer is being written into a slot. This directly enforces the strong invariant: a black object can never come to hold an unshaded white pointer, because the act of writing the pointer shades its target. Insertion barriers are conceptually simple, but they have a fatal cost for stacks: writing a write barrier on every stack store is prohibitively expensive (stack writes are the hottest writes in any program, and most do not even involve pointers). So Go 1.7 left stacks unbarriered and made them “permagrey” — a scanned stack instantly reverted to gray the moment its goroutine resumed, because any subsequent unbarriered stack write could have smuggled a white pointer onto it. Permagrey stacks then had to be re-scanned during a stop-the-world pause at mark termination, and with many active goroutines that rescan took “10’s to 100’s of milliseconds” (rescan-elimination proposal). This rescan STW was the single largest pause remaining in Go 1.7.
The Yuasa deletion barrier — shade(*slot) — shades whatever pointer is being overwritten. The intuition: a mutator can only hide an object by destroying a path to it; if every pointer destruction first shades the victim, no path can vanish before the collector has seen its endpoint. A deletion barrier enforces the weak invariant — it guarantees that an object cannot become unreachable-from-gray purely by mutation. A pure Yuasa barrier has its own awkwardness: it requires a stop-the-world snapshot of all stacks at the start of the cycle to establish the initial gray set.
Go 1.8’s insight, formalized by Austin Clements, was that combining the two barriers gets the best of both. The reasoning, quoted from mbarrier.go:
shade(*slot)prevents the mutator from hiding an object by moving the sole pointer to it out of the heap and onto a stack — the unlink shades it.shade(ptr)prevents the mutator from hiding an object by moving the sole pointer to it off a stack and into a black heap object — the install shades it.- Once a goroutine’s stack is black,
shade(ptr)becomes unnecessary. After a stack is scanned it points only to shaded objects, so it cannot be hiding anything; andshade(*slot)already prevents it from hiding any new pointers thereafter. This is why the insertion half is conditional on the writer’s stack being gray — it is needed exactly until that goroutine’s stack has been scanned, and never again.
The payoff is decisive: because point 3 holds, a stack only needs to be scanned once per GC cycle, and stays black for the rest of the cycle. There is no permagrey, no rescan, no end-of-cycle stop-the-world stack walk. The hybrid barrier satisfies the weak invariant rather than the strong one, and the rescan-elimination proposal includes a correctness proof that the weak invariant is sufficient.
Mechanical Walk-through — What the Compiler and Runtime Actually Do
Write barriers are not emitted by the programmer; the compiler inserts them. For every assignment of a pointer-typed value into a location that might be a heap object, the compiler replaces the bare store with a call to the runtime barrier. The compiler omits the barrier for writes to the current stack frame — those are known-stack and known-cheap — but if a pointer into a stack frame has been passed down the call chain, the compiler conservatively emits a barrier for writes through it, because it can no longer prove the target is not the heap (mbarrier.go). Writes of heap pointers into package-level globals also get barriers, so the collector never has to rescan globals at mark termination.
The barrier has a deliberately split fast/slow structure (mwbbuf.go):
- Fast path — the assembly routine
gcWriteBarrier. It is written in assembly precisely so it clobbers no general-purpose registers, meaning the compiler does not have to spill registers around every pointer store. When GC is off, it is one branch on a global flag, predicted taken, around a plain store. When GC is on, it appends the old and new pointer values to a per-P write barrier buffer (wbBuf) and returns. Appending to a thread-local buffer is far cheaper than shading inline. - Slow path —
wbBufFlush. ThewbBufholdswbBufEntries = 512pointer slots. When it fills, the barrier callswbBufFlush, which drains the buffer into the GC’s global work queues, shading every white pointer it finds. Because the assembly fast path did not spill registers, the flush path spills all registers and forbids any GC-observable safe point while doing so (mwbbuf.go).
The wbBuf is structurally a sequential store buffer (SSB). The 512-entry size is a latency-versus-throughput knob: a larger buffer amortizes the per-flush overhead better but lengthens the worst-case stall when a flush does happen, and enlarges the buffer’s cache footprint.
One subtlety the source comments dwell on: the barrier shades both pointers unconditionally on the color of the slot’s container. In principle a Yuasa or Dijkstra barrier can be made conditional on whether the object holding the slot is black. Go deliberately does not do this. Making it conditional would require an expensive hardware memory barrier between the pointer store and the load of the container’s mark bit, to prevent a store/load reordering on amd64/386 from letting both the mutator and the collector observe a stale value and miss the shading. Rather than pay for a fence on every pointer write, Go always shades. Relatedly, the barrier is pre-publication: it runs before the *slot = ptr store, so the shading is recorded before the new pointer can become reachable by any other goroutine.
Code Example — Seeing the Barrier in Generated Assembly
package main
type Node struct {
next *Node
data int
}
//go:noinline
func link(parent *Node, child *Node) {
parent.next = child // pointer store into a heap object -> write barrier
}Compiling this with the Go 1.26.1 amd64 toolchain (go build -gcflags=-S) produces the following for the body of link (instructions reordered slightly here for readability; this is a real dump, lightly trimmed of PCDATA/FUNCDATA bookkeeping):
CMPL runtime.writeBarrier(SB), $0 // is GC marking? compare the global flag against 0
JEQ 36 // flag == 0 (GC off): jump straight to the plain store
MOVQ (AX), CX // GC on: load the OLD pointer being overwritten (*slot)
CALL runtime.gcWriteBarrier2(SB) // set up the per-P wbBuf append (returns a slot ptr in R11)
MOVQ BX, (R11) // record the NEW pointer (child) into the buffer
MOVQ CX, 8(R11) // record the OLD pointer (*slot) into the buffer
36:
MOVQ BX, (AX) // the actual store: parent.next = child
RETLine-by-line: CMPL runtime.writeBarrier(SB), $0 compares the global writeBarrier.enabled flag — non-zero only during the mark phase — against zero. JEQ 36 is the key branch: when the flag is zero (GC off) it jumps directly to offset 36, the plain MOVQ BX, (AX) store, skipping all barrier work; this is the steady-state hot path the hardware predicts taken. When GC is marking, control falls through into the barrier block: MOVQ (AX), CX loads the old pointer being overwritten, CALL runtime.gcWriteBarrier2 arranges the per-P wbBuf append (returning the buffer slot address in R11), the two MOVQ ... (R11) instructions write the new and old pointers into the buffer, and only then does the same MOVQ BX, (AX) store run. Two details worth noting against older write-ups: the runtime entry point is gcWriteBarrier2 (a register-ABI variant, one of a family gcWriteBarrier1…gcWriteBarrier8 keyed by how many pointers are buffered), not a bare gcWriteBarrier, and arguments arrive in registers (AX=slot, BX=new value) under Go’s register-based ABIInternal, not on the stack. The structure, however, is exactly as described: flag check → predicted-taken branch around the barrier → buffered append → store. This is the concrete realization of “the barrier is free when GC is off.”
A second example shows where the compiler elides the barrier:
func localOnly() *Node {
n := &Node{data: 1}
n.next = &Node{data: 2} // n may still be stack-allocated; if escape
return n // analysis keeps it on the stack, no barrier
}If Escape Analysis proves the Node values stay on the stack, the stores are stack writes and the compiler emits no barrier — another reason stack-allocated, short-lived data is cheap in Go: it costs the collector nothing.
Common Misunderstandings
“The write barrier is always running and slows every program down.” False. The barrier’s fast path is a single predicted branch when GC is off, which is the steady state between collections. Real cost is incurred only during the mark phase, and even then it is an append to a thread-local buffer, not inline shading.
“It is a memory barrier / fence.” No. Despite the name, a GC write barrier is not a CPU memory-ordering fence. It is a collector notification hook. The mbarrier.go comments specifically explain that Go avoids needing a hardware fence by shading unconditionally.
“The barrier moves objects.” No. Go’s collector is non-moving — the barrier never relocates anything; it only shades (marks gray). This is distinct from the read/write barriers in moving collectors that fix up pointers.
“The Go 1.26 Green Tea collector changed the write barrier.” No. The Green Tea Garbage Collector redesigned only the marking/scanning phase (it queues 8 KiB spans instead of individual objects for better cache locality); the hybrid write barrier described here is untouched. The barrier still shades on every heap pointer store during marking, regardless of whether the marker is the legacy graph flood or Green Tea’s span scan. “Shading” under Green Tea simply means flipping the object’s inline marks bit and enqueuing its span rather than pushing the object onto a workbuf — a downstream detail of how the shaded object is later scanned, not a change to when the barrier fires.
“shade(ptr) is always needed.” No — it is conditional on the writing goroutine’s stack still being gray. After that goroutine’s stack has been scanned, only the deletion half (shade(*slot)) runs for its writes. This conditionality is the whole reason rescans were eliminated.
“It protects against the mutator reading garbage.” No. There is no read barrier in Go. The write barrier protects the collector’s view of reachability, not the mutator’s reads.
Alternatives and When They Apply
A pure Dijkstra insertion barrier (Go 1.5–1.7) enforces the strong invariant but, lacking cheap stack barriers, forces permagrey stacks and an O(stacks) stop-the-world rescan — exactly the pause the hybrid barrier removed. A pure Yuasa deletion barrier (snapshot-at-the-beginning) needs an initial STW snapshot of all stacks. A moving / copying collector can instead use a read barrier (or a Brooks-style forwarding pointer) and avoid a mark-phase write barrier, but Go rejected moving collection — interior pointers and cgo make pinning painful (see Go Garbage Collector). Reference counting needs a different barrier on every pointer assignment and deletion, and cannot collect cycles. The hybrid barrier is Go’s chosen point in this space: cheap-when-off, no stack barriers, no rescan, sound under the weak invariant.
Production Notes
The hybrid barrier shipped in Go 1.8 (February 2017) and, alongside earlier work, drove worst-case stop-the-world pauses from the multi-millisecond range to consistently sub-millisecond — frequently 100–200 microseconds in production services (Hudson, ISMM keynote). The rescan-elimination proposal targeted “worst-case STW time under 50µs” and also simplified the runtime by deleting stack barriers and rescan lists entirely.
The barrier is still not free during marking, and its cost is proportional to pointer-write frequency. Workloads that mutate large pointer-dense structures (big graphs, trees, linked lists) under load pay measurable barrier overhead during GC and can show runtime.gcWriteBarrier and runtime.wbBufFlush in CPU profiles. The standard mitigations are the same ones that reduce GC pressure generally: keep short-lived data on the stack (helped by Escape Analysis), prefer value types and contiguous slices over pointer-heavy structures, and reuse buffers via sync.Pool Internals. The barrier interacts with Goroutine Preemption and the GMP Scheduler Model: because the wbBuf is per-P and must not be observed mid-update, the runtime forbids preemption and GC safe points across the buffer-append-then-store sequence (mwbbuf.go — the slow path “spills all registers and disallows any GC safe points”).
See Also
- Tricolor Mark and Sweep — the algorithm the barrier keeps correct
- Go Garbage Collector — the concurrent non-moving collector overview
- Green Tea Garbage Collector — the Go 1.26 marking-phase redesign; leaves this barrier unchanged
- Stop-the-World Pauses — what the hybrid barrier shrank, and what STW phases remain
- GC Assists and Mark Workers — who does the shading work the barrier feeds
- GC Pacing and GOGC — when the mark phase (and thus the barrier) is active
- Goroutine Stacks — why stack writes are unbarriered and stacks scanned once
- Escape Analysis — decides which writes are stack writes and need no barrier
- Go Internals MOC — section 7, Garbage Collection