async and await are .NET’s default model for non-blocking I/O. The goal is responsiveness and scalability: while code waits on network, disk, or database I/O, the thread is released so it can do other work. This is why async code keeps UIs responsive and helps servers handle more concurrent requests without proportionally more threads.
The most important mental model: async is not the same as “run on another thread”. In many cases, no thread is actively executing your method while an awaited I/O operation is in flight. The thread is returned to the pool and reclaimed when the I/O completes.
How It Works — The State Machine
The C# compiler transforms every async method into a state machine struct. Each await point becomes a state transition. At compile time, this code:
public async Task<string> FetchAsync(string url)
{
var response = await _http.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}becomes roughly equivalent to a struct with fields capturing local variables (url, response) and a MoveNext() method that switches on the current state. The runtime calls MoveNext() when the awaited operation completes.
What happens at each await:
- The method runs synchronously until it hits an incomplete awaitable.
- The awaitable’s
IsCompletedis checked — if already done (cached result, synchronous completion), execution continues without yielding. - If not done, a continuation is registered on the awaitable and the method returns an incomplete
Taskto its caller. - When the I/O completes, the continuation fires and
MoveNext()resumes from the saved state.
This is why await differs from Task.Result and Task.Wait(): those block the current thread, while await releases it.
Allocation cost. The state machine is a struct, so a method that completes synchronously (every await hits an already-completed awaitable) allocates nothing on the heap. The struct is boxed to the heap only when the method actually suspends — at that point the runtime needs to keep it alive across the await. In hot paths that suspend frequently, two techniques cut this allocation:
- Return
ValueTask/ValueTask<T>instead ofTask<T>when the result is often available synchronously. - Opt into a pooled state-machine builder with
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))](or build the project withDOTNET_ReadyToRun/ pooling enabled) so the boxed boxes are reused rather than GC’d.
Default to Task and only reach for these once profiling shows async allocation is a real cost.
ConfigureAwait
By default, await captures the current SynchronizationContext (or TaskScheduler) and resumes on it. In a UI app, this means the continuation runs on the UI thread — useful for updating controls. In ASP.NET Core, there is no SynchronizationContext, so this is a no-op.
ConfigureAwait(false) tells the runtime: “resume on any available thread, don’t capture the context.”
// Library code — no UI or request context needed
var data = await _repo.GetAsync(id).ConfigureAwait(false);Rule of thumb:
- Library code: always use
ConfigureAwait(false)to avoid context capture overhead and deadlock risk. - Application code (controllers, view models): omit it — you usually want to resume on the original context.
ExecutionContext vs SynchronizationContext
These are two different ambient mechanisms, and conflating them is a common source of bugs:
SynchronizationContextcontrols where (which thread/scheduler) the continuation resumes. This is whatConfigureAwait(false)opts out of.ExecutionContextcarries ambient state —AsyncLocal<T>values, the security/impersonation context — and always flows acrossawait, even withConfigureAwait(false). SoConfigureAwait(false)does not drop yourAsyncLocalvalues; it only changes the resumption thread. To stopExecutionContextfrom flowing (rare), useExecutionContext.SuppressFlow().
ConfigureAwaitOptions (.NET 8)
.NET 8 added an overload ConfigureAwait(ConfigureAwaitOptions) with composable flags beyond the old boolean:
ContinueOnCapturedContext— the equivalent of the oldtrue.SuppressThrowing— await aTaskpurely for completion, ignoring faults/cancellation (useful for “fire and observe completion only”).ForceYielding— always yield, even if the awaitable is already complete (handy to break up synchronous re-entrancy).
Example
public async Task<OrderDto?> LoadOrderAsync(
int id,
CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(
$"orders/{id}",
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OrderDto>(
cancellationToken: cancellationToken);
}The method does not hold a thread while waiting on network I/O. The continuation runs only when the response is available.
Async Streams (IAsyncEnumerable)
For data that arrives incrementally (paged APIs, streamed query results, message consumers), IAsyncEnumerable<T> lets a producer yield return values asynchronously and a consumer pull them with await foreach — each item can suspend without buffering the whole sequence in memory.
public async IAsyncEnumerable<Order> StreamOrdersAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
string? cursor = null;
do
{
var page = await _client.GetPageAsync(cursor, ct);
foreach (var order in page.Items)
yield return order;
cursor = page.NextCursor;
} while (cursor is not null);
}
// Consumer — pulls one item at a time, never materializing the full set
await foreach (var order in StreamOrdersAsync(ct).WithCancellation(ct).ConfigureAwait(false))
{
Process(order);
}Note the [EnumeratorCancellation] attribute (so a token passed via .WithCancellation(ct) reaches the iterator) and await using / IAsyncDisposable for resources whose cleanup is itself asynchronous (e.g. DbDataReader, network streams).
Pitfalls
Sync-over-async deadlock
Calling .Result or .Wait() on a task inside a method that runs under a SynchronizationContext (UI thread, legacy ASP.NET) causes a deadlock. The blocking call holds the context thread; the continuation needs that same thread to resume. Neither can proceed.
// DEADLOCK in UI or legacy ASP.NET — never do this
var result = GetDataAsync().Result;Fix: await all the way up, or use ConfigureAwait(false) in the async method so the continuation does not need the original context.
Fire-and-forget swallows exceptions
Calling an async method without awaiting it discards the returned Task. Any exception thrown inside is silently lost.
// Exception is lost — the task is never observed
_ = SendEmailAsync(user);Fix: either await the call, or explicitly handle the task’s exception via .ContinueWith or a background queue.
Async void
async void methods cannot be awaited and their exceptions cannot be caught by callers. They exist only for event handlers.
// Caller cannot catch this exception
public async void OnButtonClick(object sender, EventArgs e) { ... }Fix: use async Task everywhere except event handlers.
Unnecessary Task.Run wrapping
Wrapping already-async I/O in Task.Run wastes a thread pool thread for no benefit.
// Pointless — GetAsync is already async
var result = await Task.Run(() => _http.GetAsync(url));Fix: await the async method directly.
Only the first exception surfaces from an awaited task
A Task can hold multiple exceptions (e.g. a faulted Task.WhenAll), but await rethrows only the first one — it unwraps the AggregateException for ergonomics. To see all of them, inspect task.Exception (an AggregateException) directly after the await. Internally the runtime preserves the original throw-site stack with ExceptionDispatchInfo.Capture(ex).Throw(), which is why an awaited exception shows the real failure location rather than the resumption point.
Questions
How is asynchrony different from multithreading?
Asynchrony is about not blocking while waiting (especially for I/O). An
asyncmethod can release the current thread while awaiting, and continue later — potentially on the same thread. Multithreading is about executing work on multiple threads concurrently (for CPU-bound work). Async code can be single-threaded and still be asynchronous. The key cost difference: async I/O uses no thread while waiting; multithreading always occupies a thread.
What is the difference between
awaitandTask.Result?
awaitwaits asynchronously: it does not block the current thread, it unwraps exceptions asException(notAggregateException), and it respectsSynchronizationContext.Task.Resultwaits synchronously: it blocks the current thread, wraps exceptions inAggregateException, and can deadlock under aSynchronizationContext. Cost:.Resultin a context-aware environment is a deadlock waiting to happen;awaitis the safe default.
When should you use
ConfigureAwait(false)?In library code that does not need to resume on a specific context. It avoids context-capture overhead and eliminates the deadlock risk when library code is called from a context-aware environment. Do not use it in application-layer code (controllers, view models) where you need to resume on the original context to update UI or access
HttpContext.
If async does not always use extra threads, why does it improve scalability?
Because waiting time is no longer paid by tying up worker threads. Released threads can process other requests while I/O is pending. A server with 100 threads can handle thousands of concurrent I/O-bound requests if each thread is released during the wait.
When should you use
Task.Runwith async code?For CPU-bound work that you intentionally offload to a pool thread (e.g., image processing, heavy computation). Do not use it to wrap already-async I/O APIs — that wastes a thread for no benefit.
References
- Async programming scenarios (Microsoft Learn) — official overview of async/await patterns with examples for I/O and CPU-bound work.
- Await, UI, and deadlocks (Stephen Toub, Microsoft) — deep dive into how
SynchronizationContextcauses deadlocks and howConfigureAwait(false)prevents them. - ConfigureAwait FAQ (Stephen Toub, Microsoft) — exhaustive reference on when and why to use
ConfigureAwait(false). - There is no thread (Stephen Cleary) — explains why async I/O does not require a dedicated thread while waiting.
- Threading in C#: Task Parallelism (Joe Albahari) — comprehensive reference on
Task, continuations, and the async state machine. - Async/await best practices (Stephen Cleary) — canonical list of async pitfalls: async void, sync-over-async, fire-and-forget.