DEV Community

VLAD
VLAD

Posted on

What async/await Actually Does in C# (and Where One .Result Costs You 3 Seconds)

Microsoft has a tutorial on debugging thread pool starvation. Its sample app has an ASP.NET Core endpoint with a fake database call of about half a second. The tutorial hits it with 125 concurrent connections. With .Result in the code, the average response takes 3.48 seconds. With await instead, it takes 532 ms.

The work and the database are the same. So where do three seconds go? To answer that, you need to know what await actually does.

The myth: async means another thread

It doesn't. async doesn't start anything. await means one thing: if this isn't done yet, give the thread back.

The thread goes off and serves another request. When the answer comes, a thread picks up where your method left off. It may not be the same thread.

Who waits for the database?

During that half second, no thread is waiting. The request is out on the network, and the operating system tracks it. When the answer arrives, the OS tells .NET, and a thread pool thread runs the rest of your method for a moment. Then that thread is free again.

Stephen Cleary's classic post There Is No Thread walks through this down to the device driver. The practical consequence is in Microsoft's ASP.NET Core Best Practices: asynchronous APIs let a small pool of threads handle thousands of concurrent requests, because none of them sit waiting on blocking calls.

So async doesn't make one request faster. It lets one server handle more of them.

The compiler rewrites your method

A method can't stop in the middle and come back later. So the C# compiler rewrites it. Take this method:

public async Task<Order> GetOrderAsync(int id)
{
    var json = await http.GetStringAsync($"/orders/{id}");
    var order = Parse(json);
    await db.SaveAsync(order);
    return order;
}
Enter fullscreen mode Exit fullscreen mode

It becomes a hidden state machine, a type that implements IAsyncStateMachine:

  • Your local variables become fields, so they survive the pause.
  • Every await gets a number, a bookmark.
  • One method, MoveNext, holds your code. It starts by reading the state and jumping to the matching bookmark.

Simplified, it looks like this:

struct GetOrderAsyncStateMachine : IAsyncStateMachine   // simplified
{
    public int state;          // -1 = not started · 0, 1 = which await
    private string json;       // your local, now a field

    public void MoveNext()
    {
        switch (state)
        {
            case 0: goto AfterAwait0;
            case 1: goto AfterAwait1;
        }
        // ...your code, cut at every await
    }
}
Enter fullscreen mode Exit fullscreen mode

In release builds it's a struct, and in debug builds it's a class. Stephen Toub's How Async/Await Really Works in C# is the long version, with the real generated code.

What one await turns into

Here's a single await, simplified:

var awaiter = http.GetStringAsync(url).GetAwaiter();
if (!awaiter.IsCompleted)
{
    state = 0;                                              // the bookmark
    builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);  // "call me back"
    return;                                                 // thread is free
}
AfterAwait0:
json = awaiter.GetResult();
Enter fullscreen mode Exit fullscreen mode
  1. Ask the task: are you done?
  2. If not, save the bookmark, tell the task what to call when it finishes, and return. That return is the moment your thread goes free.
  3. When the task finishes, MoveNext runs again, jumps to the bookmark, and reads the result.

Now look at the if again. If the task is already done, there's no pause at all: no bookmark, no thread switch. The code just keeps going. As the ConfigureAwait FAQ puts it, awaits on already-completed awaitables keep running past the await synchronously. So an async method can run from start to finish on the calling thread without ever giving it up. Async doesn't mean it waited.

Where does the rest of the method run?

When await does pause, it captures a context (SynchronizationContext.Current, or a non-default TaskScheduler) and resumes there:

  • WPF / WinForms have a UI context. The rest of your method goes back to the UI thread, so it can touch the screen.
  • ASP.NET Core has no SynchronizationContext. The rest runs on any free thread pool thread.

The .Result deadlock

This is where .Result gets dangerous in a desktop app:

  1. A button click calls an async method, then .Result. The UI thread stops and waits.
  2. Inside, the await finishes. The rest of the method needs to run on the UI thread.
  3. But the UI thread is busy, waiting for that same method.

Each one waits for the other, forever. Run the same code in a console app and it works, which is why it passes your tests. Stephen Cleary's Don't Block on Async Code has the step-by-step version.

ASP.NET Core: no deadlock, still slow

ASP.NET Core has no context, so this deadlock doesn't happen there. The problem is still there, though.

Every .Result holds a thread pool thread that does nothing for the whole database call. With 125 requests at once, the pool runs out of free threads. New work waits in a queue, and the pool adds threads, but not fast enough. That's thread pool starvation, and that's where the three seconds in the demo went. Microsoft's tutorial calls sync-over-async the most common cause of it.

The fix

  • Async all the way. Await from the controller down to the database call, with no .Result or .Wait() in between. The best practices page says it directly: don't block asynchronous execution with Task.Wait or Task.Result.
  • ConfigureAwait(false) in shared library code. It tells await not to come back to the captured context.
  var json = await http.GetStringAsync(url).ConfigureAwait(false);
Enter fullscreen mode Exit fullscreen mode

In app code, leave it out. That's Toub's advice in the FAQ. It's a safety net in libraries, not the fix for a deadlock.

Two more traps

async void. An async Task method puts its exception on the task, so you can await it and catch it. async void has no task, so the exception has nowhere to land, and the caller's try/catch never sees it. In ASP.NET Core, Microsoft calls async void "ALWAYS a bad practice", and its example crashes the process. Use it only for event handlers.

Task.Run isn't async. It moves the work to another thread pool thread, and that thread still does the work or blocks. On a server you're already on a pool thread, so Microsoft's guidance is to not call Task.Run and immediately await it. Async helps when you wait, not when you compute.

The JavaScript difference

If you also write JavaScript:

  • JavaScript runs your code on one thread, and a Promise has no .Result. You can't block on it, so this deadlock can't happen there. await never blocks the main thread.
  • In JavaScript, await always pauses, even on a promise that's already fulfilled. The rest of the function runs in a later microtask.
  • In C#, await on a finished task doesn't pause at all.

What to look for in your code

  • .Result and .Wait() on a task → replace with await.
  • async void outside event handlers → return Task.
  • await Task.Run(...) right away in a controller → just call the method.
  • Shared libraries → ConfigureAwait(false).

await doesn't start a thread. It gives one back, and the compiler keeps a bookmark so your method can come back later. The danger isn't await. It's the code that blocks while it waits.

Have you ever hit the .Result deadlock? How long did it take you to find it?

I make Vlad's Stack: short explainers on how the tools you use every day actually work, for .NET and full-stack developers. More at https://www.youtube.com/@VladsStack .

Top comments (0)