# The GOGC I set next to GOMEMLIMIT never fired

> GOGC=100 with a 256 MiB limit produced exactly what GOGC=off produced: same collections, same CPU share, same throughput. The tighter rule binds, the other goes quiet.

- Published: 2026-10-04
- Tags: gc, memory, performance
- Source: https://gopheria.com/blog/gogc-and-gomemlimit-are-different-knobs/
- Language: en-US
- Author: Nolan Keir

---
`GOGC=100` next to a 256 MiB limit produced exactly what `GOGC=off` produced
with that limit alone. Same cycle count, same CPU share, same throughput — the
ratio I set never got to decide anything.

`GOGC` sets a growth ratio and `GOMEMLIMIT` sets a ceiling, and they are
described together often enough that people set both to the same intent. The
runtime does not average them. It takes whichever produces the smaller heap
goal, and the other one stops existing.

## What is being measured

All numbers: `go1.27.0 darwin/arm64`, Apple M4 Pro, 12 cores, macOS 26.6.2. The
harness is
[`gclab/`](https://github.com/CognatePress/experiments.gopheria.com/tree/main/gclab).

These are program runs rather than `testing.B` benchmarks, so `benchstat` does
not apply and the
[control-pair method](/blog/benchstat-or-it-didnt-happen/) has to be replaced
with something plainer: **every configuration was run five times, and every
figure below is the median with the full range printed beside it.** Where the
range overlaps, the post says so instead of reporting a difference.

The machine matters more than usual here. One allocating goroutine on twelve Ps
means eleven of them are idle most of the time, and an idle P is one
[the scheduler has nothing to give](/blog/go-scheduler-blocking-goroutine/) —
which turns out to decide how the collector's CPU is accounted for, further
down.

The workload is a steady allocator: a live set of a fixed size, and a loop that
replaces one member of it per iteration. Nothing grows, so the heap reaches a
plateau and stays there — which is what makes the two knobs comparable at all,
because a heap that is still growing hides which rule set the ceiling. The live
set is 128 MiB of requested objects, which settles at about 167 MiB live once
the allocator's own bookkeeping is counted.

```bash
go build -o /tmp/gclab ./gclab
GOGC=100 /tmp/gclab -mode=pointer -live=134217728 -dur=6s
GOGC=off GOMEMLIMIT=384MiB /tmp/gclab -mode=pointer -live=134217728 -dur=6s
```

Each run reports throughput, the number of collections, the collector's CPU
share, the peak heap goal and the peak mapped memory.

## Each knob on its own

`GOGC` first. It is a ratio: the guide gives the target as
`Live heap + (Live heap + GC roots) * GOGC / 100`, so 100 means "collect when the
heap has doubled".

```text
GOGC   M alloc/s (median, n=5)   cycles   gc cpu   goal peak   mapped peak
  25        2.38  (2.36-2.39)      345    51.3%     205.0MiB      222.7MiB
  50        3.19  (3.13-3.24)      255    39.2%     255.1MiB      264.3MiB
 100        4.33  (4.31-4.40)      182    29.2%     340.8MiB      329.4MiB
 200        6.07  (6.02-6.09)      111    18.5%     514.8MiB      502.2MiB
 400        7.84  (7.81-7.99)       67    11.3%     855.3MiB      852.6MiB
```

Then `GOMEMLIMIT`, with `GOGC=off` so the limit is the only rule in play:

```text
GOMEMLIMIT   M alloc/s (median, n=5)   cycles   gc cpu   goal peak   mapped peak
    192MiB        0.90  (0.87-0.91)      411    63.1%     178.1MiB      199.0MiB
    256MiB        2.66  (2.65-2.69)      295    46.1%     240.3MiB      264.2MiB
    384MiB        4.41  (4.39-4.44)      186    29.9%     364.5MiB      387.5MiB
    512MiB        6.12  (5.56-6.27)      119    19.8%     488.7MiB      510.7MiB
```

Both tables trade the same thing: memory for CPU. That is not the interesting
part. The interesting part is that they overlap.

## Where the two rules name the same ceiling

Put the `GOGC=100` row next to the `GOMEMLIMIT=384MiB` row:

```text
                   M alloc/s (median, n=5)   cycles   gc cpu   goal peak
GOGC=100                4.33  (4.31-4.40)      182    29.2%    340.8MiB
GOMEMLIMIT=384MiB       4.41  (4.39-4.44)      186    29.9%    364.5MiB
```

The throughput ranges overlap, the cycle counts are four apart out of 184, and
the CPU shares are 0.7 points apart. These are not similar configurations; on
this workload they are the same configuration, reached by two different rules. A
167 MiB live heap with `GOGC=100` targets about 340 MiB, and 384 MiB is close
enough that the pacer lands in the same place.

That coincidence is what makes the next result easy to miss in production. If
your live heap happens to sit where your ratio and your limit agree, both knobs
look like they work.

## Setting both, and the one that goes quiet

Now both, at two different limits:

```text
                              M alloc/s (median, n=5)   cycles   gc cpu   goal peak
GOGC=100                           4.33  (4.31-4.40)      182    29.2%    340.8MiB
GOGC=100  GOMEMLIMIT=384MiB        4.36  (3.86-4.47)      185    29.0%    344.6MiB
GOGC=off  GOMEMLIMIT=384MiB        4.41  (4.39-4.44)      186    29.9%    364.5MiB

GOGC=100  GOMEMLIMIT=256MiB        2.65  (2.61-2.66)      288    44.9%    233.9MiB
GOGC=off  GOMEMLIMIT=256MiB        2.66  (2.65-2.69)      295    46.1%    240.3MiB
```

The first three rows are one number three times, which is the coincidence above.
The last two are the finding: **with a 256 MiB limit, setting `GOGC=100` produced
the same result as turning `GOGC` off entirely.** Overlapping throughput ranges,
cycle counts seven apart out of nearly three hundred, CPU shares 1.2 points
apart.

`GOGC=100` on a 167 MiB live heap asks for a goal around 340 MiB. The limit asks
for 240. The runtime takes the smaller, every cycle, and the ratio is never the
binding constraint. It is not moderating the limit and the limit is not
moderating it — one of them is simply not in the calculation.

Which one that is depends on the live heap, and the live heap moves. A service
whose working set grows past the crossover swaps which knob is live, silently,
with no configuration change and no log line.

## The limit that sits too close

The failure mode is the case worth the reader's time. Set the limit just above
the live heap — 176 MiB against a 167 MiB live set, about five percent of
headroom:

```text
                              M alloc/s (median, n=5)   cycles   gc cpu   goal peak
GOGC=100 (no limit)                4.33  (4.31-4.40)      182    29.2%    340.8MiB
GOGC=off  GOMEMLIMIT=176MiB        0.66  (0.65-0.67)      394    64.2%    166.4MiB
```

**85% of throughput, gone.** And no OOM: the limit is defined as soft, so the
runtime does not fail when it cannot meet it — it collects harder and keeps
going. The heap goal peaked at 166.4 MiB, which is the live heap itself. The
pacer is asking for a target it can never reach by collecting, because there is
nothing left to collect.

The Go GC guide calls this thrashing and describes it exactly:

> This situation, where the program fails to make reasonable progress due to
> constant GC cycles, is called **thrashing**. It's particularly dangerous
> because it effectively stalls the program.

A stall is worse to diagnose than a crash. Nothing dies, no restart happens, the
memory metric looks obedient — it is pinned at the limit, which is what you
asked for — and the throughput graph is the only place the problem appears. This
is L3's result arriving from the other side: how often the collector runs is
exactly how fast a `sync.Pool` empties, and here it is running constantly.

## The collector's CPU share is not the cost

That 64.2% needs an argument with itself, because the guide says the runtime caps
the GC at "roughly 50%" of CPU with a `2 * GOMAXPROCS` window.

Both are true. `/cpu/classes/gc/total` is the sum of four children, and one of
them is `mark/idle` — mark work performed on a P that had nothing else to run.
That work is charged to the collector but costs the application nothing it was
using, and the limiter does not govern it. In the failing run:

```text
$ GOGC=off GOMEMLIMIT=176MiB /tmp/gclab -mode=pointer -live=134217728 -dur=6s
gc cycles    396 (one every 15.214ms)
gc cpu       46.58s of 72.23s = 64.5%
gc cpu net   18.0% excluding idle-time marking (what the ~50% limiter governs)
gc cpu split assist 0.16s · dedicated 12.77s · idle 33.58s · pause 0.07s
```

That is the median of the five runs, printed whole. Thirty-four of the
forty-seven collector-seconds are idle marking. This workload is one
goroutine on twelve cores, so eleven Ps are usually free and the collector helps
itself to them. **A GC CPU share computed from `/cpu/classes/gc/total` on an
under-subscribed machine is not comparable to the limiter's ceiling.** Subtract
`/cpu/classes/gc/mark/idle` first.

Then the sharper version. Run the same workload with pointer-free objects —
identical size, identical allocation rate, nothing for the mark phase to follow:

```text
                              M alloc/s (median, n=5)   cycles   gc cpu
scalar, no limit                   8.28  (8.15-8.45)      309     2.5%
scalar, GOMEMLIMIT=176MiB          2.11  (2.07-2.17)      966     8.1%
```

It lost **75% of its throughput while the collector's share never passed 8.1%**.
The cost was not CPU spent collecting. It was 966 collections in six seconds —
one every 6.2 ms — and an allocator that spends its time waiting behind them.

So a low GC CPU share does not clear the collector of suspicion. Read the cycle
count.

:::warning
The number to watch is not the memory metric. A process thrashing against
`GOMEMLIMIT` reports memory use pinned neatly at the limit, which looks like the
limit working. The signal is collection frequency next to throughput.
:::

## What this does not measure

Pause time, entirely. The stop-the-world total in the failing run was 13.99 ms
across 396 cycles — about 35 µs each, which is not where the 85% went. A p99
story needs a request-shaped workload and a different harness.

The live set here is fixed by construction, which is the property that made the
comparison possible and also the one real services do not have. The interesting
case for a soft limit is a transient spike, where `GOMEMLIMIT` is supposed to
absorb what `GOGC` would have turned into an OOM. This workload cannot produce
that shape, and I have not measured it.

One goroutine on twelve cores is also what made the idle-marking share so large.
On a machine with no spare Ps that 46-point gap closes, the collector's
accounting and the limiter's converge, and the numbers in the first two tables
would look different — probably worse, because the mark work would have to
displace application work instead of filling gaps.

And nothing here says what to set. It says which of the two settings is
currently deciding, which is a prerequisite rather than an answer.
