skip to content

A Go RPC service has p99 spikes and runtime.gcAssistAlloc frames in its CPU profile — what is happening?

level: seniorimportance: should knowfreq 48%

answer

  1. the handler is doing the collector's job
  2. allocation rate, not request logic
  3. the payer is not the culprit
  4. spiky p99, flat mean
  5. fewer bytes and fewer pointers per request

basics

~20 s

The service allocates faster than background marking can keep up, so allocating goroutines are charged mark-assist work inline. The bill lands on whichever request is allocating at that moment, which shows as tail latency rather than a slower average.

solid answer

~50 s

Those frames mean request goroutines are doing collector marking themselves. A high-fan-out read path that decodes many small short-lived structs per request pushes the allocation rate past what the background mark workers can absorb, so the collector's assist ratio climbs and each allocation starts repaying scan work before it returns. The time is attributed to the handler that happened to be allocating, which is why the symptom is a spiky p99 while the mean barely moves, and why the request that pays is often not the one that created the pressure. I would confirm it by measuring what share of the profile sits under `runtime.gcAssistAlloc` and `runtime.gcDrainN` and which call paths feed it, then attack the allocation rate itself: reuse buffers instead of allocating per item, decode into structures the handler already owns, flatten pointer-dense types so there is less to scan per live byte, and bound the fan-out so bursts are smoothed rather than concentrated.

code

text · 8 lines
text
runtime.gcDrainN
runtime.gcAssistAlloc1
runtime.gcAssistAlloc
runtime.deductAssistCredit
runtime.mallocgc
runtime.newobject
main.(*server).decodeItem
main.(*server).handleFanOut

go deeper

for a junior

Recognise the frame: runtime.gcAssistAlloc in a CPU profile means the code above it was made to do garbage-collection work while allocating, not that the code itself got slower.

for a middle

Explain the chain — allocation rate outruns background marking, the collector charges more scan work per allocated byte, and that work runs inside the allocating goroutine. Say why it therefore appears as CPU time under your own function.

for a senior

Show diagnosis and fix together: measure the share of the profile spent assisting, trace which allocation sites feed it, and cut bytes and pointers allocated per request. Be explicit that the request that pays is not necessarily the request at fault.

for a principal

Own the framing with both the customer and the team. This is a shared-cost failure where one hot path taxes every other endpoint in the process, so the real decision is an allocation budget per service, isolating the heavy path into its own process, or accepting the tail.

