← Open in the full interactive course (progress tracking, search & more)

A CPU pegged at 100% because real customers are hammering the service is a success story. A CPU pegged at 100% because a loop is spinning on nothing is a bug wearing the same costume.

Lesson 316's leak had a quiet, patient signature — memory climbing over hours or days while everything else looked normal. High CPU is the opposite kind of alarm: loud, immediate, and visible on any dashboard within seconds. A container's CPU graph pins itself to the ceiling, requests start queuing behind it, and the natural first reaction is to assume something is broken.

Except sometimes nothing is broken at all — sometimes the CPU is at 100% because the application is doing exactly what it's supposed to be doing, just under more load than usual. This lesson teaches you to tell the difference between legitimate high CPU and pathological high CPU, the specific bug patterns that cause the pathological kind, and how to use dotnet-trace — the applied, production version of the sampling profiler concept lesson 233 introduced — to find exactly which method is actually burning the cycles.

What Is It?

The Simple Explanation

High CPU, as a production symptom, just means one or more processor cores are spending most of their time actively executing your application's code rather than sitting idle. That fact alone tells you nothing about whether it's a problem — the real question is always why the CPU is busy: doing useful work that genuinely needs to happen, or doing wasted work that shouldn't be happening at all.

The Technical Definition

Legitimate high CPU is processor time spent on genuine, necessary computation — serializing real payloads, running real business logic, handling a real spike in traffic. Pathological high CPU is processor time spent on work that accomplishes nothing useful, or accomplishes it in a needlessly expensive way: a loop that spins instead of waiting, a retry storm without backoff, a regular expression whose matching engine has gone exponential on a particular input. Both look identical on a bare CPU-percentage graph. Distinguishing them requires looking inside the process, not just at the metric.

Legitimate High CPU

Pathological High CPU

Why Does It Exist? — The Usual Suspects Behind Pathological CPU

Just as lesson 316 named a short list of leak patterns worth memorizing, pathological high CPU tends to come from a similarly short, recurring list:

Busy-wait loops
Spinning in a tight loop, checking a condition repeatedly, instead of actually blocking or awaiting until it changes.
Retries without backoff
Reference lesson 291 — retrying a failing operation immediately, in a tight loop, instead of with growing delay between attempts.
Catastrophic regex backtracking
A pattern with nested, ambiguous repetition whose matching time explodes exponentially on certain inputs.
Excessive/needless (de)serialization
Repeatedly serializing or parsing the same large payload inside a hot loop, when the result could be computed once and reused.

What ties these together is that none of them are "the CPU is broken" — every one is application code doing more work than it needs to, often invisibly, because the code that triggers it looks completely ordinary at a glance. This is exactly the same shape of trap lesson 218 warned about with sync-over-async: the dangerous code doesn't look dangerous.

Big Picture — Diagnosis Flow

FROM "CPU IS HIGH" TO "THIS METHOD IS THE CAUSE"
1. CONFIRM AND CORRELATE — dotnet-counters
2. CAPTURE A CPU PROFILE — dotnet-trace
3. OPEN THE TRACE AND READ THE FLAME GRAPH
4. FOLLOW THE WIDEST PATH TO A SPECIFIC METHOD

Simple Example — Catastrophic Regex Backtracking

This is one of the most notorious pathological-CPU patterns in any language with a backtracking regex engine, .NET's included — and it's genuinely invisible by reading the pattern casually:

// Looks like an ordinary "one or more groups of letters" validator. // On most inputs, it's instant. On a carefully (or accidentally) crafted // input like "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", // the nested quantifiers force the engine to try an exponential number // of ways to backtrack across overlapping matches before giving up. var isValid = Regex.IsMatch(input, @"^(a+)+$"); // A rewritten, non-catastrophic pattern that expresses the same intent // without nested, ambiguous repetition — or, better, avoid a hand-rolled // backtracking pattern for this kind of check entirely. var isValidFixed = Regex.IsMatch(input, @"^a+$"); // .NET also lets you cap how long any single match attempt is allowed // to run, converting a runaway match into a controlled, catchable failure // instead of a CPU-pinned thread: var regex = new Regex(@"^(a+)+$", RegexOptions.None, TimeSpan.FromMilliseconds(500));

Meaning: The buggy pattern and the fixed pattern accept exactly the same set of "all letter a" strings on ordinary input — the difference only shows up on adversarial or unusually long input, which is exactly why this class of bug so often reaches production undetected: it passed every normal test case fine.

Real-World Example

An ASP.NET Core API's CPU climbs to 100% across every instance shortly after a deploy that added a new email-format validation rule to a minimal API endpoint (lesson 256). Traffic hasn't changed — the deploy-correlation question from lesson 315's triage framework flags this immediately. A dotnet-trace capture, opened as a flame graph, shows over 90% of samples sitting inside a single call to Regex.IsMatch deep inside the new validator — and one particular customer, sending a slightly malformed email address in a batch import, happens to trigger exactly the catastrophic-backtracking shape from the Simple Example above. Every request through that validator with that kind of input pins a thread pool worker at 100% CPU for seconds at a time, and under enough concurrent requests, the whole service's CPU saturates. Notice what didn't help here: more replicas, more CPU cores, a bigger instance size — none of it fixes a bug that gets exponentially worse with input length. Only fixing the pattern does.

