Skip to content

.NET Core · C# Core

How C# Async/Await Works Under the Hood

7 min read Updated 2026-06-23 Share:

Practice Async / Await interview questions

Why async/await mastery separates good from great C# developers

async/await is C#'s most impactful feature for I/O-bound applications — yet most developers only understand the happy path. Interviewers specifically probe the edges: deadlocks, ConfigureAwait, ValueTask, cancellation, and exception propagation. This article walks through how it actually works and what to watch for.

What the compiler actually generates

async is 100% syntactic sugar. The compiler transforms an async method into a state machine — a struct implementing IAsyncStateMachine. Consider:

public async Task<string> FetchAsync(string url)
{
    var response = await httpClient.GetAsync(url);
    var body     = await response.Content.ReadAsStringAsync();
    return body;
}

The compiler generates something equivalent to:

// Pseudocode — the actual generated code is more complex:
private struct FetchAsyncStateMachine : IAsyncStateMachine
{
    public int _state;     // which await point we're at
    public string _url;
    private HttpResponseMessage _response;
    private TaskAwaiter<HttpResponseMessage> _awaiter1;
    private TaskAwaiter<string> _awaiter2;

    public void MoveNext()
    {
        switch (_state)
        {
            case 0:
                _awaiter1 = httpClient.GetAsync(_url).GetAwaiter();
                if (!_awaiter1.IsCompleted) { _state = 1; /* schedule continuation */ return; }
                goto case 1;
            case 1:
                _response = _awaiter1.GetResult();
                _awaiter2 = _response.Content.ReadAsStringAsync().GetAwaiter();
                if (!_awaiter2.IsCompleted) { _state = 2; return; }
                goto case 2;
            case 2:
                var body = _awaiter2.GetResult();
                // complete the outer Task<string> with 'body'
                break;
        }
    }
}

Key insight: no thread is blocked. When the Task is not complete, MoveNext returns and the calling thread is released. A completion callback is registered on the I/O operation; when it fires, MoveNext is called again from wherever the runtime decides (ThreadPool, SynchronizationContext).

Task vs Thread — a critical distinction

// Thread: OS resource, ~1 MB stack, kernel overhead
var thread = new Thread(() => Console.WriteLine("thread"));
thread.Start(); // allocates a thread for the duration

// Task: a promise of a result — may or may not need a thread
var task = Task.Run(() => Console.WriteLine("pool thread"));
// Task.Run uses the ThreadPool

// I/O-bound async Task: ZERO threads while waiting
async Task<string> ReadFileAsync(string path)
{
    // Thread released during file I/O:
    return await File.ReadAllTextAsync(path);
    // Thread resumes (possibly a different pool thread) when I/O completes
}

A server handling 10,000 concurrent I/O-bound requests with async/await may use only a handful of threads — each request releases its thread while waiting for the network or disk. With synchronous blocking, each request needs a dedicated thread: 10,000 threads = ~10 GB of stack memory.

SynchronizationContext and ConfigureAwait(false)

After an await, C# tries to resume on the SynchronizationContext that was current when the method suspended. In WPF/WinForms, this is the UI thread dispatcher. In ASP.NET Classic, it was the request context. In ASP.NET Core and console apps, there is no SynchronizationContext (null means "run on any pool thread").

// WPF — continuation resumes on UI thread (default behavior):
private async void Button_Click(object sender, EventArgs e)
{
    var data = await GetDataAsync();
    label.Content = data; // safe: we're on the UI thread
}

// Library code — doesn't need to return to any specific context:
public async Task<string> GetDataAsync()
{
    // ConfigureAwait(false): resume on any pool thread, no context marshalling
    var raw = await httpClient.GetStringAsync(url).ConfigureAwait(false);
    return Process(raw);
}

The deadlock trap:

A single-threaded SynchronizationContext + blocking .Result call = deadlock:

// Deadlock in WPF/WinForms or ASP.NET Classic:
string data = GetDataAsync().Result; // blocks UI/request thread
// GetDataAsync's continuation tries to resume on the same thread — BLOCKED
// Result: neither side can proceed — deadlock!

// Fix 1: go async all the way
string data = await GetDataAsync();

// Fix 2: break context capture in library (ConfigureAwait(false) in GetDataAsync)

Rule: In library code, always use ConfigureAwait(false). In application code that updates UI or accesses request-scoped services, don't.

Task.WhenAll and Task.WhenAny

// Parallel I/O — 3 independent fetches in parallel:
var t1 = FetchUserAsync(1);   // starts immediately
var t2 = FetchUserAsync(2);   // starts immediately
var t3 = FetchUserAsync(3);   // starts immediately
// All three are in flight simultaneously

