Diagnosing a deadlock with Break All, the Threads window, Parallel Stacks and Parallel Watch, then confirming it in production with dotnet-dump, and fixing it with consistent lock ordering or async-friendly design.
When it hangs, I press Break All so every thread stops where it is. In the Threads window I look for threads sitting in Monitor.Enter, Lock.Enter or Task.Wait, and switch to each one to read its call stack. Parallel Stacks shows all thread stacks merged into one graph, so two threads each holding a lock and waiting for the other's lock stand out immediately. That's the classic opposite-order deadlock. In production I can't attach, so I take a dump with dotnet-dump and run clrstack -all and syncblk. The fix is a consistent lock order, smaller locks, or removing sync-over-async.
"First I check it really is a deadlock: no progress with low CPU, rather than a livelock with high CPU or thread pool starvation with a growing queue, which
dotnet-countersshows quickly. Locally I press Break All and use the Threads window to find threads blocked inMonitor.Enter,Lock.EnterorTask.Wait, flagging, switching to, and freezing or thawing them to test a theory; then Parallel Stacks merges every stack into one graph, so two threads waiting on each other stand out. In production I can't pause the service, so I capture a dump withdotnet-dump, read every stack with clrstack -all, and use syncblk, remembering it only shows Monitor-based locks, not System.Threading.Lock.For example, our [payments worker] hung about [once a week] during end-of-day settlement, which had a hard cut-off. I needed to find the cause rather than keep restarting it. I captured a dump during the next hang and found two threads each holding one account lock and waiting for the other, the classic opposite lock order. I reproduced it locally, confirmed it with the Threads window and Parallel Stacks, changed the code to lock accounts in a consistent order by account id, and added a stress test and a progress alert. There were no more hangs over the next [N months], and settlement finished [on time every day].
The mistake I see is restarting and moving on, so I treat a deadlock as a design defect: fix it with lock ordering, smaller locks, timeouts or async all the way instead of sync-over-async, and make hangs observable with progress and queue-age metrics."
A deadlock looks like no progress with low CPU: requests time out, the process is alive, and dotnet-counters shows few completed work items. High CPU with no progress is more likely a livelock or a busy loop; a growing thread count with a long thread pool queue is more likely thread pool starvation (often from sync-over-async), which is related but different.
Monitor.Enter, Lock.Enter, SemaphoreSlim.Wait or Task.Wait are suspects. Flag them, and double-click to switch the Call Stack to each.TransferUnsafe waiting on lock B" and "thread 12 in TransferUnsafe waiting on lock A" side by side.You cannot pause a production service, so capture a dump and analyse it offline:
dotnet-dump collect -p <pid> (on App Service use Diagnose and solve problems to collect a memory dump; in Kubernetes run dotnet-dump from a debug container or use dotnet-monitor).dotnet-dump analyze <file>, then clrthreads, clrstack -all, and syncblk to list Monitor locks and their owning threads. You can also open the dump in Visual Studio and use the same Threads and Parallel Stacks windows.Monitor.TryEnter or SemaphoreSlim.WaitAsync(timeout) turn a hang into a visible error..Result and .Wait() with await all the way up.A payments worker in Azure Australia East hung roughly once a week during end-of-day settlement. A dump showed two threads in Transfers.TransferUnsafe, each owning one account's lock and waiting for the other: transfers A→B and B→A in the same batch. Ordering locks by account id fixed it, and a stress test with opposite-direction transfers now runs in CI.
The interviewer is checking structured concurrency debugging and whether you can work without a live debugger.
Green flags
Red flags
Thread.Sleep changes.Three problems look like "the service hangs" and need different fixes:
| Symptom | Deadlock | Thread pool starvation | Livelock / busy loop |
|---|---|---|---|
| CPU | Near zero | Low to moderate | High |
| Thread count | Stable | Climbing slowly (the pool injects threads) | Stable |
| Thread pool queue length | Can be normal | Growing | Normal |
| Progress | None on the affected work | Very slow, then recovers when load drops | None, but threads are running |
| Typical cause | Locks taken in opposite order | Sync-over-async (.Result, .Wait()) under load |
Retry loops, spin waits |
| Fix | Lock ordering, smaller scopes | Async all the way | Back-off, fix the loop |
Check first with dotnet-counters monitor -p <pid> --counters System.Runtime and look at CPU, thread count, threadpool-queue-length and monitor-lock-contention-count.
Thread 9: Transfer(A -> B) Thread 12: Transfer(B -> A)
lock(A) acquired lock(B) acquired
lock(B) waiting ------------+ +------ lock(A) waiting
| |
v v
each holds the lock the other needs
--> neither can ever continue
The four Coffman conditions must all hold for a deadlock:
| Condition | Here | Break it by |
|---|---|---|
| Mutual exclusion | lock allows one owner |
Lock-free or immutable designs |
| Hold and wait | Holds A while waiting for B | Take all locks at once, or none |
| No pre-emption | A lock can't be taken away | Timeouts with Monitor.TryEnter |
| Circular wait | A waits for B, B waits for A | Consistent global lock order |
Consistent ordering is the usual fix because it removes the cycle without changing anything else.
reproduce under the debugger (load test or repro program)
|
v
Debug > Break All (Ctrl+Alt+Break) -- every thread stops where it is
|
v
Threads window
group by Location, search "Enter" / "Wait"
flag suspects: Monitor.Enter, Lock.Enter, SemaphoreSlim.Wait, Task.Wait
|
v
Parallel Stacks (Threads view)
identical stacks merge: "[2 threads] Transfers.TransferUnsafe"
hover the box to see which threads are in it
|
v
Parallel Watch: from.Id, to.Id across the flagged threads
thread 9: from=A to=B thread 12: from=B to=A
|
v
prove it: Freeze thread 12, Continue --> thread 9 still stuck?
Thaw 12, freeze 9 ... confirms the cycle
In Visual Studio, the Threads window's Location column shows the top frame for each thread, and threads blocked waiting for a lock show a wait icon. The Parallel Stacks window can also annotate a blocked thread with what it is waiting for, which can point straight at the owning thread.
| Window | Gives you | Tips |
|---|---|---|
| Threads | Every thread with managed id, name, location and flags | Name your threads, group by location, flag suspects, freeze and thaw |
| Parallel Stacks: Threads | All stacks merged into a graph | The fastest way to see "N threads all stuck in the same place" |
| Parallel Stacks: Tasks | Async logical stacks | For hangs where no thread is blocked (section 7) |
| Parallel Watch | One expression evaluated on every thread | Shows which thread holds which account or key |
| Call Stack | One thread's frames | Switch frames to see the locals at each level |
You can't Break All on a production service, so capture a dump of the hung process and analyse it offline:
| Environment | How to capture |
|---|---|
| Any host with the tool | dotnet-dump collect -p <pid> |
| Azure App Service | Diagnose and solve problems > Collect Memory Dump |
| Kubernetes | Run dotnet-dump in an ephemeral debug container that shares the process namespace, or use dotnet-monitor as a sidecar |
| Automatic | DOTNET_DbgEnableMiniDump=1 captures a dump on crash (not on hang) |
Then dotnet-dump analyze <file>:
| Command | Shows |
|---|---|
clrthreads |
Managed threads, including lock counts held |
clrstack -all |
Every managed thread's stack |
syncblk |
Monitor-based locks, their owning thread and waiters |
dumpasync |
Pending async state machines and their await chains |
threadpool |
Thread pool state and queue length |
You can also open the dump in Visual Studio and use the same Threads and Parallel Stacks windows as a live session.
lock (obj) on an ordinary object uses Monitor. Contended Monitor ownership is recorded in a sync block, which is why syncblk can list owner threads and waiters.
.NET 9 introduced System.Threading.Lock. When you write lock (myLock) and myLock is a Lock, the compiler uses Lock.EnterScope() instead of Monitor. It doesn't use sync blocks, so syncblk won't list it. The deadlock looks the same in the stacks: threads sitting inside Lock.Enter. Use clrstack -all or Parallel Stacks to find them.
Some hangs have no lock owner to find, because no thread is waiting:
| Pattern | What happens |
|---|---|
task.Result on a single-threaded SynchronizationContext (WPF, WinForms, classic ASP.NET) |
The blocked thread is the only one that can run the continuation |
A TaskCompletionSource that is never completed |
Every awaiter waits forever |
SemaphoreSlim.WaitAsync with a Release skipped on an exception path |
The next caller never gets in |
Two SemaphoreSlim instances taken in opposite order |
Same cycle as lock, but asynchronous |
ASP.NET Core has no SynchronizationContext, so sync-over-async there usually shows up as thread pool starvation, not a classic deadlock. For async hangs, use dumpasync and the Tasks view of Parallel Stacks. Always release semaphores in finally.
| Technique | Example |
|---|---|
| Global lock order | Lock accounts in order of Id (the code example) |
| Smaller critical sections | Never log, call I/O, raise events or call unknown code while holding a lock |
| Timeouts | Monitor.TryEnter(obj, TimeSpan.FromSeconds(2)) or WaitAsync(timeout) turns a silent hang into an error you can see |
| Remove the lock | Partition work by key through a Channel<T> per partition, or use immutable data and Interlocked |
| Async all the way | Replace .Result and .Wait() with await up the call chain |
| Regression test | A stress test running thousands of A-to-B and B-to-A transfers in parallel |
The diagnosis tools are stable. System.Threading.Lock is increasingly used in libraries, so expect fewer locks to appear in syncblk and rely on stacks. Check the .NET 11 diagnostics release notes for updates to dotnet-dump and dotnet-monitor.
from.Id and to.Id to Parallel Watch.dotnet-dump collect -p <pid>, and find the same stacks with clrstack -all and the owners with syncblk.Sync property to a System.Threading.Lock and see that syncblk no longer lists it but clrstack -all still shows the cycle.Transfers.Transfer and confirm it completes.Transfers.cs, a deadlock-prone transfer and the fix. It locks plain objects so the locks are Monitor-based and show up in syncblk:
namespace Payments.Worker;
public sealed class Account(string id, decimal balance)
{
public string Id { get; } = id;
public decimal Balance { get; set; } = balance;
// Monitor-based lock object. With the .NET 9+ System.Threading.Lock type the
// deadlock is identical, but syncblk will not list it; read the stacks instead.
public object Sync { get; } = new();
}
public static class Transfers
{
// Deadlock-prone: one thread locks A then B while another locks B then A.
public static void TransferUnsafe(Account from, Account to, decimal amount)
{
lock (from.Sync)
{
Thread.Sleep(10); // demo only: widens the race window so the deadlock reproduces every run
lock (to.Sync)
{
from.Balance -= amount;
to.Balance += amount;
}
}
}
// Fix: always acquire locks in a consistent global order.
public static void Transfer(Account from, Account to, decimal amount)
{
var (first, second) = string.CompareOrdinal(from.Id, to.Id) < 0 ? (from, to) : (to, from);
lock (first.Sync)
{
lock (second.Sync)
{
from.Balance -= amount;
to.Balance += amount;
}
}
}
}
Program.cs:
using Payments.Worker;
var a = new Account("ACC-1", 1_000m);
var b = new Account("ACC-2", 1_000m);
var t1 = Task.Run(() => Transfers.TransferUnsafe(a, b, 10m));
var t2 = Task.Run(() => Transfers.TransferUnsafe(b, a, 20m));
if (!Task.WaitAll([t1, t2], TimeSpan.FromSeconds(5)))
{
Console.WriteLine($"Deadlocked. PID {Environment.ProcessId}: use Break All, or run dotnet-dump collect -p {Environment.ProcessId}");
Console.ReadLine();
}
Production dump analysis (all commands run inside one analyze session):
dotnet tool install --global dotnet-dump
dotnet-dump collect -p 12345
dotnet-dump analyze ./core_20261022_101500
> clrthreads
> clrstack -all
> syncblk
> dumpasync
Mistake: restarting the service and moving on. The hang clears, but the lock-ordering bug is still there and returns at the next peak, often at the worst time. Instead: capture a dump before restarting, find the cycle, and fix it with a regression test.
Mistake: confusing a deadlock with thread pool starvation.
Raising ThreadPool.SetMinThreads does nothing for two threads waiting on each other, and only hides starvation.
Instead: check dotnet-counters first. Low CPU with stable threads and no progress suggests a deadlock; a growing queue and thread count suggests starvation.
Mistake: inspecting one thread at a time. With hundreds of thread pool threads, stepping through each one's Call Stack takes forever and you miss the pattern. Instead: Break All, then use Parallel Stacks to see every stack merged into one graph, and Parallel Watch to see what each thread holds.
Mistake: calling external code, I/O or events while holding a lock. You can't control what that code locks, so a cycle can form through code you never looked at, and hold times become unpredictable. Instead: keep critical sections to in-memory updates only, and do logging and I/O after releasing the lock.
Mistake: sync-over-async.
.Result and .Wait() block a thread while waiting for a continuation. On a single-threaded SynchronizationContext that is a deadlock; in ASP.NET Core it causes starvation under load.
Instead: use await all the way up the call chain.
Mistake: forgetting to release a SemaphoreSlim on an exception path.
The next caller waits forever, and no thread is blocked, so the Threads window shows nothing.
Instead: always Release() in a finally block, and use dumpasync or the Tasks view to find async hangs.
Mistake: expecting syncblk to show System.Threading.Lock owners.
The .NET 9+ Lock type doesn't use sync blocks, so syncblk reports nothing and the deadlock seems to have no locks.
Instead: read clrstack -all or Parallel Stacks for threads inside Lock.Enter.
Mistake: "fixing" it with Thread.Sleep or by moving code around.
Changing timing makes the deadlock rarer, not impossible, and it comes back under different load.
Instead: break one of the four deadlock conditions, usually with a consistent lock order.
Mistake: a health check that doesn't measure progress. The HTTP endpoint keeps answering on other threads while the worker is stuck, so nothing alerts. Instead: alert on queue age or work-item progress, and make the liveness check track it.
Mistake: leaving production dumps lying around. A dump contains everything in memory, including customer data, tokens and connection strings. Instead: store dumps encrypted with restricted access, record who took them, and delete them when the investigation is done.
A senior engineer treats a deadlock as a design defect, not a bad day. The investigation proves the cycle with evidence (stacks from two threads, lock owners), and the fix removes the possibility: global lock ordering, lock-free designs such as partitioning work per key through a Channel<T>, or async all the way. They add a regression test that forces the interleaving.
They also know the async variants, which have no blocked lock owner to find:
task.Result blocks the only thread the continuation needs. ASP.NET Core has no such context, so there it shows up as thread pool starvation instead.TaskCompletionSource nobody completes, or a SemaphoreSlim.WaitAsync whose Release was skipped on an exception path (always release in finally).SemaphoreSlim instances taken in opposite order by different flows deadlock exactly like lock.For these, dumpasync and the Tasks view of Parallel Stacks show the stuck chain.
Observability catches hangs sooner: request timeouts with clear errors, a metric for work-item progress or queue age, the runtime's lock contention and thread pool queue counters in Application Insights or dotnet-counters, and a liveness check that tracks progress rather than just answering HTTP. They also make dumps a routine, safe capability (who can take them, where they are stored encrypted, when they are deleted) before an incident needs one.
When I can't attach a debugger, I take a dump: a snapshot of the process's memory, threads and stacks that I analyse offline. For a hang or a leak, dotnet-dump collect -p <pid> does it in seconds. For crashes, I enable automatic crash dumps with the DOTNET_DbgEnableMiniDump environment variables. On App Service I use Diagnose and solve problems, and in Kubernetes dotnet-monitor. Then I analyse with dotnet-dump analyze: clrstack -all and syncblk for hangs, dumpheap -stat and gcroot for leaks, pe for exceptions. Or I open the dump in Visual Studio and use the Threads and Parallel Stacks windows. Dumps contain customer data, so they're handled as sensitive.
First I measure: Application Insights tells me which endpoint is slow and at what percentile, and whether time goes into dependencies like SQL or HTTP. Then I reproduce it on a Release build and run the Visual Studio Performance Profiler (Alt+F2): CPU Usage for hot paths, Database for query count and duration, .NET Object Allocation Tracking for GC pressure, and .NET Async for slow awaits. In an environment where I can't use Visual Studio, dotnet-counters shows live metrics like thread pool queue length and GC, and dotnet-trace captures CPU samples. I fix the biggest cause, often an N+1 query, re-measure against the baseline, and add a load test so it doesn't regress.
How to tell it (STAR)
**Situation:** Our [payments worker] hung about once a week during end-of-day settlement, which had a hard cut-off. **Task:** I needed to find the cause rather than keep restarting it. **Action:** I captured a dump during the next hang and found two threads each holding one account lock and waiting for the other. I reproduced it locally, confirmed it with the Threads window and Parallel Stacks, changed the code to lock accounts in a consistent order, and added a stress test and a progress alert. **Result:** There were no more hangs over the next [N months], and settlement finished [on time every day].
Last updated 1 Nov 2026.