Asynchronous programming with async and await
Async is not about threads, it is about not blocking one. The failure modes are all about blocking, fire-and-forget and lost cancellation.
Task, async all the way, cancellation
public sealed class ExchangeRateClient
{
private readonly HttpClient _http;
public ExchangeRateClient(HttpClient http) => _http = http;
// Async all the way: no .Result, no .Wait(), no GetAwaiter().GetResult()
public async Task<decimal> GetRateAsync(string pair, CancellationToken ct)
{
using var response = await _http.GetAsync(
"rate/" + Uri.EscapeDataString(pair), ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content
.ReadFromJsonAsync<RatePayload>(ct)
.ConfigureAwait(false);
return payload?.Rate
?? throw new InvalidOperationException("no rate in response");
}
}
// Bounded concurrency instead of launching a thousand requests at once
public static async Task<IReadOnlyList<T>> MapBoundedAsync<TIn, T>(
IEnumerable<TIn> source, int degree, Func<TIn, CancellationToken, Task<T>> f,
CancellationToken ct)
{
using var gate = new SemaphoreSlim(degree);
var tasks = source.Select(async item =>
{
await gate.WaitAsync(ct);
try { return await f(item, ct); }
finally { gate.Release(); }
});
return await Task.WhenAll(tasks);
}async voidis only for event handlers. Anywhere else the exception cannot be observed and will crash the process.- Never block on an async call.
.Resultand.Wait()can deadlock when a synchronisation context is present, and they tie up a thread even when they do not deadlock. ConfigureAwait(false)in library code avoids needing the captured context to resume; application code that touches UI or request state may need the context.- Take a
CancellationTokenon every async method that does IO and pass it down. A token that is accepted and ignored is worse than none. - Return
ValueTaskonly when a method frequently completes synchronously and is on a hot path; otherwiseTaskis simpler and safer. Task.WhenAllfails with the first exception but the others still run to completion. Inspect every task if you need all the errors.
Patterns worth knowing
// Fire and forget, done properly: a queue, not a loose task
public sealed class AuditQueue
{
private readonly Channel<AuditEvent> _channel =
Channel.CreateBounded<AuditEvent>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.DropOldest
});
public ValueTask EnqueueAsync(AuditEvent e, CancellationToken ct) =>
_channel.Writer.WriteAsync(e, ct);
public IAsyncEnumerable<AuditEvent> ReadAllAsync(CancellationToken ct) =>
_channel.Reader.ReadAllAsync(ct);
}
// Timeouts that do not leak the operation
public static async Task<T> WithTimeout<T>(
Task<T> task, TimeSpan timeout, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
return await task.WaitAsync(cts.Token);
}
// Cancel work on a shared deadline
public static async Task RunAsync(CancellationToken outer)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(outer);
cts.CancelAfter(TimeSpan.FromSeconds(30));
await Task.WhenAll(
FetchAsync(cts.Token),
WarmAsync(cts.Token));
}| Pattern | Use it when | Avoid |
|---|---|---|
Task.WhenAll | Independent operations, fail-fast acceptable | Assuming every task's exception is observed |
Task.WhenAny | Racing two sources, or a manual timeout | Leaving the loser running without a cancellation token |
Channel | Producer and consumer inside one process | Unbounded channels under load |
Parallel.ForEachAsync | CPU or IO work with a fixed degree of parallelism | IO-bound work with no bound |
IAsyncEnumerable | Streaming results as they arrive | Materialising with ToList first |
⚠️
An unobserved task exception does not always crash the process immediately — it can surface much later as a finaliser-time failure with no link to the original work. If you start a task and cannot await it, make something own it: a channel, a hosted service or an explicit continuation that logs.
FAQ
Does async make code run in parallel?
No. It releases the thread while the IO completes. Parallelism comes from starting several operations and awaiting them together, or from Parallel.ForEachAsync.
Why do I need a cancellation token if the client disconnects?
Because the library on the other side has to be told. Without a token the query keeps running and keeps holding a connection long after nobody wants the answer.
Related
Dependency injection and the generic host Performance: allocation, Span and benchmarking
Last refreshed 2026-09-18.