Async and Await
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Asynchrony is about not blocking a thread while waiting. A web request, a database query and a file read all
spend almost all of their time waiting for something outside the process; a thread parked on that wait is a
thread that cannot serve anyone else. async and await let a method give its thread back while it waits and
pick up where it left off when the result arrives — written in the ordinary, sequential shape of the code that
blocked.
Asynchrony is not parallelism. await does not start a thread; it releases one. Running CPU-bound work on
another thread is a different job, covered in
Threads and Synchronization.
The Task-Based Asynchronous Pattern
The .NET convention, TAP, is simple: an asynchronous operation is a method that returns Task, Task<T> or
ValueTask<T>, is named with an Async suffix, and optionally accepts a CancellationToken. The returned
object represents an operation already in flight:
public sealed class PriceService(HttpClient http)
{
public async Task<decimal> GetPriceAsync(string symbol, CancellationToken cancellationToken = default)
{
using HttpResponseMessage response =
await http.GetAsync($"/prices/{symbol}", cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return decimal.Parse(body, System.Globalization.CultureInfo.InvariantCulture);
}
}
async is an implementation detail of a method, not part of its signature as far as callers are concerned:
what callers see is that it returns a Task<decimal>. An interface declares Task<decimal> GetPriceAsync(…);
whether the implementation uses async is up to it.
What await Actually Does
Given await expression, the compiler:
-
Evaluates the expression to an awaitable — anything with a
GetAwaiter()method whose result implementsINotifyCompletionand exposesIsCompletedandGetResult(). -
Checks
IsCompleted. If the operation has already finished, execution simply continues — no suspension, no allocation, no thread switch. -
Otherwise, it registers the rest of the method as a continuation, returns to the caller, and the thread is free.
-
When the operation completes, the continuation is scheduled, the method resumes at the
await, andGetResult()either yields the value or rethrows the captured exception.
The crucial point is step 3: the method returns to its caller at the first incomplete await, but it has not
finished. It has handed back a task that will complete later.
Task, Task<T> and ValueTask<T>
Task is a reference type representing an operation that will complete, fail or be cancelled. Task<T> adds
a result. Both are hot — by convention, a task returned from a method has already been started; you never
call Start() on one.
ValueTask<T> is a struct that wraps either an already-known result or a Task<T>. Its purpose is to avoid
allocating a task in the common case where the operation completes synchronously — a cached value, a buffered
read:
public sealed class CachingReader
{
private readonly Dictionary<string, string> _cache = new();
public ValueTask<string> ReadAsync(string key, CancellationToken cancellationToken = default)
{
if (_cache.TryGetValue(key, out string? cached))
{
return new ValueTask<string>(cached); // no allocation at all
}
return new ValueTask<string>(LoadAsync(key, cancellationToken));
}
private async Task<string> LoadAsync(string key, CancellationToken cancellationToken)
{
await Task.Delay(10, cancellationToken).ConfigureAwait(false);
string value = key.ToUpperInvariant();
_cache[key] = value;
return value;
}
}
ValueTask<T> comes with restrictions that Task<T> does not have. A ValueTask may be awaited only once,
must not be awaited concurrently, and .Result must not be read before it completes. If you need to await a
result twice, or hand it to several consumers, call .AsTask() first and use that. Use Task<T> by default;
reach for ValueTask<T> on hot paths that usually complete synchronously.
Async Return Types
| Return type | Use it for |
|---|---|
|
An asynchronous operation with no result. |
|
An asynchronous operation producing a |
|
Hot paths that usually complete synchronously; single-await only. |
|
Event handlers only. See |
|
An asynchronous stream of values — an |
A custom task-like type |
Advanced: a type marked with |
A method returning Task need not be async at all. When it only forwards, returning the inner task directly
avoids an extra state machine — at the cost of losing the try/catch and using scopes an async method
would give it:
public sealed class Forwarding(HttpClient http)
{
// No state machine: the inner task is returned as-is.
public Task<string> GetAsync(string url) => http.GetStringAsync(url);
}
The Compiler-Generated State Machine
An async method is rewritten into a struct implementing IAsyncStateMachine, with a field per local that
lives across an await and an int field tracking where to resume. The method body becomes a MoveNext()
switch over that state. Roughly:
// What you write
public sealed class Sketch
{
public async Task<int> SumAsync(Task<int> first, Task<int> second)
{
int a = await first;
int b = await second;
return a + b;
}
}
The compiler produces (conceptually) a state machine whose MoveNext runs to the first await, stores a
into a field, registers a continuation, and returns; when resumed it jumps to state 1, reads a back out of
the field, and proceeds. The builder (AsyncTaskMethodBuilder<int>) owns the Task<int> handed to the caller
and completes it with the result or the exception.
Two consequences worth internalising:
-
The state machine is a struct. As long as the method never actually suspends, it stays on the stack and the method allocates nothing. Only the first real suspension boxes it onto the heap.
-
Awaits are sequential.
await first; await second;waits for the first before starting to wait for the second. If both were started before the awaits, they overlap; ifsecondis itself produced by a call made after the firstawait, they cannot.
public sealed class Sequencing(HttpClient http)
{
// Sequential: the second request starts only after the first finishes.
public async Task<int> SequentialAsync()
{
string a = await http.GetStringAsync("/a").ConfigureAwait(false);
string b = await http.GetStringAsync("/b").ConfigureAwait(false);
return a.Length + b.Length;
}
// Concurrent: both requests are in flight before either is awaited.
public async Task<int> ConcurrentAsync()
{
Task<string> a = http.GetStringAsync("/a");
Task<string> b = http.GetStringAsync("/b");
string[] results = await Task.WhenAll(a, b).ConfigureAwait(false);
return results[0].Length + results[1].Length;
}
}
Synchronization Contexts and ConfigureAwait
By default, await captures the current SynchronizationContext (or TaskScheduler) and resumes the
continuation on it. In a UI application that means resuming on the UI thread, which is exactly what you want
when the code after the await touches controls. In a console application or ASP.NET Core there is no
synchronization context, and continuations run on the thread pool.
ConfigureAwait(false) says "I do not care where I resume":
public static class ContextControl
{
public static async Task<int> LibraryMethodAsync(Stream stream)
{
// Library code: never needs the caller's context, and capturing it
// costs a context switch -- and risks a deadlock in some hosts.
byte[] buffer = new byte[1024];
return await stream.ReadAsync(buffer).ConfigureAwait(false);
}
}
The guidance:
-
Library code: use
ConfigureAwait(false)on everyawait. It is faster and it is what makes a library safe to call from a UI application. -
Application code with a UI: omit it where you need to resume on the UI thread.
-
ASP.NET Core: there is no synchronization context, so it makes no behavioural difference; it is still harmless and some teams apply it uniformly.
ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing) and ForceYielding for finer control:public static class ConfigureAwaitOptionsUsage
{
public static async Task IgnoreFailureAsync(Task work)
{
// Await completion without observing an exception or cancellation.
await work.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
public static async Task AlwaysYieldAsync(Task work)
{
// Force an asynchronous resumption even if `work` is already complete.
await work.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
}
}
Composing Tasks
public sealed class Composition(HttpClient http)
{
// All of them, in parallel; fails if any fails, after all have completed.
public async Task<string[]> AllAsync(IEnumerable<string> urls)
{
IEnumerable<Task<string>> requests = urls.Select(u => http.GetStringAsync(u));
return await Task.WhenAll(requests).ConfigureAwait(false);
}
// The first to finish wins; the rest keep running unless cancelled.
public async Task<string> FastestAsync(string primary, string mirror)
{
Task<string> a = http.GetStringAsync(primary);
Task<string> b = http.GetStringAsync(mirror);
Task<string> winner = await Task.WhenAny(a, b).ConfigureAwait(false);
return await winner.ConfigureAwait(false); // observe its result or exception
}
// .NET 9+: consume results in completion order, without a WhenAny loop.
public async Task ProcessAsTheyFinishAsync(IEnumerable<string> urls)
{
List<Task<string>> requests = urls.Select(u => http.GetStringAsync(u)).ToList();
await foreach (Task<string> finished in Task.WhenEach(requests).ConfigureAwait(false))
{
Console.WriteLine((await finished.ConfigureAwait(false)).Length);
}
}
}
Notes on Task.WhenAll:
-
It waits for every task, even after one has failed.
-
If several tasks fail, the returned task carries an
AggregateExceptionholding all of them — butawaitrethrows only the first. Inspecttask.Exceptionwhen you need them all. -
Task.WhenAlloverTask<T>returnsTask<T[]>in the same order as the input, regardless of completion order.
Task.WhenAny returns the task, not its result, so it never throws for a faulted winner — you must await
the winner to observe it. Also remember that losing tasks keep running; pair WhenAny with cancellation, or
with Task.WaitAsync below, when the losers matter.
Cancellation
Cancellation in .NET is cooperative. A CancellationTokenSource owns the trigger; the CancellationToken it
produces is passed down the call chain, and each operation decides how to respond:
public sealed class Cancellable(HttpClient http)
{
public async Task<string> RunAsync(string url, CancellationToken cancellationToken)
{
// Pass the token to every call that accepts one.
string body = await http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
foreach (string line in body.Split('\n'))
{
// In a loop of your own work, check the token explicitly.
cancellationToken.ThrowIfCancellationRequested();
Process(line);
}
return body;
}
private static void Process(string line) => _ = line.Length;
}
public static class CancellationDrivers
{
public static async Task WithDeadlineAsync(Cancellable service, string url)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
await service.RunAsync(url, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
Console.WriteLine("timed out after five seconds");
}
}
public static async Task WithLinkedTokenAsync(Cancellable service, string url, CancellationToken outer)
{
// Cancel when EITHER the caller cancels or the local deadline elapses.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(outer);
cts.CancelAfter(TimeSpan.FromSeconds(2));
await service.RunAsync(url, cts.Token).ConfigureAwait(false);
}
}
Cancellation surfaces as OperationCanceledException (or its subclass TaskCanceledException), which is
expected, not a failure: a cancelled task ends in the Canceled state, not Faulted.
Task.WaitAsync (.NET 6+) applies a timeout or a token to any task, including one whose API takes neither:
public static class Timeouts
{
public static async Task<string> WithTimeoutAsync(Task<string> work, CancellationToken cancellationToken)
{
try
{
return await work.WaitAsync(TimeSpan.FromSeconds(3), cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
return "(timed out)";
}
}
}
Note the caveat: WaitAsync stops waiting; it cannot stop the underlying operation, which keeps running.
Only a token threaded into that operation can actually cancel it.
Exceptions in Async Code
An exception thrown inside an async method is captured and placed on the returned task; it is rethrown, with
its original stack trace preserved, when the task is awaited:
public static class AsyncExceptions
{
public static async Task<int> FailsAsync()
{
await Task.Yield();
throw new InvalidOperationException("boom");
}
public static async Task HandleAsync()
{
try
{
await FailsAsync().ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
// Ordinary try/catch works across awaits.
Console.WriteLine(ex.Message);
}
}
public static async Task HandleManyAsync(Task[] work)
{
Task all = Task.WhenAll(work);
try
{
await all.ConfigureAwait(false);
}
catch (Exception)
{
// `await` rethrew only the first failure; all of them are here.
foreach (Exception inner in all.Exception!.InnerExceptions)
{
Console.WriteLine(inner.Message);
}
}
}
}
A task that fails and is never awaited is an unobserved exception. In modern .NET it does not crash the
process, but the TaskScheduler.UnobservedTaskException event fires when the task is finalized. Awaiting every
task you create — or deliberately observing it — is the fix. See
Exceptions and Error Handling.
Async Streams
An async iterator returns IAsyncEnumerable<T> and combines yield return with await. It is the right
shape for paged APIs, streaming responses and anything producing values over time:
public sealed class Paging(HttpClient http)
{
public async IAsyncEnumerable<string> ReadPagesAsync(
string url,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
for (int page = 1; page <= 3; page++)
{
string body = await http.GetStringAsync($"{url}?page={page}", cancellationToken)
.ConfigureAwait(false);
foreach (string line in body.Split('\n'))
{
yield return line;
}
}
}
}
public static class AsyncStreamConsumer
{
public static async Task ConsumeAsync(Paging paging, CancellationToken cancellationToken)
{
await foreach (string line in paging.ReadPagesAsync("/log")
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
Console.WriteLine(line);
}
}
}
[EnumeratorCancellation] is what lets a token supplied at enumeration time (via WithCancellation) reach
the iterator’s own parameter. Without it, the token passed to WithCancellation would be ignored by the method
body.
await foreach also drives IAsyncDisposable on the enumerator, so cleanup in a finally inside the iterator
runs asynchronously and correctly.
async Main
An entry point may be asynchronous. The compiler generates a synchronous Main that waits on it:
public static class EntryPoint
{
public static async Task<int> MainAsync(string[] args)
{
await Task.Delay(10).ConfigureAwait(false);
return args.Length;
}
}
Written as a top-level program, await may simply be used at file scope. Permitted entry-point signatures are
Task Main(), Task Main(string[]), Task<int> Main() and Task<int> Main(string[]).
Pitfalls
async void
An async void method returns nothing that can be awaited, so the caller cannot know when it finished and
cannot catch its exceptions — an exception escapes onto the synchronization context and typically crashes the
process. The only justified use is an event handler, whose signature is fixed:
public sealed class Handler
{
// Acceptable: an event handler signature leaves no choice.
public async void OnClicked(object? sender, EventArgs e)
{
try
{
await DoWorkAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
// An async void method MUST catch its own exceptions.
Console.WriteLine(ex.Message);
}
}
private static Task DoWorkAsync() => Task.CompletedTask;
}
Everywhere else, return Task.
Sync-Over-Async
Calling .Result, .Wait() or GetAwaiter().GetResult() on an incomplete task blocks the calling thread. In a
context that has a synchronization context (a UI thread, or classic ASP.NET), the continuation needs that same
thread to resume — and the thread is blocked waiting for it. That is a deadlock:
public sealed class Deadlocking(HttpClient http)
{
// Do not do this.
public string BlockingCall() => http.GetStringAsync("/data").Result;
// Do this: async all the way up.
public Task<string> NonBlockingCall() => http.GetStringAsync("/data");
}
Even without a synchronization context, blocking burns a thread-pool thread and, under load, causes thread-pool starvation. The rule is "async all the way": if a method awaits, its callers await, up to the entry point or the framework.
Forgotten await
Calling an async method without awaiting it compiles (warning CS4014) and starts the operation, but the
caller continues immediately — ordering is lost and failures are unobserved. Where this is genuinely
intentional, say so explicitly:
public static class FireAndForget
{
public static void Start(Func<Task> work)
{
// Deliberate, and the failure is still observed.
_ = Task.Run(async () =>
{
try
{
await work().ConfigureAwait(false);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
});
}
}
Other Traps
-
asyncover CPU-bound work.asyncdoes not make code parallel. Wrap genuinely CPU-bound work inTask.Runat the call site, not inside the library method. -
Task.DelayversusThread.Sleep.Thread.Sleepblocks; in async code alwaysawait Task.Delay. -
Capturing a
Span<T>across anawait. Not allowed —ref structtypes cannot live in a state machine’s fields. UseMemory<T>. -
Awaiting inside a
lock. Not allowed either. UseSemaphoreSlim.WaitAsyncfor asynchronous mutual exclusion — see Threads and Synchronization. -
ValueTaskawaited twice. Undefined behaviour. CallAsTask()if it must be reused.
Practical Guidance
-
Return
Task/Task<T>, name the method…Async, and accept aCancellationToken. -
Await everything, all the way to the entry point; never block on a task.
-
Use
ConfigureAwait(false)in libraries. -
Start concurrent work before awaiting it, and compose with
Task.WhenAll. -
Pass the cancellation token down; do not invent a new one halfway.
-
Reserve
async voidfor event handlers, and make them catch everything. -
Reach for
ValueTask<T>only when a hot path measurably needs it.
See Also
-
Threads and Synchronization — parallelism, locks, channels and the thread pool.
-
Exceptions and Error Handling —
AggregateExceptionand failure design. -
Memory Management and Disposal —
IAsyncDisposableandawait using. -
Collections and Iterators — the synchronous
yield returniterators that async streams extend. -
ASP.NET Reference — asynchronous request handling in web applications.