User[] users = await Task.WhenAll(t1, t2, t3);
// Total time ≈ max(t1, t2, t3) — not t1 + t2 + t3

// WhenAny — timeout pattern:
var work    = DoLongWorkAsync();
var timeout = Task.Delay(TimeSpan.FromSeconds(30));
if (await Task.WhenAny(work, timeout) == timeout)
    throw new TimeoutException("Operation exceeded 30 s");
var result = await work;

Exception handling with WhenAll:

var tasks = new[] { t1, t2, t3 };
try { await Task.WhenAll(tasks); }
catch
{
    // 'await' re-throws only the first exception
    // Inspect all faults via the Task objects:
    foreach (var t in tasks.Where(t => t.IsFaulted))
        logger.LogError(t.Exception!.InnerException, "Task failed");
}

async void — the exception swallower

// async void — exception escapes to SynchronizationContext (process crash):
async void LoadBad()
{
    await Task.Delay(100);
    throw new Exception("boom"); // crashes the app!
}

// async Task — exception stored in the Task:
async Task LoadGood()
{
    await Task.Delay(100);
    throw new Exception("boom"); // caught when awaited
}

try { await LoadGood(); }
catch (Exception ex) { /* caught correctly */ }

The only acceptable async void is event handlers:

private async void Button_Click(object sender, EventArgs e)
{
    try { await DoWorkAsync(); }
    catch (Exception ex) { ShowError(ex); } // must handle internally
}

ValueTask — when to skip the heap allocation

Task<T> is a class — every call allocates a heap object. ValueTask<T> is a struct:

// Cache hit path: synchronously available — Task<T> still allocates!
public async Task<int> GetTaskAsync()
{
    if (_cache.TryGetValue("n", out int v)) return v; // still wraps in a Task!
    return await FetchAsync();
}

// ValueTask: zero allocation on the fast path:
public ValueTask<int> GetValueTaskAsync()
{
    if (_cache.TryGetValue("n", out int v))
        return new ValueTask<int>(v); // struct, no heap allocation
    return new ValueTask<int>(FetchAsync());
}

ValueTask<T> constraints — await it exactly once:

var vt = GetValueTaskAsync();
var a = await vt; // ok
var b = await vt; // undefined behaviour! don't do this

// If you need to await multiple times, convert to Task<T>:
Task<int> task = GetValueTaskAsync().AsTask();
var x = await task;
var y = await task; // fine — Task can be awaited multiple times

Default to Task<T>. Use ValueTask<T> only in hot paths (high-frequency calls where the fast synchronous path is common) and after profiling confirms the allocation overhead matters.

CancellationToken — the cooperative cancellation model

// Caller: create source, set timeout, pass token
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

try { await DoWorkAsync(cts.Token); }
catch (OperationCanceledException) { Console.WriteLine("Cancelled or timed out"); }

// Method: accept and forward the token
public async Task DoWorkAsync(CancellationToken ct = default)
{
    // Forward to awaitable calls:
    await httpClient.GetAsync(url, ct);           // cancels the HTTP request
    await Task.Delay(5000, ct);                   // cancels the delay

    // Manual check in CPU-bound loops:
    for (int i = 0; i < 1_000_000; i++)
    {
        ct.ThrowIfCancellationRequested();
        Process(i);
    }
}

IAsyncEnumerable — streaming without buffering everything

// Producer: yields items asynchronously one at a time
public async IAsyncEnumerable<Order> StreamOrdersAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var order in dbContext.Orders.AsAsyncEnumerable().WithCancellation(ct))
        yield return order;
}

// Consumer: processes items as they arrive — never buffers all orders
await foreach (var order in StreamOrdersAsync(cancellationToken))
    await ProcessOrderAsync(order);

Compare to Task<IEnumerable<Order>>: that waits until all rows are loaded before the caller gets the first one. IAsyncEnumerable<T> pipelines the work — rows are processed as they come off the database cursor.

Recap

async/await transforms methods into state machines — no threads are blocked during I/O. ConfigureAwait(false) in library code prevents context-capture overhead and deadlocks. Never block async code with .Result/.Wait() on a single-threaded SynchronizationContext. Run independent I/O tasks concurrently with Task.WhenAll. Never use async void outside event handlers — exceptions disappear silently. Use ValueTask<T> only in allocation-sensitive hot paths. Pass CancellationToken through every layer and forward it to every awaitable call. Use IAsyncEnumerable<T> for large or unbounded data streams.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel