Go's Hybrid Write Barrier: Why the GC Can Mark While You Write
Go's write barrier is a hybrid of a Yuasa deletion barrier and a Dijkstra insertion barrier, buffered per-P, so that a concurrent collector never misses an object the mutator moved.
TL;DR: Go’s write barrier is a hybrid: a Yuasa deletion barrier that shades the object you just unlinked, plus a Dijkstra insertion barrier that shades the object you just linked. Neither half is optional — drop one and the mutator can hide an object from the collector. The barrier does not do the shading itself: it appends the old and new pointer values to a 512-entry per-P buffer, and the flush greys both. When the GC is off, the whole thing is a single byte test.
A concurrent garbage collector has a problem that a stop-the-world collector never has: the program keeps running while the collector walks the heap. If a goroutine writes a pointer into a heap object while the collector is halfway through deciding what is reachable, the collector can reach a wrong conclusion about liveness — and it can free something that is still in use.
The classic example is the “hide the object” trick. A collector marks everything reachable from the roots. A mutator holds the only pointer to an object in a register, and writes that pointer into a heap object the collector has already scanned. The collector never sees the pointer, concludes the object is garbage, and frees it. The mutator then dereferences freed memory.
Go’s answer is the write barrier: every pointer store into the heap is intercepted, and the collector is told about it. This is a walk through how that works in the Go 1.28 development tree, from the algorithmic description down to the assembly.
The hybrid barrier, in the runtime’s own words
The whole design is written out at the top of mbarrier.go:
// src/runtime/mbarrier.go:24
// Go uses a hybrid barrier that combines a Yuasa-style deletion
// barrier—which shades the object whose reference is being
// overwritten—with Dijkstra insertion barrier—which shades the object
// whose reference is being written. The insertion part of the barrier
// is necessary while the calling goroutine's stack is grey. In
// pseudocode, the barrier is:
//
// writePointer(slot, ptr):
// shade(*slot)
// if current stack is grey:
// shade(ptr)
// *slot = ptr
//
// slot is the destination in Go code.
// ptr is the value that goes into the slot in Go code.
//
// Shade indicates that it has seen a white pointer by adding the referent
// to wbuf as well as marking it.“Shade” is the collector’s word for “make this object grey” — marked, but not yet scanned. Shading is the expensive part (it has to find the object’s span and set its mark bit); recording that you shaded something is cheap.
Why one barrier is not enough
The two halves exist for two different ways of hiding an object.
// src/runtime/mbarrier.go:43
// The two shades and the condition work together to prevent a mutator
// from hiding an object from the garbage collector:
//
// 1. shade(*slot) prevents a mutator from hiding an object by moving
// the sole pointer to it from the heap to its stack. If it attempts
// to unlink an object from the heap, this will shade it.
//
// 2. shade(ptr) prevents a mutator from hiding an object by moving
// the sole pointer to it from its stack into a black object in the
// heap. If it attempts to install the pointer into a black object,
// this will shade it.
//
// 3. Once a goroutine's stack is black, the shade(ptr) becomes
// unnecessary. shade(ptr) prevents hiding an object by moving it from
// the stack to the heap, but this requires first having a pointer
// hidden on the stack. Immediately after a stack is scanned, it only
// points to shaded objects, so it's not hiding anything, and the
// shade(*slot) prevents it from hiding any other pointers on its
// stack.Read case 1 and case 2 as a matched pair:
- Deletion barrier (shade the old value). Protects against the mutator removing the last pointer to a live object from the heap. If the collector already scanned the containing object, the removed pointer is the only evidence the referent is alive.
- Insertion barrier (shade the new value). Protects against the mutator adding a pointer to an object the collector has already scanned. The referent might be reachable only from the mutator’s stack, which the collector has already walked.
Delete the deletion barrier and case 1 is a use-after-free. Delete the insertion barrier and case 2 is a use-after-free. Both have to be there.
Why the barrier shades unconditionally
You might expect the barrier to be conditional on the colour of the object containing the slot — if the slot’s object is white (not yet scanned), the collector will find the pointer anyway, so no shading is needed. Go deliberately does not do this, and the file explains why with a memory-ordering argument:
// src/runtime/mbarrier.go:68
// Dealing with memory ordering:
//
// Both the Yuasa and Dijkstra barriers can be made conditional on the
// color of the object containing the slot. We chose not to make these
// conditional because the cost of ensuring that the object holding
// the slot doesn't concurrently change color without the mutator
// noticing seems prohibitive.
//
// Consider the following example where the mutator writes into
// a slot and then loads the slot's mark bit while the GC thread
// writes to the slot's mark bit and then as part of scanning reads
// the slot.
//
// Initially both [slot] and [slotmark] are 0 (nil)
// Mutator thread GC thread
// st [slot], ptr st [slotmark], 1
//
// ld r1, [slotmark] ld r2, [slot]
//
// Without an expensive memory barrier between the st and the ld, the final
// result on most HW (including 386/amd64) can be r1==r2==0. This is a classic
// example of what can happen when loads are allowed to be reordered with older
// stores (avoiding such reorderings lies at the heart of the classic
// Peterson/Dekker algorithms for mutual exclusion). Rather than require memory
// barriers, which will slow down both the mutator and the GC, we always grey
// the ptr object regardless of the slot's color.The trade is explicit: a memory fence on every pointer store, or an unconditional shade. Go takes the unconditional shade.
Two more design notes from the same file. First, globals need barriers too:
// src/runtime/mbarrier.go:104
// Global writes:
//
// The Go garbage collector requires write barriers when heap pointers
// are stored in globals. Many garbage collectors ignore writes to
// globals and instead pick up global -> heap pointers during
// termination. This increases pause time, so we instead rely on write
// barriers for writes to globals so that we don't have to rescan
// global during mark termination.Second, the barrier is pre-publication, which is what makes the “old value” available to shade:
// src/runtime/mbarrier.go:114
// Publication ordering:
//
// The write barrier is *pre-publication*, meaning that the write
// barrier happens prior to the *slot = ptr write that may make ptr
// reachable by some goroutine that currently cannot reach it.The fast path: a buffer, not a call
Shading on every pointer store would be brutal. So Go does not shade on the store. It records the pointer values in a per-P buffer and shades them later, in bulk.
The switch that turns the whole mechanism on and off is a single byte the compiler reads:
// src/runtime/mgc.go:239
var writeBarrier struct {
enabled bool // compiler emits a check of this before calling write barrier
pad [3]byte // compiler uses 32-bit load for "enabled" field
alignme uint64 // guarantee alignment so that compiler can use a 32 or 64-bit load
}and it is set only during the two marking phases:
// src/runtime/mgc.go:250
const (
_GCoff = iota // GC not running; sweeping in background, write barrier disabled
_GCmark // GC marking roots and workbufs: allocate black, write barrier ENABLED
_GCmarktermination // GC mark termination: allocate black, P's help GC, write barrier ENABLED
)// src/runtime/mgc.go:257
func setGCPhase(x uint32) {
atomic.Store(&gcphase, x)
writeBarrier.enabled = gcphase == _GCmark || gcphase == _GCmarktermination
}Outside a GC cycle, writeBarrier.enabled is false and the compiler’s
emitted check skips the barrier entirely: a pointer store is just a
store.
When the barrier is enabled, the compiler rewrites the store. From the compiler’s own source:
// src/cmd/compile/internal/ssacompile/writebarrier.go:143
// writebarrier pass inserts write barriers for store ops (Store, Move, Zero)
// when necessary (the condition above). It rewrites store ops to branches
// and runtime calls, like
//
// if writeBarrier.enabled {
// buf := gcWriteBarrier2() // Not a regular Go call
// buf[0] = val
// buf[1] = *ptr
// }
// *ptr = val
//
// A sequence of WB stores for many pointer fields of a single type will
// be emitted together, with a single branch.Note the two entries: buf[0] = val is the new pointer (insertion
barrier), buf[1] = *ptr is the old pointer (deletion barrier). Both
go into the buffer; neither is shaded yet.
gcWriteBarrier2 is not a Go call. It is assembly, and it exists
mostly to carve out space in the buffer:
// src/runtime/asm_amd64.s:1268
// gcWriteBarrier informs the GC about heap pointer writes.
//
// gcWriteBarrier returns space in a write barrier buffer which
// should be filled in by the caller.
// gcWriteBarrier does NOT follow the Go ABI. It accepts the
// number of bytes of buffer needed in R11, and returns a pointer
// to the buffer space in R11.
// It clobbers FLAGS. It does not clobber any general-purpose registers,
// but may clobber others (e.g., SSE registers).
// Typical use would be, when doing *(CX+88) = AX
// CMPL $0, runtime.writeBarrier(SB)
// JEQ dowrite
// CALL runtime.gcBatchBarrier2(SB)
// MOVQ AX, (R11)
// MOVQ 88(CX), DX
// MOVQ DX, 8(R11)
// dowrite:
// MOVQ AX, 88(CX)The fast path does not take a lock, does not call into Go, and does not clobber a general-purpose register:
// src/runtime/asm_amd64.s:1291
retry:
// TODO: Consider passing g.m.p in as an argument so they can be shared
// across a sequence of write barriers.
MOVQ g_m(R14), R13
MOVQ m_p(R13), R13
// Get current buffer write position.
MOVQ (p_wbBuf+wbBuf_next)(R13), R12 // original next position
ADDQ R11, R12 // new next position
// Is the buffer full?
CMPQ R12, (p_wbBuf+wbBuf_end)(R13)
JA flush
// Commit to the larger buffer.
MOVQ R12, (p_wbBuf+wbBuf_next)(R13)
// Make return value (the original next position)
SUBQ R11, R12
MOVQ R12, R11
// Restore registers.
MOVQ 96(SP), R12
MOVQ 104(SP), R13
RETNo general-purpose register is clobbered on the fast path, which is why it can be called from the middle of an otherwise register-tight sequence without a spill. The buffer is per-P, so no locking is needed either.
The buffer
// src/runtime/mwbbuf.go:42
type wbBuf struct {
// next points to the next slot in buf. It must not be a
// pointer type because it can point past the end of buf and
// must be updated without write barriers.
//
// This is a pointer rather than an index to optimize the
// write barrier assembly.
next uintptr
// end points to just past the end of buf. It must not be a
// pointer type because it points past the end of buf and must
// be updated without write barriers.
end uintptr
// buf stores a series of pointers to execute write barriers on.
buf [wbBufEntries]uintptr
}// src/runtime/mwbbuf.go:60
const (
// wbBufEntries is the maximum number of pointers that can be
// stored in the write barrier buffer.
//
// This trades latency for throughput amortization. Higher
// values amortize flushing overhead more, but increase the
// latency of flushing. Higher values also increase the cache
// footprint of the buffer.
//
// TODO: What is the latency cost of this? Tune this value.
wbBufEntries = 512
// Maximum number of entries that we need to ask from the
// buffer in a single call.
wbMaxEntriesPerCall = 8
)512 pointer-sized entries, 4 KiB per P. The compiler can ask for up to
eight at a time, which is why the runtime exports
gcWriteBarrier1 through gcWriteBarrier8.
A note on naming: this buffer is per-P, not per-goroutine. There is
a g.writebuf field in the runtime, but it has nothing to do with the
GC — it is the byte buffer print uses to build up panic messages
before there is a G to print them on.
Flushing: where recording becomes shading
The flush is where old and new pointer values turn into marks. It runs on the system stack, non-preemptibly, and it processes the buffer in place:
// src/runtime/mwbbuf.go:214
// Mark all of the pointers in the buffer and record only the
// pointers we greyed. We use the buffer itself to temporarily
// record greyed pointers.
//
// TODO: Should scanObject/scanblock just stuff pointers into
// the wbBuf? Then this would become the sole greying path.
//
// TODO: We could avoid shading any of the "new" pointers in
// the buffer if the stack has been shaded, or even avoid
// putting them in the buffer at all (which would double its
// capacity). This is slightly complicated with the buffer; we
// could track whether any un-shaded goroutine has used the
// buffer, or just track globally whether there are any
// un-shaded stacks and flush after each stack scan.
gcw := &pp.gcw
pos := 0
for _, ptr := range ptrs {
if ptr < minLegalPointer {
// nil pointers are very common, especially
// for the "old" values. Filter out these and
// other "obvious" non-heap pointers ASAP.
//
// TODO: Should we filter out nils in the fast
// path to reduce the rate of flushes?
continue
}
if tryDeferToSpanScan(ptr, gcw) {
continue
}
obj, span, objIndex := findObject(ptr, 0, 0)
if obj == 0 {
continue
}
// TODO: Consider making two passes where the first
// just prefetches the mark bits.
mbits := span.markBitsForIndex(objIndex)
if mbits.isMarked() {
continue
}
mbits.setMarked()and then the greyed objects are queued for scanning:
// src/runtime/mwbbuf.go:255
// Mark span.
arena, pageIdx, pageMask := pageIndexOf(span.base())
if arena.pageMarks[pageIdx]&pageMask == 0 {
atomic.Or8(&arena.pageMarks[pageIdx], pageMask)
}
if span.spanclass.noscan() {
gcw.bytesMarked += uint64(span.elemsize)
continue
}
ptrs[pos] = obj
pos++
}
// Enqueue the greyed objects.
gcw.putObjBatch(ptrs[:pos])This is a detail worth pausing on, because it differs from the
pseudocode at the top of mbarrier.go: shading does not happen at
the write. It happens at the flush. The write barrier’s job is to
record the two pointer values; the flush’s job is to grey them. That
is what lets the fast path stay down to a compare, a branch and two
stores.
Bulk barriers for typed memory
The compiler handles individual stores. Bulk operations — copying a struct, copying a slice, zeroing memory — have to be handled at run time, because only the runtime knows the type’s pointer bitmap.
typedmemmove is the entry point:
// src/runtime/mbarrier.go:150
func typedmemmove(typ *abi.Type, dst, src unsafe.Pointer) {
if dst == src {
return
}
if writeBarrier.enabled && typ.Pointers() {
// This always copies a full value of type typ so it's safe
// to pass typ along as an optimization. See the comment on
// bulkBarrierPreWrite.
bulkBarrierPreWrite(uintptr(dst), uintptr(src), typ.PtrBytes, typ)
}
// There's a race here: if some other goroutine can write to
// src, it may change some pointer in src after we've
// performed the write barrier but before we perform the
// memory copy. This safe because the write performed by that
// other goroutine must also be accompanied by a write
// barrier, so at worst we've unnecessarily greyed the old
// pointer that was in src.
memmove(dst, src, typ.Size_)And bulkBarrierPreWrite walks the type’s pointer bitmap, calling
get1 or get2 on the buffer for each pointer slot:
// src/runtime/mbitmap.go:429
var tp typePointers
if typ != nil {
tp = s.typePointersOfType(typ, dst)
} else {
tp = s.typePointersOf(dst, size)
}
if src == 0 {
for {
var addr uintptr
if tp, addr = tp.next(dst + size); addr == 0 {
break
}
dstx := (*uintptr)(unsafe.Pointer(addr))
p := buf.get1()
p[0] = *dstx
}
} else {
for {
var addr uintptr
if tp, addr = tp.next(dst + size); addr == 0 {
break
}
dstx := (*uintptr)(unsafe.Pointer(addr))
srcx := (*uintptr)(unsafe.Pointer(src + (addr - dst)))
p := buf.get2()
p[0] = *dstx
p[1] = *srcx
}
}The src == 0 case is a memclr: there is no new value, so only the
old value needs recording — the deletion barrier alone, because
clearing a slot cannot hide anything by adding a reference.
bulkBarrierPreWriteSrcOnly is the variant used where the destination
is known to be zero already (freshly allocated memory), so only the
new values need recording:
// src/runtime/mbitmap.go:460
// bulkBarrierPreWriteSrcOnly is like bulkBarrierPreWrite but
// does not execute write barriers for [dst, dst+size).
//
// In addition to the requirements of bulkBarrierPreWrite
// callers need to ensure [dst, dst+size) is zeroed.
//
// This is used for special cases where e.g. dst was just
// created and zeroed with malloc.
//
// The type of the space can be provided purely as an optimization.
// See bulkBarrierPreWrite's comment for more details -- use this
// optimization with great care.There is no bulkBarrierPostWrite in this tree. Older descriptions
of Go’s write barrier mention one; the barrier here is entirely
pre-write, which is what the “pre-publication” note above insists on.
What it costs
The fast path, per pointer store to the heap:
- A load of
writeBarrier.enabledand a branch. - A call to
gcWriteBarrierN(assembly, no Go ABI). - Two stores into the per-P buffer: the old value and the new value.
- The store to the slot itself.
Two of those — the buffer stores — are pure overhead if the GC is not running, but they only happen when it is. And the buffer store is a write to a hot, P-local cache line, which is why the whole design uses a per-P buffer rather than a shared queue.
The expensive part is not the fast path; it is the flush. Every
buffer entry is a pointer that has to be looked up in the heap
(findObject), checked against the mark bits, and queued. A
pointer-heavy application that allocates hard during the mark phase
will spend real time here.
Trade-offs
What the hybrid buys: correctness against both directions of the hiding attack, without needing a memory fence on every store, and without needing to rescan globals at termination.
What it costs: every heap pointer store during the mark phase writes two extra words to a buffer. The buffer must be flushed often enough that its contents stay bounded, and the flush does real work.
Why buffer rather than shade inline: the fast path becomes a compare, a branch and two stores, with no register clobbering and no lock. The price is that greying is deferred, so the collector learns about the write slightly late. That is fine, because the collector is concurrent anyway — but it does mean the flush is a latency spike for the goroutine that triggers it.
Why not make the barrier conditional on colour: the memory ordering argument above. A fence per store would cost more than shading everything.
Caveats
The barrier is only for pointer stores into the heap. The compiler
omits barriers for writes to the current frame, and it emits them for
writes through pointers it cannot prove are not heap pointers. Code
that stores pointers into unsafe.Pointer slots in non-Go memory is
outside what the barrier can protect.
Stack writes are the collector’s problem, not the barrier’s. Stacks are scanned at the start of the mark phase and again at termination; the barrier covers heap slots and globals.
Signal handlers cannot call the barrier. There is exactly one
exception in the tree — profbuf.go omits a barrier during signal
handler profile logging — and it is safe only because the deletion
barrier is still there.
GODEBUG=invalidptr=1 and the race detector change the picture.
Sanitizer builds add their own instrumentation to the flush path, so
the cost of a pointer store is not the same in a -race binary as in
a production one.
A barrier is not a substitute for the collector’s own accounting. The buffer records values; whether those values end up marking an object depends on the collector’s state at flush time, which is why the flush is also where the collector’s “grey” bookkeeping happens.
Wrapping up
The one thing to remember: the barrier has two halves for a reason. The old value is recorded so the collector cannot lose an object you unlinked; the new value is recorded so it cannot lose an object you linked into something it already scanned. Drop either one and you get a use-after-free, not a missed collection.
The part people get wrong: the barrier does not shade anything when you store a pointer. It appends two words to a 4 KiB per-P buffer and returns. The shading happens later, in bulk, at the flush. That is why the fast path is cheap enough to leave in.
What to go do about it: look at the mark phase’s cost in a profile
before assuming the barrier is your problem. If you have a
pointer-heavy workload, the flush (wbBufFlush1) is where the time
goes, not the store. And if you are chasing a suspected GC bug, check
your unsafe.Pointer usage first — the barrier cannot protect a
pointer stored outside Go’s heap.
Further reading
src/runtime/mbarrier.go— the algorithmic description of the hybrid barrier, the memory-ordering argument, and the bulk entry points (typedmemmove,typedmemclr,typedslicecopy).src/runtime/mwbbuf.go— the per-P write barrier buffer, its size, and the flush that does the shading.src/runtime/mbitmap.go—bulkBarrierPreWriteandbulkBarrierPreWriteSrcOnly, the typed-memory bulk barriers.src/runtime/mgcmark.go—shadeandgreyobject, the marking side of what the barrier feeds.src/runtime/mgc.go— thewriteBarriervariable, the GC phases, andsetGCPhase.src/runtime/asm_amd64.s— thegcWriteBarrierassembly fast path and the flush path.src/cmd/compile/internal/ssacompile/writebarrier.go— how the compiler rewrites pointer stores.src/runtime/runtime2.go—g.writebuf, which is the print buffer, not the GC’s.