Concurrent versus background Gen 2 collection, foreground GCs during background GC, configuration naming, latency modes and no-GC regions.
Both let a Gen 2 collection run mostly while application threads keep working. The older concurrent GC (.NET Framework before 4.0) could not perform ephemeral collections while a concurrent Gen 2 was running, so allocation-heavy threads could block when the ephemeral space ran out. Background GC (workstation from .NET Framework 4.0, server from 4.5, and the default in modern .NET) allows foreground Gen 0 and Gen 1 GCs during a background Gen 2, which removes that stall. Background GC applies only to Gen 2 and does not compact. The setting is still named "concurrent" (<ConcurrentGarbageCollection>, System.GC.Concurrent). Latency modes such as SustainedLowLatency and TryStartNoGCRegion further limit blocking GCs for critical periods.
"Both concurrent GC and background GC do the expensive part of a Gen 2 collection, marking, while application threads keep running. The difference is what happens if the app keeps allocating. With the old concurrent GC, ephemeral collections couldn't run during a concurrent Gen 2, so once Gen 0 filled up, allocating threads stalled until the Gen 2 finished. Background GC, the default in all modern .NET, allows short foreground Gen 0/Gen 1 GCs during the background Gen 2, which removes that stall. Background GC never compacts; when fragmentation or memory pressure is high the GC still does a blocking, compacting Gen 2. Confusingly, the setting is still called Concurrent. On top of that, latency modes like
SustainedLowLatencyand no-GC regions let you suppress blocking GCs during short, critical windows, but they're last-resort tools."
| Era | Gen 2 behaviour | Ephemeral GCs during Gen 2? |
|---|---|---|
| .NET Framework 1.0–3.5 | Concurrent GC (workstation) | No: allocations could stall |
| .NET Framework 4.0 | Background GC for workstation | Yes |
| .NET Framework 4.5 | Background GC for server too | Yes |
| .NET Core / .NET 5–10 | Background GC by default, both modes | Yes |
The configuration still uses the old name: <ConcurrentGarbageCollection>, System.GC.Concurrent, DOTNET_gcConcurrent. Turning it off gives non-concurrent (all blocking) GCs.
| Concurrent (legacy) | Background (current) | |
|---|---|---|
| Gen 2 marking concurrent | Yes | Yes |
| Gen 0/1 GCs during Gen 2 | No | Yes (foreground GCs) |
| Allocation stalls during Gen 2 | Possible | Largely avoided |
| Compaction | No | No (blocking GCs compact) |
| Server GC support | No | Yes, background threads per heap |
| Pause | Typical length |
|---|---|
| Background GC start and end | Short |
| Foreground Gen 0/1 GCs | Short |
| Blocking compacting Gen 2 (fragmentation, memory pressure, induced) | Long on large heaps |
Background GC reduces Gen 2 pause impact; it does not make the GC pause-free.
| Mode | How you get it | Effect | Use |
|---|---|---|---|
Batch |
Concurrent GC disabled | All GCs blocking; best throughput | Batch jobs |
Interactive |
Default with background GC | Normal behaviour | Almost everything |
SustainedLowLatency |
GCSettings.LatencyMode = ... |
Avoids blocking full GCs when possible; background Gen 2 only | Bounded critical windows (market hours) |
LowLatency |
GCSettings.LatencyMode = ... (Workstation only) |
Suppresses Gen 2 GCs except under low memory | Very short critical sections |
NoGCRegion |
GC.TryStartNoGCRegion(bytes) |
No GC at all while allocations stay under the budget | Microsecond-sensitive bursts |
Almost never for ordinary services. The right order:
SustainedLowLatency for a bounded window, or a no-GC region for a short burst with a measured budget.finally.Our [market data service] took a burst of [hundreds of thousands] of messages at the market open and breached its latency SLO for the first minutes, with pauses of [about 300 ms]. A dotnet-trace opened in PerfView GCStats showed blocking compacting Gen 2 GCs at the open, triggered by fragmentation from per-message byte[] buffers. I pooled the message buffers first, which removed most fragmentation, and then set GCSettings.LatencyMode = SustainedLowLatency during trading hours only, restoring Interactive after the close so compaction could happen overnight. Blocking GCs during the open dropped to [zero] and the SLO held; we added a dashboard for blocking GC count during trading hours.
The interviewer checks detailed GC knowledge for latency-sensitive systems.
Green flags: concurrent versus background difference, foreground GCs, no compaction in background, configuration naming, latency modes with safe usage, trace-based verification.
Red flags: thinks background GC is pause-free; sets low-latency modes permanently; confuses the terms with no explanation.
app threads ====|p|=====================|g0|=========================|p|=====
BGC thread [start][ concurrent mark ......... ][final mark][ sweep ]
foreground |g0|
p = short pause g0 = foreground (blocking) Gen 0 GC during the BGC
With the legacy concurrent GC, the g0 would not be allowed: a thread that ran out of Gen 0 space would wait until the concurrent Gen 2 finished.
While the background thread marks, the application keeps writing references. If the app stores a reference to an unmarked object into an already-scanned object, the GC could miss it. The runtime records modified memory using write watch (implemented in software via the write barrier in modern .NET). In the final pause, the GC re-scans modified pages/cards so nothing live is missed.
That is also why background GC has a small ongoing cost: the write barrier variant used while a BGC is in progress does extra work.
Compaction moves objects and rewrites references. Doing that while app threads run would need read barriers (check-and-forward on every reference load) or a stop-the-world phase. .NET chose not to put read barriers on every load for performance, so concurrent work is limited to marking and sweeping. When fragmentation matters, the GC decides to do a blocking compacting Gen 2 instead.
const long budget = 32 * 1024 * 1024;
if (GC.TryStartNoGCRegion(budget))
{
try
{
RunMatchingBurst(); // must allocate less than the budget
}
finally
{
if (GCSettings.LatencyMode == GCLatencyMode.NoGCRegion)
GC.EndNoGCRegion();
}
}
TryStartNoGCRegion may perform a GC first to make the space available.ArgumentOutOfRangeException.EndNoGCRegion would then throw, hence the check..NET 8+ adds GC.RegisterNoGCRegionCallback to be notified if the budget is exhausted.GCSettings.LatencyMode = NoGCRegion directly.| PerfView GCStats "Type" | Meaning |
|---|---|
BackgroundGC |
Background Gen 2 |
ForegroundGC |
Ephemeral GC during a background GC |
NonConcurrentGC |
Blocking GC (ephemeral or full) |
In-process: GC.GetGCMemoryInfo(GCKind.Background), GCKind.FullBlocking, GCKind.Ephemeral return the most recent GC of each kind, with pause durations and whether it compacted.
| Version | Change |
|---|---|
| .NET Framework 4.0 | Background workstation GC |
| .NET Framework 4.5 | Background server GC; SustainedLowLatency |
| .NET Framework 4.6 | GC.TryStartNoGCRegion |
| .NET 5 | GC.GetGCMemoryInfo(GCKind) |
| .NET 8 | GC.RegisterNoGCRegionCallback |
| .NET 9 / 10 | DATAS adapts background GC threads with heap count |
Interactive.DOTNET_gcConcurrent=0 and confirm Batch; compare GC.GetGCMemoryInfo(GCKind.Background).Index (stays 0).GC.TryStartNoGCRegion with a budget smaller than what you allocate and observe the mode change and the exception if you call EndNoGCRegion unconditionally.dotnet-trace collect --profile gc-collect under load and find ForegroundGC entries occurring inside a BackgroundGC in PerfView.using System.Runtime;
Console.WriteLine($"Default latency mode: {GCSettings.LatencyMode}"); // Interactive with background GC
// Critical period: avoid blocking full GCs where possible
GCLatencyMode previous = GCSettings.LatencyMode;
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
try
{
ProcessMarketOpen();
}
finally
{
GCSettings.LatencyMode = previous;
}
// Very short burst with a bounded allocation budget
if (GC.TryStartNoGCRegion(16 * 1024 * 1024))
{
try
{
var batch = new byte[64][];
for (int i = 0; i < batch.Length; i++) batch[i] = new byte[64 * 1024];
Console.WriteLine($"Allocated in no-GC region, mode: {GCSettings.LatencyMode}");
}
finally
{
if (GCSettings.LatencyMode == GCLatencyMode.NoGCRegion)
GC.EndNoGCRegion();
}
}
GCMemoryInfo bg = GC.GetGCMemoryInfo(GCKind.Background);
Console.WriteLine(bg.Index == 0 ? "No background GC yet" : $"Last background GC #{bg.Index}, gen {bg.Generation}");
static void ProcessMarketOpen()
{
for (int i = 0; i < 1_000_000; i++) _ = new byte[128];
}
What happens: "we use background GC, so GC can't cause latency". Why it is wrong: start/end pauses, foreground ephemeral GCs and blocking compacting Gen 2 GCs still occur. Do instead: say it reduces Gen 2 pause impact, and measure pause types.
What happens: "we turned on concurrent GC" is read as using the legacy algorithm.
Why it is wrong: the Concurrent setting enables background GC in modern .NET.
Do instead: explain the naming history.
What happens: ConcurrentGarbageCollection=false in an API.
Why it is wrong: every Gen 2 becomes fully blocking, increasing tail latency.
Do instead: keep it on for interactive/request workloads; turn off only for batch jobs.
SustainedLowLatency or LowLatency on permanentlyWhat happens: the mode is set at startup and forgotten.
Why it is wrong: compaction is avoided, so fragmentation and memory grow until the GC is forced to do something drastic.
Do instead: bound the window and restore the previous mode in finally.
LowLatency with Server GCWhat happens: code sets GCLatencyMode.LowLatency in an ASP.NET Core app.
Why it is wrong: it isn't available for Server GC and is meant for short Workstation critical sections.
Do instead: SustainedLowLatency or a no-GC region.
EndNoGCRegion unconditionallyWhat happens: the budget was exceeded and the region already ended.
Why it is wrong: EndNoGCRegion throws InvalidOperationException.
Do instead: check GCSettings.LatencyMode == GCLatencyMode.NoGCRegion first.
What happens: a large arbitrary budget is requested.
Why it is wrong: too large throws; too small silently ends the region.
Do instead: measure allocation of the critical section with GC.GetAllocatedBytesForCurrentThread() and add margin.
What happens: modes are toggled because "GC is slow". Why it is wrong: the pause may be ephemeral GCs, thread pool starvation or I/O. Do instead: confirm blocking GCs with a trace first.
What happens: fragmentation keeps growing despite many background GCs. Why it is wrong: background GC only sweeps; it doesn't compact. Do instead: fix the fragmenting pattern; compaction comes from blocking GCs.
What happens: low-latency mode is used to hide a high allocation rate. Why it is wrong: the cost moves elsewhere (memory growth, later longer GCs). Do instead: reduce allocations and mid-life objects first.
Latency tuning is a last step after reducing allocations and fragmentation.
| Need | Tool |
|---|---|
| General service | Defaults (background GC, Interactive) |
| Bounded critical window (market open) | SustainedLowLatency for that window |
| Microsecond-sensitive burst | TryStartNoGCRegion with measured budget |
| Batch throughput | Batch (non-concurrent) |
Seniors confirm with traces that blocking GCs are the actual cause of pauses before changing modes.
A Gen 2 GC collects the whole managed heap: Gen 0, Gen 1, Gen 2, the LOH and the POH. It is triggered when Gen 2's budget is exceeded by promotions, when large object allocations exceed the LOH budget, under memory pressure (approaching the container or GCHeapHardLimit), or by code calling GC.Collect(). Most Gen 2 GCs run as background GCs that let threads keep running; blocking full GCs (with compaction) pause everything. To investigate frequent Gen 2 GCs, check the GC reason (dotnet-trace or PerfView GCStats), then the cause: LOH churn, mid-life objects, induced collections, or memory limit pressure.
Workstation GC uses a single heap and runs collections on the thread that triggered them (with a background GC thread for concurrent Gen 2 work). It aims for small memory footprint and short pauses rather than maximum throughput. It is the default for console, desktop and worker apps that do not use the Web SDK; ASP.NET Core apps default to Server GC. You enable it with <ServerGarbageCollector>false</ServerGarbageCollector>; background (concurrent) GC is on by default and can be turned off with <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>. Choose Workstation GC for desktop apps, CLI tools, and many small containers where memory density matters more than throughput.
Server GC creates one heap per logical CPU (limited by affinity and container CPU limits) with dedicated, high-priority GC threads that collect in parallel. Budgets are larger, so GCs are less frequent and throughput is high, but memory use is higher. It is the default for ASP.NET Core. DATAS (Dynamic Adaptation To Application Sizes), opt-in in .NET 8 and on by default with Server GC since .NET 9, starts with fewer heaps and adjusts the heap count and budgets to the live data size and load, so a lightly loaded service uses far less memory. In containers, the GC honours CPU and memory limits; GCHeapHardLimit (or ...Percent), GCHeapCount and GCConserveMemory give finer control.
How to tell it (STAR)
In [our market data service], the open caused [300 ms] GC pauses. Traces showed blocking compacting GCs, so I pooled the buffers that fragmented the heap and used SustainedLowLatency only during trading hours. Blocking GCs at the open disappeared.
Last updated 31 Oct 2026.