gap·map

C# async/await

[ middle depth ]

What async and await actually do

async and await do not create threads. The compiler rewrites your async method into a state machine. The method runs synchronously up to the first await on an operation that is not already finished. At that point it returns the Task to its caller and registers the rest of the method as a continuation that runs when the awaited operation completes. If the awaited Task is already done, execution keeps going with no yield at all.

For I/O there is no thread parked during the wait. await client.GetStringAsync(url) hands the thread back. When the OS signals the response is ready, some thread picks up the continuation.

Wrong "await runs my code on a background thread." await schedules a continuation. Whether it resumes on the same thread, the UI thread, or a pool thread depends on the synchronization context, not on await spawning anything.

The sync-over-async deadlock

The bug that catches everyone once:

// UI thread, or a legacy ASP.NET request thread
string html = GetPageAsync().Result;   // blocks this thread

async Task<string> GetPageAsync() =>
    await client.GetStringAsync(url);   // continuation wants the captured context

A WPF or WinForms app, and classic ASP.NET, run on a single-threaded SynchronizationContext. .Result (or .Wait()) blocks that one thread. The await inside GetPageAsync captured that context and tries to post its continuation back onto it. But the thread is stuck in .Result, so the continuation never runs, so .Result never returns. The app freezes.

Two ways out. Stop blocking: make the caller async and await. Or, in library code, put ConfigureAwait(false) on the await so the continuation resumes on the thread pool instead of the captured context.

ASP.NET Core has no SynchronizationContext, so this exact deadlock does not happen there. Blocking on async is still a mistake (it ties up a pool thread for nothing), just not a fatal one.

async void, Task.Run, and cancellation

async void cannot be awaited, and its exceptions cannot be caught by the caller. Use it only for event handlers, and wrap the body in try/catch. Everywhere else, return Task.

Task.Run is for pushing CPU-bound work onto the thread pool. Do not wrap synchronous I/O in it to look asynchronous; that just blocks a pool thread while the real work runs. Genuine async I/O already has an async API.

Cancellation is cooperative. Thread a CancellationToken through the calls; the operation checks it and throws OperationCanceledException. A token nobody checks cancels nothing.

async Task CopyAsync(CancellationToken ct)
{
    while (HasMore())
    {
        ct.ThrowIfCancellationRequested();
        await WriteChunkAsync(ct);
    }
}
[ senior depth ]

Inside the state machine

The compiler turns an async method into a struct that implements IAsyncStateMachine, with a MoveNext that switches on an integer state field. On the first await of an incomplete operation, MoveNext hooks a continuation through the awaiter's UnsafeOnCompleted and returns; the awaiter later calls MoveNext again to resume at the next state. AsyncTaskMethodBuilder produces the Task you hand back. An async void method uses AsyncVoidMethodBuilder instead, and that one swap is why async void behaves the way it does.

Why async void is fire-and-crash

With no Task, there is nowhere to store a fault. So the void builder re-raises the exception on the SynchronizationContext that was active when the method started. In a UI app that surfaces on the UI thread; on the thread pool (no context) it reaches the process's unhandled-exception path and can take the process down. You also cannot await it, so no caller or test can observe completion or failure.

Wrong "async void is just async Task with no return value." The no-value async method is async Task. async void has no awaitable at all, which is exactly why its exceptions escape normal handling. Keep it to event handlers and wrap the body in try/catch.

Task vs ValueTask, and ConfigureAwait

ValueTask<T> is a struct wrapping either a result or a Task<T>. When a method usually completes synchronously (cache hit, buffered read), returning ValueTask<T> skips the Task heap allocation on that path. That allocation pressure is the one place this topic touches GC; csharp-clr-memory owns the rest.

The constraints are strict: await a ValueTask once, never read .Result before it completes, never call AsTask() twice. Composing with Task.WhenAll needs AsTask(), which allocates and can erase the win. So Task stays the default; reach for ValueTask only after a profiler shows the allocation matters on a hot path.

ConfigureAwait(false) configures a single awaiter, not the whole method, so every await in a library needs its own. It does not stop a caller's .Result deadlock; it removes your library's contribution to one. .NET 8 generalized it to ConfigureAwait(ConfigureAwaitOptions) with flags like SuppressThrowing and ForceYielding.

What interviewers grade

They listen for the captured context as the deadlock's cause, not vague talk about "threads". They want ConfigureAwait(false) framed as a library concern and a no-op in ASP.NET Core, ValueTask treated as a measured optimization rather than a default, and cancellation understood as cooperative. A candidate who claims Task.Run makes I/O asynchronous, or reaches for it inside an ASP.NET Core request to speed things up (it steals from the same pool that serves requests), has not carried this to production. One more tell: awaiting rethrows the original exception, while blocking on .Result wraps it in an AggregateException.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more C# / .NET

was this useful?