arthrop0d

Brief articles on varied topics.

Go’s Concurrent Garbage Collector

golangperformance

Go’s garbage collector (GC) finds heap objects that the running program can no longer reach, then reclaims their memory. It does most of that work concurrently—while application goroutines continue running—using tri-color marking, write barriers, and a few short stop-the-world pauses.

The problem garbage collection solves

A Go program creates heap objects when it builds values whose lifetime cannot be determined at compile time. Those objects may contain pointers to other objects. Eventually, some objects are no longer reachable from the program’s live references and can be reused.

Without a garbage collector, code would need to release each object manually. That creates two opposing failure modes:

Go instead determines reachability. An object is live if the program can follow pointers from a root—a starting reference such as a global variable, a goroutine stack, or a runtime-held reference—to reach it. Objects not reached from any root are garbage.

The difficult part is that goroutines keep changing those pointers while the collector is examining them. A collector that simply stopped every goroutine for the entire scan would make collection easy, but would produce unacceptable pauses in a server or latency-sensitive service.

The tri-color model

Go’s concurrent mark phase models heap objects with three colors:

The collector starts with heap objects white. It scans the roots, turns each referenced object gray, and places those objects on work queues. It then repeatedly takes a gray object, examines the pointers it contains, turns newly discovered objects gray, and finally turns the original object black.

A simplified traversal looks like this:

  1. Roots point to A and D, so A and D become gray.
  2. The collector scans A, finds B, and colors B gray.
  3. Once all of A’s fields have been examined, A becomes black.
  4. The process continues until no gray objects remain.

At that point, black objects are reachable and white objects are unreachable—provided pointer changes made by running goroutines did not hide a reachable object. That condition is the central challenge of concurrent marking.

Why pointer changes are dangerous

Suppose the collector has already scanned object A and marked it black. A goroutine then changes A so that it points to a still-white object B. If the collector never scans A again, it may finish with B white and incorrectly reclaim it, even though the program can still reach it through A.

The reverse change is also important: a goroutine might remove the last visible pointer to an object while the collector has not yet examined that pointer. The collector must not lose track of an object merely because the program changed a field during the scan.

Write barriers preserve the marking work

A write barrier is a small piece of runtime-inserted bookkeeping that runs when a program writes a pointer into a heap object during concurrent marking. It is not a memory barrier in the CPU-ordering sense; it protects the collector’s view of the object graph.

When a goroutine performs a pointer assignment, the barrier records or shades the relevant object so that a reachable object cannot remain invisibly white. “Shading” means changing a white object to gray and adding it to the collector’s work. Go’s implementation uses a hybrid of two standard barrier strategies, handling both pointers being replaced and pointers being installed. The result is that concurrent pointer mutations cannot create a path to a white object that the collector will never inspect.

This lets the collector maintain the important marking rule: it must not finish with a black, already-scanned object hiding a white object that is still reachable.

The cost is that pointer writes during marking do a little more work. Most writes pay only a small barrier cost, while the collector can perform the expensive graph traversal on multiple processor threads alongside application work.

Where the pauses occur

“Concurrent” does not mean “never stops the world.” Stop-the-world (STW) means all goroutines are briefly paused so the runtime can perform an operation that cannot safely happen while the heap is changing.

A typical cycle has these broad stages:

  1. Setup pause: the runtime coordinates the beginning of marking and enables the write barrier.
  2. Concurrent mark: collector workers scan roots and gray objects while goroutines continue running. Goroutines may also perform marking assistance when allocation is high, sharing some of the collector’s work.
  3. Mark-termination pause: the runtime briefly stops goroutines, drains remaining marking work, and completes the checks needed to establish that marking is finished. It then disables the write barrier.
  4. Concurrent sweep: the runtime identifies white objects as reclaimable and makes their memory available for future allocations. Much of this work can overlap with program execution.

The pauses are intended to be short, but they are not a fixed constant. The amount of root and marking work, goroutine stack state, heap size, CPU availability, and application behavior all affect them. The collector also balances CPU spent on GC against the application’s work, so collection can reduce throughput even when pauses remain small.

What this explains in practice

The heap is not freed immediately when a value becomes unreachable. It remains available for the next collection cycle, and the collector eventually finds it during marking and reclaims its space during sweeping.

If you have seen GC pause lines in GODEBUG=gctrace=1 output, the pause fields describe these coordination points, while the larger concurrent portions account for CPU and memory activity spread across the cycle. Runtime metrics and profiles can likewise show GC CPU time, heap growth, allocation rate, and pause behavior.

The useful mental model is therefore not “Go periodically freezes and deletes unused objects.” It is: Go marks reachable objects while the program runs, uses write barriers to make concurrent changes safe, briefly pauses to start and finish the marking protocol, and reuses what remains white.

← All articles