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

Popular posts from this blog

How to Compare Strings in C#: Best Practices

C# vs Rust: Performance Comparison Using a Real Algorithm Example

Is Python Becoming Obsolete? A Look at Its Limitations in the Modern Tech Stack