Skip to content

Case 5: The Deadlock Trap - Blocking on async Code with .Result or .Wait() ​

The Scenario πŸ“ ​

  • System: An ASP.NET Core application. A developer is trying to call an async (asynchronous) method from inside a sync (synchronous) method, for example, inside a constructor or an old library method that can't be changed.
  • Problem: To get the result from an async Task, they use .Result or .Wait().

The Problematic Code (Extremely Dangerous) ​

csharp
public class MyController : ControllerBase
{
    private readonly MyService _myService;

    // A constructor is one of those places that cannot be async.
    public MyController(MyService myService)
    {
        _myService = myService;
        // The mistake: Trying to run async code in a sync method
        // by blocking the thread.
        var initialData = _myService.GetInitialDataAsync().Result; // <-- DEADLOCK!
        // ...
    }
}

public class MyService
{
    public async Task<string> GetInitialDataAsync()
    {
        // Simulate an async I/O operation
        await Task.Delay(1000);
        return "Some data";
    }
}

The Bottleneck and the "Deadlock" 🧐 ​

  1. The Synchronization Context: In UI applications and older versions of ASP.NET, there's something called a "Synchronization Context." It ensures that the code after an await runs on the original thread or context. While ASP.NET Core doesn't have this specific context, a similar problem can happen with the thread pool.

  2. The Deadly Embrace (Deadlock):

    • The main thread calls .Result. It blocks and waits for GetInitialDataAsync to finish.
    • Inside GetInitialDataAsync, await Task.Delay(1000) is called. This task will complete after 1 second.
    • After Task.Delay is done, the async/await machinery tries to get back to the original thread to run the rest of the method (return "Some data").
    • The Problem: That original thread is still blocked by the .Result call, waiting for the task to finish.
    • The Result: The task is waiting for the thread, and the thread is waiting for the task. They will wait for each other forever, causing a deadlock. Your application will hang.
  3. Thread Pool Starvation (Even Without a Deadlock):

    • In ASP.NET Core, a full deadlock is less common, but another problem arises: Thread Pool Starvation. The thread handling the request is blocked and cannot be returned to the thread pool to serve other requests. This exhausts the available threads and can crash the application under high load (just like in Case 2).

The Solution: "Async All the Way" βœ… ​

  • The Logic: The only correct solution is to make the entire call chain asynchronous.

  • The Optimized Code:

    • You should not perform I/O in a constructor. Move it to the controller's action method instead.
    csharp
    public class MyController : ControllerBase
    {
        private readonly MyService _myService;
    
        public MyController(MyService myService)
        {
            _myService = myService;
        }
    
        [HttpGet]
        public async Task<IActionResult> MyAction()
        {
            // Move the I/O call here and use await
            var initialData = await _myService.GetInitialDataAsync();
            // ...
            return Ok(initialData);
        }
    }

The Results ✨ ​

  1. No Deadlock: Because no thread is ever blocked, the deadly embrace never happens.
  2. Optimized Thread Pool: The thread is returned to the pool while waiting for I/O, making the application much more scalable.

Conclusion:

  • Golden Rule: Never block on an asynchronous Task by calling .Result or .Wait().
  • Follow the "Async All the Way" principle. If a method you call is async, your method must also be async and use await.
  • Improperly mixing sync and async code is one of the most common causes of the hardest-to-find performance issues and deadlocks in C#.