arthrop0d

Brief articles on varied topics.

Go’s Garbage-Collector Pacer and Mutator Assists

golangperformance

Go’s garbage-collector pacer is the runtime component that schedules concurrent marking so the collector finishes before the heap reaches its next growth limit. It sets a CPU budget for background marking and charges allocating goroutines for extra marking work when background workers are not keeping up.

The problem the pacer solves

Go’s collector is mostly concurrent: application goroutines, called mutators in garbage-collection terminology, continue running while the collector finds live objects. That keeps stop-the-world pauses short, but creates a scheduling problem.

The collector has a limited amount of work to do before the next collection should finish. The work is primarily scanning: examining an object’s pointer fields and discovering other live objects. Meanwhile, the application is allocating more objects and changing pointers. If the collector marks too slowly, it can reach the heap limit with unfinished work. If it marks too aggressively, it consumes CPU that the application needs.

Without a pacing policy, a concurrent collector could alternate between those failures: run too little and fall behind, or run continuously and waste application CPU. The pacer turns the heap-growth target and the estimated marking work into a rate at which marking should proceed.

Heap goals and the CPU target

At the start of a GC cycle, Go estimates the amount of live heap left after the previous collection. The GOGC setting determines how much larger the heap may become before another collection is expected. With the default GOGC=100, the target is approximately twice the live heap, subject to runtime details and other limits.

That target is a heap goal, not a CPU goal. The pacer also estimates how much scanning the current cycle will require and how quickly the program is allocating. From those values it determines how much marking must happen for each byte the application allocates.

During concurrent marking, Go aims for background GC workers to use roughly 25% of the available CPU capacity. “Available” here is based on the runtime’s processor setting, GOMAXPROCS, rather than the number of physical machines or the number of goroutines. The target is an internal policy, not a user-facing flag that says “use exactly 25%.”

The pacer continually compares expected progress with actual progress. Background workers may complete marking faster or slower than expected, and allocation rates may change. The runtime adjusts its accounting and the amount of work charged to allocators rather than blindly maintaining a fixed number of worker goroutines.

The practical goal is not simply “run GC on one quarter of the CPUs.” It is “finish the required marking work by the heap goal while normally reserving most CPU capacity for the application.”

How marking makes progress

A GC cycle first reaches a concurrent mark phase. The collector starts with objects known to be reachable from roots, such as goroutine stacks and global variables. It marks those objects and places newly discovered objects on work queues. A worker removes an object, scans its pointer fields, marks previously unseen targets, and adds those targets to the queues.

The collector has background mark workers for this job. But their progress depends on the application’s behavior. If the application suddenly allocates rapidly, the heap can approach its goal faster than those workers can scan objects. The pacer detects that the collector’s work rate is insufficient and creates assist debt for mutators.

Assist debt is an accounting measure: it says that an allocating goroutine must perform some marking work in proportion to the memory it allocates. A goroutine that allocates more receives more responsibility because its allocations are consuming the remaining heap budget.

What a mutator assist does

When an allocating goroutine reaches an allocation path during concurrent marking, the runtime checks whether it owes GC work. If it does, the goroutine temporarily performs marking itself instead of immediately continuing with application code.

It takes marking work from the collector’s queues, scans objects, and marks the objects those scans reference. The amount of work reduces its assist debt. Once the debt is paid, allocation proceeds normally. This is why a program can show allocation latency even though no long stop-the-world pause occurred: an allocating goroutine may have been doing GC work on the allocation path.

The runtime does not assign each allocation a permanently fixed amount of work. The pacer derives an assist ratio from the estimated remaining scan work and the expected allocation budget, then adjusts that ratio as conditions change. Background workers earn progress against the same overall accounting. If they do more work than expected, they provide credit and mutators need to assist less. If they fall behind, mutator assists increase.

A goroutine that performs no allocations generally does not incur this particular cost. Conversely, a workload that allocates heavily can become its own source of marking capacity when necessary. That feedback is what lets the collector keep up with bursts without dedicating unlimited background CPU to GC.

What this explains in practice

The pacer and assists are behind several runtime observations:

The design is a compromise. Background workers provide predictable baseline progress, while mutator assists supply emergency capacity tied to the allocation rate. The pacer continually balances those sources so marking finishes near the heap goal without making every application goroutine pay the full cost of collection all the time.

← All articles