Fixing Deadlocks in .NET
Transitioning a large codebase from synchronous to asynchronous execution in C# sounds straightforward, but it hides a dangerous trap. Last week, I spent six hours debugging why a legacy ASP.NET Web API simply stopped responding under load. The culprit? A classic Async Deadlock.
The Scenario: The "Silent Hang"
We were integrating a new cloud-based logging service into an older .NET Framework application. To keep things simple, one of our developers called an asynchronous method from a synchronous controller action using .Result. In isolation, it worked. Under the slightest bit of concurrency, the entire thread pool starved, and the API froze.
The Problematic Code
Here is a simplified version of what caused the disaster:
// The Legacy Controller
public class DataController : ApiController {
public string Get() {
// BLOCKED HERE: Calling async from sync
var data = Service.GetDataAsync().Result;
return data;
}
}
// The Service Method
public async Task<string> GetDataAsync() {
await Task.Delay(100); // Context is captured here
return "Success";
}
Why did it crash?
In the .NET Framework (and UI apps), there is a SynchronizationContext. When the await finishes in GetDataAsync, it tries to resume on the original thread. However, that thread is already occupied—it’s waiting for .Result to finish. They are both waiting for each other. Deadlock.
The Solution: Two-Step Fix
To solve this properly, we implemented a two-fold strategy that every C# developer should follow:
1. Go "Async All the Way"
The best fix is never to block on async code. We refactored the controller to be truly asynchronous:
public async Task<IHttpActionResult> Get() {
var data = await Service.GetDataAsync(); // No blocking
return Ok(data);
}
2. Use ConfigureAwait(false)
In library or service code, if you don't need to return to the original context (like the UI or the HTTP request context), always use ConfigureAwait(false). This tells the Task to resume on any available thread pool thread.
public async Task<string> GetDataAsync() {
await Task.Delay(100).ConfigureAwait(false);
return "Success";
}
The Result
After applying these changes, the CPU usage stabilized, and the "hangs" disappeared. The main lesson? Mixing synchronous and asynchronous code is a recipe for disaster.
If you are maintaining a legacy C# app, audit your .Result and .Wait() calls today before they audit your server's uptime!
Have you faced similar issues with Task synchronization? Let's talk in the comments below!

Comments
Post a Comment