How can I detect async deadlocks in a .NET Core web API?

Asked 24 days ago Updated 16 days ago 136 views

0

Async deadlocks in .NET are sneaky. They happen when you mix blocking calls like .Result or .Wait() with asynchronous code, and the synchronization context tries to reclaim a thread that’s already stuck waiting. In a web API, this often surfaces as a request that never returns, leaving connections pooled and threads exhausted.

1 Answer


0

Why async deadlocks happen

Deadlocks in async code are sneaky because the thread context matters. In a classic ASP.NET Core request pipeline, if you block on an async method using .Result or .Wait() from a context that has a synchronization context—like the old ASP.NET request context—you can end up waiting forever. The continuation that should resume your work never gets scheduled because the thread you’re blocking on is the one that’s supposed to pick it up.

In modern .NET Core with the default SynchronizationContext null, this is less common, but it still happens when you call blocking code from within async methods, or when third-party libraries internally use .Wait(). The symptoms are usually thread starvation: requests hang, CPU stays low, and logs show nothing unusual.

Common patterns that trigger them

Here are the usual suspects:

  • Calling .Result or .Wait() on a Task inside an async controller action.
  • Using Task.Run to “fix” a blocking call without understanding the context switch.
  • Synchronous wrappers around async libraries (e.g., older ORMs or HTTP clients).
  • Locking on a value type or async method return value, which causes an InvalidOperationException before a deadlock even forms.

How to detect them in practice

Start with the runtime. When a deadlock occurs, the .NET runtime emits a BCL deadlock event. You can subscribe to this via EventSource or by enabling the diagnostic source:

// Enable diagnostic sources for deadlock detection
EventSource.CurrentGroup = "Microsoft-Windows-DotNETRuntime";
EventListener.Enable("Microsoft-Windows-DotNETRuntime", EventLevel.Verbose, (EventSourceOptions)0);

// Subscribe to deadlock events
Task.Run(async () =>
{
    await Task.Delay(1000);
    var deadlockSource = new EventListener("Microsoft-Windows-DotNETRuntime", "Microsoft-Windows-DotNETRuntime/Deadlock");
    deadlockSource.Enable();
    
    deadlockSource.AllEvents += (sender, args) =>
    {
        var payload = args.Payload;
        Console.WriteLine($"Deadlock detected: {payload}");
    };
});

In production, tools like PerfView or dotnet-trace capture these events without restarting the app. Look for DEADLOCK in the traces. If you see a cycle of waits involving your controller methods, you’ve found the culprit.

Fixing the root cause

The fix is almost always to go fully async:

  1. Remove every .Result and .Wait() from controller actions.
  2. Return Task<IActionResult> and await your service calls.
  3. If you must call a sync method, isolate it to a background thread pool thread with Task.Run, but verify that the downstream code is also async-friendly.

A quick sanity check is to run your API under a load test with dotnet-trace collect --providers Microsoft-Windows-DotNETRuntime:0x3FF and inspect the output. If no deadlock events appear, you’re likely clear.

Write Your Answer