Analogy

The Tachometer Doesn't Tell You If the Wheels Are Turning

Picture a car's tachometer pinned near the redline. That alone tells you nothing about whether the car is doing anything useful — it could be climbing a steep hill at full power, genuinely working hard and making real progress (legitimate high CPU), or it could be stuck in a ditch with the wheels spinning uselessly in mud, engine screaming, going absolutely nowhere (pathological high CPU). From the dashboard alone, both look identical: RPM maxed. The only way to tell them apart is to look past the tachometer at whether the car is actually moving — the equivalent of checking whether throughput is climbing along with CPU, and then popping the hood (a flame graph) to see exactly which part of the engine is working, and whether that work is going anywhere.

Under the Hood

Recall from lesson 233 how a sampling profiler works: it interrupts the running process hundreds of times per second and records exactly what's on the call stack at that instant, building a statistical picture from many samples rather than instrumenting every call. dotnet-trace is the CLI front end for capturing exactly this kind of sampled data from a live process via EventPipe, without the heavier overhead an instrumentation profiler would add. A flame graph is simply that raw sample data rendered visually: each frame's width is proportional to how many of the total samples included that method somewhere on the stack, and stacking frames vertically shows the actual call chain — so the widest bar at the very top of a tall stack is, statistically, exactly where the process's cycles are actually going, distinguishing "self time" (this method's own code) from "total time" (this method plus everything it called).

Common Confusion

1. "High CPU always means I need bigger or more servers" — sometimes it means a bug

Scaling out or up genuinely helps with legitimate high CPU under real load — that's exactly what it's for. It does nothing at all for pathological high CPU, and can actively hide the bug: more replicas just mean more instances each independently burning cycles on the same wasted work, at proportionally higher cost, while the root cause ships untouched.

2. "If it's not a deadlock, it must be high CPU" — these are opposite signatures, not the same failure family

Worth flagging early, ahead of lessons 318 and 319: a genuine deadlock (218, 318) shows threads permanently blocked, doing nothing at all — CPU usage for those specific threads sits near zero, not high. High CPU and a hang from blocked/waiting threads are near-opposite symptoms, even though both can make an application feel "stuck" to a user waiting on a response.

Common Mistakes

Mistake 1 — Retrying a failing call in a tight loop with no backoff

Wrapping a downstream call in a retry loop that fires again immediately on failure, with no delay — under a real outage, this turns one failing dependency into a CPU (and network) storm across every instance retrying it simultaneously.

Use exponential backoff between retry attempts (lesson 291), and pair it with a circuit breaker (lesson 292) so a persistently failing dependency stops being hammered at all after enough consecutive failures.

Mistake 2 — Writing a busy-wait loop instead of actually waiting

Polling a flag or condition in a tight while loop with no delay or yield, burning an entire core just to notice a change the instant it happens.

Use a real waiting primitive — an await on a task, a SemaphoreSlim, a Channel (lesson 214) — that actually releases the thread while nothing has changed, instead of spinning.

Mistake 3 — Shipping a hand-written regex pattern with nested, ambiguous quantifiers, untested against adversarial input

Writing a validation pattern that works fine on every normal test case, without considering what happens on a pathological or maliciously crafted input.

Avoid nested repetition where possible, set a matching timeout on any regex processing untrusted input, and specifically test validators against long, adversarial strings — not just the happy path.

When Should I Use It?

Rule of thumb: If a flame graph shows time spread broadly across genuine business logic, you're probably looking at real, legitimate load — consider scaling or the profiling-and-optimize workflow from lesson 233. If it shows a single narrow method dominating the graph, especially one that looks unrelated to how much traffic increased, you're looking at a bug.

Mental Model

Legitimate high CPU = the tachometer's high because the car is climbing a real hill.
Pathological high CPU = the tachometer's high because the wheels are spinning in mud, going nowhere.

Diagnose it: correlate CPU% against throughput (dotnet-counters) → capture a sampled profile (dotnet-trace) → read the flame graph → a narrow, dominant method is your suspect.
Remember: a genuine deadlock or thread pool starvation looks like the opposite of this — near-zero CPU, threads idle or blocked, not busy.

Key Takeaway


Check Your Understanding

You've seen how to tell legitimate CPU use apart from a pathological bug, and how to find the culprit with dotnet-trace. Let's confirm it landed.

1. Two services both show CPU pinned at 100%. Service A's throughput graph shows a matching spike in real traffic. Service B's throughput graph is flat, with no traffic increase at all. What does this difference suggest?

Show answer

Correct: B

Why B is correct: This is exactly the correlation check the lesson's diagnosis flow opens with — CPU that rises alongside real traffic looks legitimate; CPU pinned high with flat throughput is the strongest early signal that the work being done isn't producing proportional useful output, pointing toward a pathological cause.

Why A is incorrect: Throughput correlation is precisely the first, cheapest signal the lesson recommends checking — it's far from irrelevant.

Why C is incorrect: A deadlock's signature is near-zero CPU with blocked threads, not high CPU — high CPU with flat throughput points toward pathological CPU use (this lesson), not a deadlock (218/318).

Why D is incorrect: Both being at 100% CPU is exactly why further investigation matters — the graph alone can't tell you whether either one is a real problem without correlating against throughput and, ideally, a flame graph.

Reinforcement: Always correlate CPU against throughput first — it's the fastest way to separate "probably fine" from "probably a bug."

2. A flame graph captured via dotnet-trace shows over 85% of samples concentrated in a single, narrow method deep in the call stack, unrelated to any change in traffic volume. What does this most likely indicate?

Show answer

Correct: B

Why B is correct: This is exactly the flame-graph reading pattern the lesson describes — a legitimate workload's time is usually spread across genuine business logic, while a single narrow method dominating the graph (especially disconnected from a traffic increase) is the classic fingerprint of pathological, wasted CPU work.

Why A is incorrect: A single dominant method with no traffic correlation is specifically flagged in the lesson as suspicious, not as normal healthy behavior.

Why C is incorrect: A flame graph legitimately dominated by one method is a valid, meaningful, and common result — it's exactly what points an investigator toward the actual bug, not a sign of tool misconfiguration.

Why D is incorrect: A CPU flame graph says nothing about heap growth or reachability — that's lesson 316's concern, diagnosed with dotnet-gcdump, an entirely different tool and symptom.

Reinforcement: A narrow, dominant hot method in a flame graph — especially with no matching traffic increase — is the fingerprint of pathological CPU use.

3. A team's response to sustained high CPU is to add more server replicas. If the underlying cause is a busy-wait loop bug, what will happen?

Show answer

Correct: B

Why B is correct: This is exactly the point the lesson makes about pathological CPU — scaling out helps legitimate load, but for a genuine bug like a busy-wait loop, every new replica just independently reproduces the same wasted work, raising cost without touching the actual cause.

Why A is incorrect: More capacity only helps when the CPU usage is legitimate and proportional to real work — a bug that wastes cycles wastes them on every instance regardless of how many you add.

Why C is incorrect: Scaling out and a memory leak are unrelated concerns — adding replicas has no direct mechanism for causing a leak (lesson 316's topic) on any of them.

Why D is incorrect: Scaling infrastructure has no ability to inspect or patch application code — the fix for a busy-wait loop requires an actual code change, not more instances.

Reinforcement: Scaling is the right lever for legitimate load, never a substitute for fixing a genuine CPU-wasting bug.

4. A retry mechanism calls a failing downstream dependency again immediately on every failure, with no delay between attempts. During an outage of that dependency, what's the likely CPU/network consequence, and what's the fix?

Show answer

Correct: B

Why B is correct: This matches the lesson's explicit Mistake 1 — an immediate, backoff-free retry loop wastes CPU and network capacity hammering a dependency that's already failing; exponential backoff spaces out attempts, and a circuit breaker stops the hammering altogether once failure becomes persistent.

Why A is incorrect: This directly contradicts the lesson's point — a tight retry loop with no delay is specifically named as a pathological-CPU cause, not a safe default.

Why C is incorrect: Increasing retry count without adding backoff makes the problem worse, not better — more immediate retries mean more wasted CPU and network calls against an already-struggling dependency.

Why D is incorrect: This scenario is explicitly a CPU/network concern in the lesson (retries burning cycles and bandwidth) — it has no particular connection to heap reachability or memory leaks.

Reinforcement: Backoff and circuit breakers aren't just resilience patterns — they directly prevent a specific class of pathological CPU spike.

5. A support engineer reports "the app is completely stuck — requests never come back, and CPU usage across all threads is near zero." Based on this lesson, is this consistent with the high-CPU failure mode covered here?

Show answer

Correct: B

Why B is correct: The lesson explicitly flags this distinction — near-zero CPU with hanging requests is the opposite symptom profile from high CPU, and points toward threads that are blocked or idle rather than busy, which is exactly what the next two lessons (deadlocks and thread pool starvation) diagnose.

Why A is incorrect: This lesson is specifically about CPU that's busy, whether legitimately or pathologically — a hang with idle CPU is a fundamentally different symptom, covered by different lessons.

Why C is incorrect: A busy-wait loop is defined by actively spinning the CPU, checking a condition repeatedly — that produces high CPU, not near-zero CPU, which is the exact opposite of what's described here.

Why D is incorrect: dotnet-gcdump diagnoses heap growth (lesson 316's tool), which has no direct bearing on diagnosing a hang with idle CPU — that scenario calls for the thread/lock analysis covered in lessons 318 and 319.

Reinforcement: High CPU and a near-zero-CPU hang are opposite signatures — recognizing which one you're looking at is what routes you to the right lesson's toolkit.

You now know how to tell legitimate CPU load apart from a pathological bug, and how to pin down exactly which method is responsible with dotnet-trace. Next up: the exact opposite signature — threads sitting frozen, CPU near zero, and nothing moving at all. Deadlocks, diagnosed live.


dotnetmadeeasy.com — Learn C# and .NET, the right way.