Classify the Pause Before You Blame the GC: Reading GCSuspendEEBegin/GCStart Pairs in a Production Trace

A long Gen2 pause shows up in the trace. Someone writes “blocking Gen2” in the incident channel, and the next two hours go into heap size, LOH allocation rate, and whether to raise the container memory limit. That investigation may be aimed at the wrong event. The first question is not how big the heap is. It is which event sequence produced the timestamp difference you are looking at.

The runtime gives you two cheap discriminators: the Type field on GCStart and the Reason field on GCSuspendEEBegin. Read them before you read promotion numbers. This article is a classification procedure, not a root-cause procedure. It tells you what the pair can establish, what it cannot, and where to stop and collect more events.

What the fields actually say

The .NET runtime GC event reference defines GCStart_V1 (event ID 1) with a Type field that separates three cases: 0x0 is a blocking garbage collection outside background GC, 0x1 is a background garbage collection, and 0x2 is a blocking garbage collection that occurred during background GC (.NET runtime garbage collection events). That single field is the primary classifier. If you have not read it, you do not yet know what kind of pause you are measuring.

GCSuspendEEBegin_V1 (event ID 9) carries a Reason field. The values that matter for this procedure are 0x1 (Suspend for GC) and 0x6 (Suspend for GC Prep). The same reference lists the others: app domain shutdown, code pitching, shutdown, debugger, and debugger sweep. The reason tells you why the execution engine was suspended; the GCStart type tells you what kind of collection the suspension belongs to. You need both.

Promotion is a separate signal. GCHeapStats reports TotalPromotedSize0 (bytes promoted from generation 0 to generation 1) and TotalPromotedSize1 (bytes promoted from generation 1 to generation 2), among other fields. Those numbers are not derivable from the suspend/start pair. If your incident summary says “background GC promotion caused the latency,” the pair did not tell you that. Something else did, or nothing did.

The two canonical sequences

For a blocking GC, the published ordering is simple. Maoni Stephens documents it in the GC ETW series: GCSuspendEE, GCSuspendEEEnd, GCStart, GCEnd, GCRestartEEBegin, GCRestartEEEnd (GC ETW Events – Part 3). Suspension completes, the collection runs, resumption completes. One suspension window, one collection.

A background GC is not that shape. The same post lays out the ordering: an initial suspension and resumption, then GCStart with Type 0x1, then zero or more foreground gen0/gen1 collections inside the background GC’s lifetime, then a second suspension and resumption, then GCEnd. The second suspension uses the SuspendForGCPrep reason, distinct from the first suspension’s SuspendForGC reason, as Stephens describes in the memory performance workflow post.

Foreground collections are the ones that stop everything. Microsoft’s background GC documentation states that a collection on ephemeral generations during background GC is a foreground collection, and when foreground collections occur, all managed threads are suspended (Background garbage collection). So a background GC envelope can contain real stop-the-world windows that belong to foreground gen0/gen1 collections, not to the background collection itself.

This is why a single pause number for a background GC is misleading. Stephens notes that the pause shown for a background GC in GCStats is the sum of its two pauses, not one suspension window. If you treat that sum as one blocking stop-the-world event, you overstate the blocking impact and you aim the investigation at the wrong thing.

Walk the trace

Collect a GC-only trace first. dotnet-trace offers the gc-collect profile, documented as tracking GC collections only at very low overhead (dotnet-trace). That is the right starting point when you need to classify before you need to explain. Record the exact provider and keyword configuration alongside the trace file. Instrumentation changes the trace, and you will need to know what was enabled when you interpret pause counts.

Then, in the trace viewer, filter to GC events and anchor on the pause start timestamp. For each candidate pause, read the events in order and answer three questions:

  1. Is there a GCStart with Type 0x1 whose GCEnd encloses this suspension? If yes, you are inside a background GC envelope.
  2. What is the Reason on the GCSuspendEEBegin? 0x1 (Suspend for GC) at the start of the envelope, or 0x6 (Suspend for GC Prep) at the second suspension, are the expected values for a background GC.
  3. Is there a nested GCStart/GCEnd pair with Type 0x0 or 0x2 inside the envelope? That is a foreground or blocking-during-background collection, and its suspension window is the one that stopped managed threads.

Classify each pause into one of three buckets before you measure anything:

  • Blocking outside background GC. GCStart Type 0x0, single suspend/start/end/restart sequence. The pause is one suspension window.
  • Background GC envelope. GCStart Type 0x1. The envelope contains two suspension windows, and possibly interior foreground collections. Do not report the envelope’s total duration as a blocking pause.
  • Blocking during background GC. GCStart Type 0x2, nested inside a 0x1 envelope. This is a real blocking collection; attribute its suspension window to it, not to the enclosing background GC.