### Reading the profile `runtime.gcAssistAlloc` in a CPU profile means one specific thing: the goroutine on that stack was made to perform garbage-collection marking itself, inside the allocator, before its allocation returned. Underneath it you will typically see the marking routines (`runtime.gcAssistAlloc1`, `runtime.gcDrainN`); above it you will see `runtime.mallocgc` and then whatever application function allocated — a decode step, a struct literal, an `append` that grew, a map insert. That stacking is the whole reason this failure is confusing. The assist time is charged to *your* function, so the profile says "decoding is expensive" when decoding did not change. What changed is the price of allocating. ### The mechanism behind the symptom Go's collector marks concurrently, funded by background mark workers capped at roughly a quarter of the process's Ps. When the program allocates faster than those workers can mark, the runtime does not raise the collector's budget; it raises the price of allocation. Each allocating goroutine is charged scan work in proportion to the bytes it allocates and performs that work inline. A high-fan-out read path is the classic generator of this. One inbound request produces many downstream calls, each of which decodes a response into a fresh set of small short-lived structs. The bytes per request are large, the objects are pointer-bearing (so they add scan work as well as heap), and they die almost immediately — meaning they contribute nothing to the live set but everything to the allocation rate the collector is racing. ### Why the tail moves and the mean does not Assists only occur while a mark phase is running, and only while background marking is behind. That is a minority of wall-clock time. A request that arrives outside that window pays nothing; a request that arrives in the middle of it can pay a great deal. On top of that, an assisting goroutine deliberately scans a larger chunk than it owes and banks the surplus as credit, so cost arrives in lumps rather than smoothly. The result is a latency distribution with a normal body and a heavy tail — exactly the shape that generates a customer complaint about p99 while every dashboard of average latency looks healthy. ### Why the endpoint at the top of the profile is often innocent Allocation rate is a property of the whole process, but assists are billed at the allocation site. Whichever code path happens to be allocating when the collector falls behind pays, regardless of who created the backlog. A cheap endpoint co-resident with an expensive one will show assist frames and elevated p99 that it did nothing to earn. The practical consequence: do not optimise the function at the top of the assist profile. Find the allocation *rate* — which call paths produce the most bytes per second across the whole process — and attack that. ### Confirming the diagnosis - Measure the share of the CPU profile that sits under `runtime.gcAssistAlloc`. A few percent is background noise; a double-digit share is the story. - Look at which call paths feed it. Those are the allocation sites paying the bill, not necessarily the ones creating it. - Check whether the elevated latency correlates with mark phases rather than with request content. If two requests with identical inputs differ by an order of magnitude, timing, not logic, is the variable. - Distinguish it from stop-the-world time, which is not attributed to your functions at all and hits every in-flight request in the same short window whether or not it was allocating. - Watch for the silent variant: a goroutine that owes assist work but can find neither credit nor available marking work parks until credit is released. That shows as latency with no matching CPU anywhere. ### What actually fixes it Every durable fix reduces one of the two inputs to the assist charge — bytes allocated, or scan work created. - **Allocate fewer bytes per request.** Reuse buffers the handler already owns across the items it processes, decode into a preallocated slice rather than appending into a fresh one per item, and avoid intermediate copies of payloads that are read once. - **Reduce pointer density.** Assist debt is repaid in *scanning*, and scanning follows pointers. A `[]Item` of pointer-free structs is one object the collector can skip past; a `[]*Item` is one edge per element to walk. Flattening pointer-heavy types lowers the scan work per live byte, which lowers the ratio the collector needs to charge. - **Cap fan-out concurrency.** Letting one request launch an unbounded number of concurrent downstream decodes concentrates allocation into a burst, which is the worst possible shape for a pacer trying to stay ahead. A bounded worker count smooths it. - **Shorten the live set the decode produces.** Objects that die immediately still cost allocation, but objects that survive cost scanning on every subsequent cycle too. What does *not* fix it: adding goroutines (the allocation is the same, now more concurrent), or blaming the decoder's logic and micro-optimising code that was never slow.

  • Why does the endpoint at the top of the assist profile often turn out not to be the problem?
    Assists are billed at the allocation site, so the path that shows the time is simply the one allocating when the collector fell behind. Allocation rate is a whole-process property, so a cheap endpoint sharing the binary with a heavy one absorbs assists it did nothing to cause. Fix the process's allocation rate, not the frame on top.
  • How would you tell assist-driven latency apart from stop-the-world pauses?
    Assist cost appears as CPU time inside your own goroutine's stack, spread across requests roughly in proportion to how much each allocated. Stop-the-world time is not attributed to your functions at all and hits every in-flight request within the same short window, whether or not it was allocating. The correlation with allocation volume is the tell.
  • Why does flattening pointer-heavy structures help even when the bytes allocated stay the same?
    Assist debt is repaid in scanning, and scanning follows pointers. A slice of pointer-free structs is one object the collector can skip through, while a slice of pointers to structs is one edge per element to walk. Fewer pointers means less scan work per live byte, so the ratio the collector must charge per allocated byte comes down.
  • What would you expect if a goroutine owes assist work but there is nothing to scan?
    It parks on the runtime's assist queue until a background worker flushes credit or the cycle completes. That variant is nastier to diagnose because it produces latency with no matching CPU time anywhere — the profile is quiet while the request sits still, so a CPU profile alone can understate how much of the tail the collector owns.

saying these in an interview costs you the question

  • Blames the endpoint sitting at the top of the assist profile
  • Reads assist frames as evidence of a stop-the-world pause
  • Adds goroutines to fix an allocation-rate problem
  • Assumes more CPU cores will make the assists disappear
  • Concludes the decoding logic itself became slower