async/await in .net

Elijah Koulaxis

November 10, 2025

async-await-csharp.png

Let's get this straight from the start:

"async in .net, is NOT about multithreading, it's about not blocking a thread"

When you await something, you're not creating a new thread. You're essentially suspending the current method at that point, returning control to the caller, and later resuming when the awaited task completes

If the compiler could speak, it would say:

Cool, I'll just pause your method here, let the runtime know you're waiting, and come back when there's data

What the Compiler actually generates

Let's say you write this very small async method:

public async Task<int> GetAsync()
{
    var data = await GetDataAsync();
    return data.Length;
}

The compiler looks at this and it creates a state machine behind the scenes. If you decompile it, you'll see something like this:

[AsyncStateMachine(typeof(<GetAsync>d__0))]
public Task<int> GetAsync()
{
    var stateMachine = new <GetAsync>d__0();

    stateMachine.builder = AsyncTaskMethodBuilder<int>.Create();
    stateMachine.state = -1;
    stateMachine.outer = this;
    stateMachine.builder.Start(ref stateMachine);

    return stateMachine.builder.Task;
}

And that <GetAsync>d__0 type? It’s a struct implementing IAsyncStateMachine. Inside it, you’ll find an some fields like:

private int _state;
private AsyncTaskMethodBuilder<int> _builder;
private TaskAwaiter<string> _awaiter;

When you await, the compiler stores the awaiter, updates the _state and returns control. When the awaited task finishes, the runtime calls MoveNext() which jumps back to the right place in your method and continues execution

SynchronizationContext and Deadlocks

var result = GetAsync().Result;

Seems very harmless, doens't it? But under the hood, you've just asked the async method to complete synchronously while the UI thread (or old ASP.NET request thread) is sitting there waiting, and the async method itself is trying to resume on that same thread Boom. Deadlock :D

How to fix it? You can just opt out the context capture mechanism when you don't need it:

await GetAsync().ConfigureAwait(false);

This basically tells the runtime: "I don't care where (in which context) you resume me, just do it asap"

It's a must use for Libraries

Funny enough, this is also why async void doesn’t immediately explode in UI apps.. exceptions bubble up to the current SynchronizationContext (like WPF or WinForms), where the framework can handle them for you. But in .NET Core or ASP.NET Core, there’s no such context by default, so you must return a Task, otherwise, the exception is lost into the void (pun intended) instead of being captured in the task

ValueTask

Task is absolutely great, BUT, every time you return a Task, you allocate an object on the heap

If your async method often completes synchronously (e.g. cache), that's just wasted overhead.

That's why we have ValueTask, a struct that can wrap either:

public async ValueTask<int> GetCachedDataAsync()
{
    if (_cache.TryGetValue("key", out var result))
    {
        return result; // synchronous fast path, no allocation
    }

    return await LoadFromDatabaseAsync(); // async path
}

Just be aware, if you await a ValueTask multiple times or call .Result more than once, you'll get a runtime exception They're one-shot. Use them when you really care about allocations, otherwise stick with Task

Task vs Thread

"But I Thought Async Was Parallel!"

Nope

await Task.Delay(1000);
Console.WriteLine("done");

This doesn't create a new thread, it just sets a timer. The current thread is released back to the thread pool until the delay completes

If you really want parallel CPU-bound work, you need to explicilty offload:

await Task.Run(() => compute());

Task.Run schedules that delegate on the thread pool, so yes, that's where multithreading enters the picture

Best Practies

1. Never block on async code

Avoid .Result, .Wait(), .GetAwaiter().GetResult()

Use await all the way up!

2. Prefer ValueTask only for hot paths If your method completes synchronously 90% of the time, ValueTask saves you allocations. Otherwise, it’s complexity for nothing

3. Use ConfigureAwait(false) in libraries

You don’t want your async library logic tied to some caller’s context

4. Watch out for fire-and-forget

If you just GetAsync(); without await, you lose exception handling and control

If you must fire and forget, wrap it:

_ = Task.Run(async () =>
{
    try { 
        await GetAsync(); 
    }
    catch (Exception ex) {
        Log(ex); 
    }
});

5. Async all the way down

Don’t mix sync and async layers. That’s how deadlocks and thread starvation happen

What Happens at Runtime (Bonus)

  1. You call an async method -> returns immediately with a Task (a promise of future result)
  2. Your method runs until the first await
  3. It returns to the caller, the continuation is registered with the awaited task
  4. When the awaited task completes, the runtime invokes the continuation
  5. The state machine resumes, executes the rest, sets the task's result and completes
Tags:
Back to Home