If a SuspendForGCPrep suspension appears without an enclosing background GCStart/GCEnd, that is an investigation lead, not a conclusion. It may mean your filter dropped events, your trace started mid-envelope, or the event stream is incomplete. Check the trace boundaries and the enabled keywords before you write a theory.

What the pair cannot tell you

The pair classifies. It does not explain. Specifically:

  • Promotion volume. Read GCHeapStats for TotalPromotedSize0 and TotalPromotedSize1. The suspend/start pair carries no promotion data.
  • Allocation source. GCAllocationTick events and the Reason on GCStart (for example, 0x0 small object heap allocation versus 0x4 large object heap allocation) point at what triggered the collection. Trigger reason is not the same as condemned reason; the runtime can escalate the generation it collects after the trigger.
  • Root cause. A long suspension window can be caused by work outside the GC itself. To see what the runtime was doing during a suspension, you need CPU samples or additional runtime events in the same trace. The pair tells you where to look, not what you will find.

There is also an instrumentation trap. Stephens documents that enabling GCHeapSurvivalAndMovementKeyword events adds a third pause during a background GC, because the managed threads must be paused to fire those events. If your trace has survival/movement events enabled and you see three suspensions in a background envelope, the third may be an artifact of your own collection configuration. Record the configuration with the trace so you can tell product behavior from measurement behavior.

A worked reading, using published ordering only

Suppose the trace shows, in order: GCSuspendEEBegin (Reason 0x1), GCSuspendEEEnd, GCStart (Type 0x1, Depth 2), then a nested GCSuspendEEBegin (Reason 0x1), GCSuspendEEEnd, GCStart (Type 0x0, Depth 0), GCEnd, GCRestartEEBegin, GCRestartEEEnd, then a second GCSuspendEEBegin (Reason 0x6), GCSuspendEEEnd, GCRestartEEBegin, GCRestartEEEnd, and finally GCEnd for the background collection.

The classification is: one background GC envelope, one interior foreground gen0 collection, two background suspension windows. The blocking pause that affected request latency is the foreground gen0 collection’s suspension window, not the background envelope’s total duration. If a dashboard reported the envelope as a single 170 ms pause, that number is a sum of two suspension windows plus the interior foreground collection’s window, and it does not describe any single stop-the-world event.

What you still do not know: how many bytes were promoted, which allocations triggered the foreground collection, or whether the suspension window was dominated by GC work or by something else in the runtime. Those require GCHeapStats, allocation ticks, and CPU samples respectively. The classification step is done; the causation step has not started.

Checklist for the next trace

  1. Record the trace configuration: providers, keywords, profile, and whether survival/movement events were enabled.
  2. Filter to GC events and list every GCStart with its Type and Depth.
  3. For each suspension, read the GCSuspendEEBegin Reason and find the enclosing GCStart.
  4. Classify each pause as blocking outside background GC, background envelope, or blocking during background GC.
  5. Only then measure: suspension window durations for the classified blocking events, and GCHeapStats promotion fields for the collections you care about.
  6. Correlate with CPU samples or additional runtime events before writing a root cause.
  7. In the incident summary, state which classification the evidence supports and which it does not. “Background GC envelope with an interior foreground gen0 collection” is a defensible sentence. “Blocking Gen2 caused the latency” is not, unless the Type field and the suspension window say so.

The discipline is boring and it saves time. Classify first, measure second, correlate third. The event pair is cheap; the misdiagnosis is not.

FAQ

Can I tell background GC promotion from blocking Gen2 using only GCSuspendEEBegin and GCStart?

You can classify the collection type and the suspension reason. You cannot measure promotion from those events. Promotion is reported by GCHeapStats fields such as TotalPromotedSize0 and TotalPromotedSize1. Treat the pair as a classifier and the heap stats as the measurement.

Why does my trace show three suspensions inside one background GC?

Check whether GCHeapSurvivalAndMovementKeyword events were enabled. According to Stephens, enabling those events adds a third pause during a background GC because the runtime must pause managed threads to fire them. If the keyword was on, the third suspension may be an artifact of the trace configuration rather than product behavior.

Is a long background GC pause a runtime bug?

Not by itself. A long suspension window is a measurement that needs explanation. It can come from GC work, from work outside the GC in the runtime, or from instrumentation. Collect CPU samples or additional runtime events before attributing it to a defect.

Does this procedure differ across .NET 6, 7, 8, and 9?

The event reference used here documents the GCStart Type and GCSuspendEEBegin Reason fields without version-splitting them. If you need a version-scoped claim, verify the field semantics against the reference for the specific runtime version in the trace before you write it down.