Async
How async/await and Task work: how asynchrony differs from parallelism, where code runs after await, why .Result hangs, and how to cancel operations and limit how many run at once. No .NET developer interview gets by without this topic.
Before this topic: Types and memory, Multithreading.
How is asynchrony different from multithreading and parallelism?
Section titled “How is asynchrony different from multithreading and parallelism?”What it is
- Asynchrony — the thread does not sit idle while an operation waits for an external resource.
awaitreturns control, and the method resumes when the result is ready. - Multithreading — code runs on several threads.
- Parallelism — several threads really run at the same time on different cores.
Why
Asynchrony keeps the UI responsive and saves server threads. Parallelism makes computation faster.
How to use
| Task | Tool |
|---|---|
| Waiting for network, disk, database (I/O-bound) | async/await without Task.Run |
| Heavy calculation in a UI app | await Task.Run(...) |
| Need to compute faster | Parallel.For, PLINQ |
Common mistake
Thinking that async creates a thread by itself. An async method runs synchronously until the first await of an incomplete task, so a heavy loop inside it blocks the calling thread.
Takeaway: waiting — await, computing — parallelize.
How it works
- An asynchronous API hands the I/O operation to the OS and returns an incomplete
Task. - The method exits at
await, and the thread returns to the pool to pick up other work. - The OS signals completion, and the rest of the method is queued to the thread pool or the UI thread.
CPU-bound work needs the processor. You cannot “compute asynchronously” — you can only move the computation to another thread.
When to choose what
| Situation | Solution |
|---|---|
| Several independent I/O calls | start them all and await Task.WhenAll |
| Heavy calculation in a UI app | await Task.Run(...) |
| Large amount of computation | Parallel.For, Parallel.ForEach, PLINQ |
Pitfalls
- A synchronous call inside an async method (
Thread.Sleep, a synchronousRead) blocks the thread just as it would without async. - A method marked
asyncbut withoutawaitruns fully synchronously — the compiler issues warning CS1998. - For small CPU work,
Task.Runand thread switching can cost more than the work itself.
Rule of thumb
I/O — async/await all the way through the call chain. CPU — an explicit move to another thread, and only if a measurement shows a gain.
Takeaway: asynchrony frees a thread while waiting, parallelism uses more threads for speed.
Under the hood
- A synchronous server holds a thread for every waiting request. A thread means memory for its stack and context switches.
- An asynchronous server keeps only a small object with the method’s state on the heap while waiting.
- Network operations complete via IOCP (Windows) or epoll/kqueue (Linux/macOS), and then the work goes to the pool.
- Async code on a UI thread or in Blazor is concurrent but not parallel: everything runs in turn on one logical thread.
Cost
- Async does not speed up a single request and even adds overhead for saving state and scheduling.
- The gain is throughput: more concurrent requests on the same number of threads.
Trade-offs
await Task.Run(...)in an ASP.NET Core handler gains nothing: the request is already on the pool, and you pay for an extra hop.- Heavy computation is moved to background processing (
Channel<T>andBackgroundService) with limited parallelism, so it does not take threads away from requests.
How to check in production
The thread pool keeps growing while the CPU is underused — so threads are not working but blocked waiting. In dotnet-counters this shows up in the dotnet.thread_pool.thread.count metric (.NET 9+).
Takeaway: async scales waiting, parallelism scales computation.
// I/O-bound: the thread is free while the request is in flightasync Task<int> GetPageLengthAsync(HttpClient http, string url){ string html = await http.GetStringAsync(url); return html.Length;}
// CPU-bound in a UI app: move the calculation off the UI threadasync Task ShowResultAsync(int n){ long result = await Task.Run(() => SlowFibonacci(n)); resultLabel.Text = result.ToString(); // back on the UI thread}
// CPU-bound and needs to be faster: split the work across coresvar squares = new long[1_000];Parallel.For(0, squares.Length, i => squares[i] = (long)i * i);Console.WriteLine(squares[999]); // 998001- Does
asynccreate a new thread? — No: the method runs synchronously until the firstawaitof an incomplete task. - Why use
Task.Runin an ASP.NET Core controller action? — Usually you shouldn’t: the request is already on the pool, it is an extra hop. - How does
Task.Rundiffer fromTask.Factory.StartNew? —Task.Rununwraps an async delegate and always uses the pool;StartNewreturnsTask<Task>and uses the current scheduler by default. - Will async speed up a single request? — No, it increases throughput rather than reducing latency.
Why is async void dangerous, and where is it still acceptable?
Section titled “Why is async void dangerous, and where is it still acceptable?”What it is
async void is an asynchronous method that returns void instead of Task. The caller gets control back after the first await inside the method and knows nothing more about it.
Why
An event handler’s signature is defined by the event’s delegate, and it must return void. To let a handler use await, the language allows async void.
How to use
- An asynchronous method returns
TaskorTask<T>. async void— only for event handlers such asButton_Click.- Move the logic into an
async Taskmethod and keep onlyawaitandtry/catchin the handler.
Common mistake
Wrapping a call to an async void method in try/catch and expecting to catch its exception. It is thrown later and somewhere else.
Takeaway: async Task — always, async void — only for event handlers.
How it works
async Task |
async void |
|
|---|---|---|
| Can be awaited | yes, with await |
no |
| Where the exception goes | into the task, thrown at await |
to the SynchronizationContext or the thread pool |
Task.WhenAll, tests |
work | impossible |
Without a context (console, ASP.NET Core, worker service), an exception on the thread pool terminates the process. In WinForms and WPF it lands in Application.ThreadException and Application.DispatcherUnhandledException.
When to choose what
- An event handler or
ICommand.Execute—async voidwith atry/catchinside. - Everything else —
async Task.
Pitfalls
- Hidden
async void. An async lambda passed as anActionparameter becomesasync void. Solist.ForEach(async x => await SendAsync(x))does not wait for the sends. - Implementing a
voidmethod. An async implementation of an ordinaryvoidmethod on an interface or base class is almost always a bug: the caller assumes the work is done. - Resources. The caller may dispose a resource before the
async voidmethod has finished using it.
Rule of thumb
Pass async lambdas only to parameters of type Func<Task>.
Takeaway: async void breaks error handling and composition, so it belongs only in event handlers.
Under the hood
- When created,
AsyncVoidMethodBuildercallsSynchronizationContext.Current?.OperationStarted(). - When the method completes, it calls
OperationCompleted(). This lets a context, such as a test framework’s, wait for allasync voidcalls. - The builder posts an unhandled exception via
Postto the captured context, and without a context throws it on the thread pool.
Cost
- An exception on a thread pool thread terminates the process:
AppDomain.UnhandledExceptiononly gets a chance to log it. - For ASP.NET Core that means an app restart and every in-flight request dropped.
TaskScheduler.UnobservedTaskExceptionwill not help: it is about unobserved task exceptions, and here there is no task.
Trade-offs
_ = DoAsync();will not crash the process, but the exception stays in the task and is lost.- A discarded task in a web app outlives the request: scoped dependencies like
DbContextare already disposed, and nobody waits for it on shutdown. - Reliable fire-and-forget is a background queue:
Channel<T>andBackgroundServicewith their own scope,try/catch, logging and graceful shutdown.
How to check in production
In code review, look for async void outside event handlers and async lambdas passed as Action parameters: List<T>.ForEach, Parallel.ForEach, timer callbacks. Catch sudden process crashes by logging in AppDomain.UnhandledException.
Takeaway: wherever there is no UI context, any exception in async void means a process crash.
// Bad: can't wait for completion, can't catch the exceptionasync void SaveBad() => await File.WriteAllTextAsync("data.txt", "...");
// Good: the logic lives in async Taskasync Task SaveAsync() => await File.WriteAllTextAsync("data.txt", "...");
// An event handler is the only legitimate place for async voidprivate async void SaveButton_Click(object sender, EventArgs e){ try { await SaveAsync(); } catch (Exception ex) { MessageBox.Show(ex.Message); // otherwise the exception goes to the UI context }}
// Hidden async void: ForEach takes an Action<T>items.ForEach(async x => await SendAsync(x)); // nobody waits for the sendsawait Task.WhenAll(items.Select(SendAsync)); // correct- What happens to an exception in
async voidin ASP.NET Core? — There is no context, so it goes to the thread pool and kills the process. - Why is
list.ForEach(async x => ...)a bug? — The lambda is converted toAction<T>, which makes itasync void. - How does
_ = DoAsync()differ fromasync void? — The exception stays in the task and does not crash the process, but it is lost unless observed. - How do you do fire-and-forget safely? — Your own
try/catchwith logging, or a background queue (Channel<T>andBackgroundService).
How does async/await work under the hood?
Section titled “How does async/await work under the hood?”What it is
The compiler rewrites an async method into a state machine: the number of the current step plus the local variables.
Step by step
- Code before the first
awaitruns immediately and synchronously on the calling thread. - At
awaitit checks whether the task is complete. If it is, execution simply continues. - If not, the method records the step and returns an incomplete
Taskto the caller. - When the task completes, the method continues from where it stopped.
returncompletes theTaskwith a result, and an exception is stored in it and thrown atawait.
Common mistake
Assuming code after await runs on the same thread. In a UI app it does, but in a console app or ASP.NET Core it can run on any pool thread.
Takeaway: await does not block the thread — it exits the method and comes back when the result is ready.
How it works
- The compiler generates a stub method and a state machine type. Locals that live across
awaitbecome its fields. - The stub calls
builder.Start, which runsMoveNextsynchronously, and then returnsbuilder.Task. MoveNextis the original code split into states and wrapped intry/catch. An exception goes tobuilder.SetException, the result tobuilder.SetResult.await xcallsx.GetAwaiter()and checksIsCompleted.- If the task is not ready, the builder hooks
MoveNextup to its completion, and the method exits. - After resuming,
GetResult()returns the result or rethrows the original exception with its stack trace preserved.
Where the code resumes
What exists at the await |
Where the continuation runs |
|---|---|
SynchronizationContext (UI, classic ASP.NET) |
in that context |
a non-default TaskScheduler |
on that scheduler |
| nothing (console, ASP.NET Core) | on the thread pool |
Pitfalls
- After
awaitin ASP.NET Core the thread can change, so[ThreadStatic]fields are unreliable between steps of a method. awaitinsidelockis forbidden (error CS1996): the continuation may run on another thread, while aMonitoris tied to its owning thread.awaitworks with any type that has a suitableGetAwaiter()— even an extension method.
Rule of thumb
Do not rely on a specific thread after await: pass state through parameters and local variables.
Takeaway: await is a check of IsCompleted, hooking MoveNext up to completion, and exiting the method.
Under the hood
- In Release the state machine is a struct; in Debug it is a class, for easier debugging.
- While every
awaitcompletes synchronously, the struct never leaves the stack. If the result is also cached (Task.CompletedTask,Task<bool>, some smallintvalues), the method allocates nothing. - On the first real suspension the struct is copied into an
AsyncStateMachineBox<TStateMachine>. That type derives fromTask<TResult>, so the box is the returned task. builder.Startsaves and restores theExecutionContext, so changes toAsyncLocal<T>inside the method do not leak to the caller.
Cost
- One box allocation on suspension, context capture and an indirect call to
MoveNext. - A continuation can run synchronously inside
SetResult, on the thread that completes the task. ForTaskCompletionSourcethat means surprising reentrancy, so it is created withTaskCreationOptions.RunContinuationsAsynchronously.
Trade-offs
- A synchronous fast path (check the cache before
await) removes allocations but complicates the code. - .NET 11 introduces runtime async — still a preview feature, enabled with the
<Features>runtime-async=on</Features>property. The runtime manages suspension instead of a generated state machine: live stack traces are cleaner and overhead is lower.
How to check in production
- Allocations in async code show up in BenchmarkDotNet with
[MemoryDiagnoser]. - The SOS command
dumpasync(dotnet-dump analyze) shows pending async operations in a dump.
Takeaway: the state machine moves to the heap only on a real suspension; the cost of async is that allocation plus scheduling the continuation.
// The original methodasync Task<int> GetLengthAsync(HttpClient http, string url){ string s = await http.GetStringAsync(url); return s.Length;}
// Simplified MoveNext of the generated struct (Release)void MoveNext(){ try { if (_state == 0) goto Resume; _awaiter = _http.GetStringAsync(_url).GetAwaiter(); if (!_awaiter.IsCompleted) { _state = 0; _builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this); return; // the method exits, the thread is free } Resume: string s = _awaiter.GetResult(); // result or exception _builder.SetResult(s.Length); // completes the Task<int> } catch (Exception ex) { _builder.SetException(ex); }}- Why can an async method’s locals end up on the heap? — On suspension the state machine, along with them, is copied to the heap.
- Can you
awaityour own type? — Yes, you need aGetAwaiter()returning an awaiter withIsCompleted,OnCompletedandGetResult. - What does
await Task.Yield()do? — Forces the method to suspend and queues the continuation, even when there is nothing to wait for. - Why can’t you
awaitinsidelock? — The continuation may run on another thread, while aMonitoris tied to its owning thread (CS1996). - How does the state machine differ between Debug and Release? — In Debug it is a class; in Release it is a struct that moves to the heap only on suspension.
What is SynchronizationContext, and why use ConfigureAwait(false)?
Section titled “What is SynchronizationContext, and why use ConfigureAwait(false)?”What it is
SynchronizationContext knows how to send work to the right thread. In WinForms and WPF you can change UI elements only from the UI thread, and the context sends code exactly there.
Why
Thanks to it, you can update the UI again after await in a button click handler: the code is back on the UI thread.
How to use
- By default
awaitremembers the context and resumes the method in it. await task.ConfigureAwait(false)means “I don’t need the context”: code afterawaitruns on the thread pool.- Console apps and ASP.NET Core have no context:
SynchronizationContext.Currentisnull.
Common mistake
Adding ConfigureAwait(false) in a button click handler and then touching a control. You get an exception about access from another thread.
Takeaway: the context brings code back to the UI thread, and ConfigureAwait(false) opts out of it.
How it works
| Environment | Where code returns after await |
|---|---|
| WinForms, WPF | the UI thread’s message queue |
| Classic ASP.NET | the request context, where HttpContext.Current is available |
| Blazor | its own context, one logical thread |
| ASP.NET Core, console, worker | no context — the thread pool |
The implementations are WindowsFormsSynchronizationContext, DispatcherSynchronizationContext and AspNetSynchronizationContext.
When to choose what
- App code (UI handlers, controllers) — no
ConfigureAwait(false): the UI needs the context, and ASP.NET Core has none. - General-purpose library —
ConfigureAwait(false)on everyawait: it may be called from a UI app. Analyzer CA2007 helps you remember.
Pitfalls
- The first
awaitalone is not enough. If the first task completes synchronously, the context is not dropped, and the nextawaitcaptures it again. - Each method has its own context.
ConfigureAwait(false)inside a library does not affect the calling handler — it still returns to the UI thread.
Rule of thumb
In app code you don’t write ConfigureAwait(false); in a library you write it on every await.
Takeaway: ConfigureAwait(false) is a tool for libraries, not for app code.
Under the hood
- The
Taskawaiter capturesSynchronizationContext.Currentif it is not the base class. Otherwise,TaskScheduler.Currentif it is non-default. Otherwise the continuation goes to the pool. ConfigureAwait(false)skips both captures. But if the task is already complete, the code continues synchronously on the current thread and in the current context.ExecutionContextflows through anyawait:AsyncLocal<T>andActivity.Currentdo not depend onConfigureAwait.
Cost
- Every continuation in a UI context goes through the message queue. Hundreds of small
awaits on the UI thread noticeably slow the UI down. - In ASP.NET Core continuations go straight to the pool, with no extra hops. But there is no single-thread guarantee either:
HttpContextis not thread-safe.
Trade-offs
| Way to leave the context | Limitation |
|---|---|
ConfigureAwait(false) |
needed on every await, including in dependencies |
Task.Run around the call |
an extra task and a hop to the pool |
SetSynchronizationContext(null) |
does not reset a non-default TaskScheduler |
ConfigureAwaitOptions (.NET 8+): SuppressThrowing works only with the non-generic Task, and for Task<T> the analyzer reports CA2261. ForceYielding sends even a completed task to the pool.
How to check in production
A frozen UI is diagnosed with a dump: the UI thread sits in Wait or GetResult, and the continuation never runs. Look at the stacks in Visual Studio (Parallel Stacks) or dotnet-dump analyze.
Takeaway: ConfigureAwait(false) removes the hop into the context, but it does not guarantee leaving it and does not touch ExecutionContext.
// WinForms/WPF: after await we are back on the UI threadprivate async void LoadButton_Click(object sender, EventArgs e){ string data = await _service.LoadAsync(); // context captured resultBox.Text = data; // safe to touch the UI}
// Library: the caller's context is not neededpublic async Task<string> LoadAsync(){ using var response = await _http.GetAsync(_url).ConfigureAwait(false); // we are on the thread pool here, don't touch the UI return await response.Content.ReadAsStringAsync().ConfigureAwait(false);}
// .NET 8+: wait for a task without rethrowing its exceptionawait backgroundTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);- Do you need
ConfigureAwait(false)in ASP.NET Core? — Not in app code, there is no context; yes in general-purpose libraries. - Why put it on every
awaitand not just the first? — The first task may complete synchronously, and the context is not dropped. - Does
ConfigureAwait(false)affectAsyncLocal<T>? — No,ExecutionContextalways flows throughawait. - How do you get back to the UI thread from background code? —
Control.BeginInvoke,Dispatcher.InvokeAsync, orPoston a saved context. - Does Blazor have a context? — Yes, it emulates one logical thread; outside code enters it via
InvokeAsync.
Why do .Result and .Wait() cause deadlocks, and how do you avoid them?
Section titled “Why do .Result and .Wait() cause deadlocks, and how do you avoid them?”What it is
A deadlock happens when the UI thread synchronously waits for a task, and the task needs the UI thread to finish.
How it happens
- A button click handler calls
LoadAsync().Result— the UI thread is blocked. LoadAsynccontains anawait. When the operation completes, the continuation has to run on the UI thread.- The UI thread is busy waiting, the continuation never starts, and the task never completes.
How to avoid it
| Instead of | Use |
|---|---|
.Result, .Wait() |
await |
Task.WaitAll |
await Task.WhenAll |
Thread.Sleep |
await Task.Delay |
Make the whole chain asynchronous up to the entry point: an event handler, a controller action, async Task Main.
Common mistake
Testing the code in a console app, where there is no context and everything works, then moving it to a UI app, where it hangs.
Takeaway: don’t block on tasks — await all the way to the top.
How it works
A deadlock requires three conditions at once:
- A single-threaded context: WinForms, WPF, classic ASP.NET, Blazor.
- A blocking wait in that context:
.Result,.Wait(),GetAwaiter().GetResult(). - An
awaitwithoutConfigureAwait(false)somewhere down the chain.
Remove any one condition and there is no deadlock.
When to choose what
| Approach | What you get |
|---|---|
await all the way |
solves the problem completely |
GetAwaiter().GetResult() |
the original exception instead of AggregateException, but a deadlock is still possible |
Task.Run(() => DoAsync()).GetAwaiter().GetResult() |
no context, so no deadlock, but the thread is blocked |
Pitfalls
ConfigureAwait(false)in your library does not help if even one dependency awaits without it.- Constructors and synchronous interfaces push you towards
.Result. Asynchronous initialization belongs in a factory or anIHostedService.
Rule of thumb
Async all the way: async Task Main (since C# 7.1), async Task in tests. .Result is fine only on a task that has already completed, for example after await Task.WhenAll.
Takeaway: a deadlock is a single-threaded context, a block inside it, and an await that wants to get back in.
Under the hood
- In ASP.NET Core a request calling
.Resultholds a pool thread. - The continuation that would complete the task also waits for a free pool thread.
- The pool quickly ramps up to a few threads per core, then adds roughly 1–2 per second.
- Under load, latency climbs to seconds even though the CPU is nearly idle.
Since .NET 6 the pool reacts faster to blocking in Task.Wait and .Result, but that does not remove the problem.
Cost
- Every extra thread costs memory for its stack and context switches.
ThreadPool.SetMinThreadsmasks the problem: it comes back at the next load spike.
Trade-offs
- A synchronous wrapper over an async method is debt, not a solution. If you need a synchronous API, implement it as genuinely synchronous.
- Sync-over-async hides in synchronous methods on top of async code:
Dispose, DI factories, validators, third-party SDKs.
How to check in production
dotnet-counters monitor:dotnet.thread_pool.thread.countgrows slowly,dotnet.thread_pool.queue.lengthis large, and CPU stays well below 100%. In older versions these are thethreadpool-thread-countandthreadpool-queue-lengthcounters.dotnet-stack report: many pool threads sit inTask.WaitorGetResultCore.- For rare episodes —
dotnet-trace collect --clrevents waithandle(.NET 9+) and stack analysis in PerfView.
Takeaway: in ASP.NET Core sync-over-async doesn’t hang right away — it slowly strangles the service through thread pool starvation.
// WinForms: deadlockprivate void LoadButton_Click(object sender, EventArgs e){ string data = LoadAsync().Result; // UI thread blocked resultBox.Text = data; // execution never gets here}
private async Task<string> LoadAsync(){ await Task.Delay(1000); // the continuation waits for a free UI thread return "done";}
// Fix: async all the wayprivate async void LoadButtonFixed_Click(object sender, EventArgs e){ resultBox.Text = await LoadAsync(); // "done"}- Why doesn’t the same code hang in a console app? — There is no
SynchronizationContext, so the continuation goes to the pool. - Why is
GetAwaiter().GetResult()better than.Result? — It rethrows the original exception rather thanAggregateException; it does not prevent deadlocks. - Can you get a deadlock in ASP.NET Core? — Not the classic one, but sync-over-async leads to thread pool starvation.
- How do you find sync-over-async in production? —
dotnet-countersfor the thread pool, thendotnet-stackordotnet-tracewithWaitHandleWaitevents. - Is
.Resultsafe afterawait Task.WhenAll? — Yes, the task has already completed, so nothing blocks.
How do you cancel asynchronous operations correctly?
Section titled “How do you cancel asynchronous operations correctly?”What it is
CancellationTokenSourceis the “remote control”: it hasCancel()andCancelAfter(...).CancellationTokenis the “receiver”: it hasIsCancellationRequestedandThrowIfCancellationRequested().
Why
To stop work nobody needs anymore: the user closed the window, the client dropped the request, a timeout expired.
How to use
- Create the source with
using. - Pass
cts.Tokento the method as the last parameter. - Inside, pass the token to every async call:
ReadAsync(buffer, ct),SaveChangesAsync(ct). - Catch
OperationCanceledExceptionseparately from other errors.
Common mistake
Accepting a token and never passing it on. Then cancellation cancels nothing.
Takeaway: pass the token through the whole chain, or the cancellation never reaches the operation.
How it works
Cancel()moves every copy of the token into the “canceled” state, and this cannot be undone.- A canceled task has the
Canceledstatus, andawaiton it throwsOperationCanceledException. ct.Register(...)registers a callback for APIs that don’t support tokens.Cancel()runs callbacks synchronously, so they must be fast.
When to choose what
| Task | Tool |
|---|---|
| Timeout | new CancellationTokenSource(TimeSpan) or CancelAfter |
| External cancellation plus a timeout | CreateLinkedTokenSource(ct) and CancelAfter |
| Stop waiting for someone else’s task | task.WaitAsync(timeout, ct) (.NET 6+) |
| Long CPU loop | ct.ThrowIfCancellationRequested() every N iterations |
Pitfalls
WaitAsyncstops waiting but does not stop the operation itself.- A linked source is registered on the parent token. Without
Dispose, that registration lives as long as the parent — a leak if the parent is long-lived. - Different APIs throw either
TaskCanceledExceptionorOperationCanceledException. Catch the base type.
Rule of thumb
A public async method takes CancellationToken cancellationToken = default as its last parameter and passes it on.
Takeaway: a timeout is cancellation too, and a linked source combines external cancellation with your own timeout.
Under the hood
- The async method builder moves the task to
Canceledon anyOperationCanceledException, regardless of the token. Task.Run(work, ct)ends upCanceledonly if the exception’s token matchesctand is canceled. Otherwise the task isFaulted.- For
Task.WhenAll, one task beingFaultedoutweighs another beingCanceled.
Cost
CancellationTokenis a struct, anddefaultcosts nothing.CancelAftercreates a timer, andRegisteradds an entry to the callback list. In frequently called code a non-canceled source is reused viaTryReset()(.NET 6+).CancelAsync()(.NET 8+) runs callbacks asynchronously and does not hold up the calling thread.
Trade-offs
- Canceling in the middle of a write leaves partial state. Accept the token up to the commit point (a transaction, a sent message), and pass
CancellationToken.Noneafter it. - In ASP.NET Core a
CancellationTokenaction parameter isHttpContext.RequestAborted: the client has gone. App shutdown is signaled by theBackgroundService.ExecuteAsynctoken andIHostApplicationLifetime.ApplicationStopping.
How to check in production
Tell a timeout apart from cancellation by the caller: catch (OperationCanceledException) when (!ct.IsCancellationRequested) means your internal timeout fired. Log it and turn it into a TimeoutException, or your metrics will mistake it for the client giving up.
Takeaway: cancellation is cooperative and changes the task’s status; decide up front where it can be accepted without corrupting data.
public async Task<string> DownloadAsync(string url, CancellationToken ct = default){ // Cancel on the external token or after 5 seconds using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(5));
try { return await _http.GetStringAsync(url, cts.Token); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { // the external token isn't canceled, so our timeout fired throw new TimeoutException($"{url}: no response within 5 seconds"); }}
// A CPU-bound loop polls the token itselfstatic long Sum(int[] data, CancellationToken ct){ long sum = 0; for (int i = 0; i < data.Length; i++) { if (i % 10_000 == 0) ct.ThrowIfCancellationRequested(); sum += data[i]; } return sum;}- How does
OperationCanceledExceptiondiffer fromTaskCanceledException? — The second derives from the first; catch the base one. - Why
Disposea linkedCancellationTokenSource? — It is registered on the parent token, and withoutDisposethat registration lives as long as the parent. - Does
WaitAsynccancel the operation itself? — No, it only stops waiting for it. - How do you tell a timeout from cancellation by the client? — A
whenfilter on the external token’sIsCancellationRequested. - Where do you get a token in ASP.NET Core? — A
CancellationTokenaction parameter (that isHttpContext.RequestAborted) and theExecuteAsynctoken inBackgroundService.
How do you run several tasks in parallel and limit how many run at once?
Section titled “How do you run several tasks in parallel and limit how many run at once?”What it is
Task.WhenAll returns a task that completes when all the tasks you pass in have completed.
Why
Sequential awaits add up the operations’ times. Concurrent operations take as long as the longest one.
How to use
- Start the tasks without
await:var a = GetUserAsync(id);andvar b = GetOrdersAsync(id);. - Wait for all of them:
await Task.WhenAll(a, b);. - Get the results with
await aandawait b— the tasks are already complete.
For a collection, WhenAll returns an array right away: int[] r = await Task.WhenAll(tasks);.
Common mistake
Enumerating an IEnumerable from Select twice: the tasks get created again. Call ToArray() right away.
Takeaway: start all the tasks first, then wait for them together.
How it works
- The
WhenAlltask waits for every task, even if one has already failed. - If any is
Faulted, the result isFaulted, andException.InnerExceptionsholds every error.awaitrethrows only the first. WhenAnyreturns the first task to complete and does not rethrow its exception itself — you need toawaitthe chosen task.
When to choose what
| Task | Tool |
|---|---|
| A few different calls | Task.WhenAll |
| Results as they become ready | Task.WhenEach (.NET 9+) |
| A large collection with a limit | Parallel.ForEachAsync (.NET 6+) |
| A limit around arbitrary code | SemaphoreSlim: WaitAsync, Release in finally |
Pitfalls
- Without options,
Parallel.ForEachAsyncis limited toEnvironment.ProcessorCount, which is often too low for I/O. SetMaxDegreeOfParallelism. Parallel.ForEachwith an async lambda turns it intoasync void: the loop does not wait for the operations.- A loop of
WhenAnythat removes the finished task from a list runs in quadratic time. That is whatWhenEachis for.
Rule of thumb
To see every error, keep the WhenAll task in a variable and read its Exception in the catch.
Takeaway: WhenAll is for a small set of tasks; Parallel.ForEachAsync and a semaphore are for when the number of concurrent operations must be limited.
Under the hood
WhenAlldoes not cancel sibling tasks. For fail-fast, pass a sharedCancellationTokenSourcetoken to every task and callCancel()on the first failure.- On an exception,
Parallel.ForEachAsynccancels the token passed to the body, stops handing out items and completesFaultedwith all the exceptions. If canceled by the external token, the result isCanceled.
Cost
items.Select(ProcessAsync).ToArray() for a million items creates a million tasks and state machines at once. A semaphore inside does not help: the tasks already exist and are waiting. Parallel.ForEachAsync and channels create work as slots free up.
Trade-offs
- A continuous stream of work, with producers and consumers at different speeds — a
Channel<T>with bounded capacity and N readers. - A global limit on a resource used from different places in the code — a shared
SemaphoreSlimor a rate limiter from theSystem.Threading.RateLimitingpackage. - The degree of parallelism for I/O is set by the external system’s limits, not by the core count, and is moved into configuration.
How to check in production
Run a load test at different degrees of parallelism. If throughput stops growing while latency, 429 responses and timeouts go up, the parallelism is too high.
Takeaway: limit not just the number of concurrent operations but also the number of tasks you create.
using System.Collections.Concurrent;
// At most 8 requests at a timevar options = new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct };var lengths = new ConcurrentDictionary<string, int>();await Parallel.ForEachAsync(urls, options, async (url, token) =>{ string html = await http.GetStringAsync(url, token); lengths[url] = html.Length;});
// WhenAll: every error, not just the firstTask<int>[] tasks = ids.Select(id => LoadAsync(id)).ToArray();Task<int[]> all = Task.WhenAll(tasks);try{ int[] values = await all;}catch (Exception) when (all.Exception is { } agg){ foreach (var ex in agg.InnerExceptions) Console.WriteLine(ex.Message); // each error separately}
// .NET 9+: results in completion orderawait foreach (Task<int> done in Task.WhenEach(tasks)) Console.WriteLine(done.Status); // RanToCompletion or Faulted- What does
await Task.WhenAllthrow if three tasks failed? — The first exception; all three are inException.InnerExceptionsof theWhenAlltask. - Does
WhenAllcancel the other tasks on failure? — No, you need a sharedCancellationTokenSource. - Why not use
Parallel.ForEachwith an async lambda? — The lambda becomesasync void, and the loop does not wait for the operations. - What is the default degree of parallelism of
Parallel.ForEachAsync? —Environment.ProcessorCount; for I/O it is usually set explicitly. - When is a
Channel<T>better thanParallel.ForEachAsync? — When data arrives continuously and you need backpressure between the producer and consumers.
When should you use ValueTask instead of Task, and what are its limitations?
Section titled “When should you use ValueTask instead of Task, and what are its limitations?”What it is
Task is a class, and an async method usually creates a new task object on the heap. ValueTask<T> is a struct:
- if the result is already available, it stores the value itself, with no allocation;
- if not, it wraps an ordinary
Task<T>.
Why
A method reads a value from a cache and goes to the database only on a miss. Almost every call completes immediately, and ValueTask<T> saves the extra objects.
How to use
- By default, write
TaskandTask<T>. awaitaValueTaskright away, and only once.
Common mistake
Storing a ValueTask in a field or passing it to Task.WhenAll. For that, first convert it to a task with AsTask().
Takeaway: ValueTask is an optimization for a frequently synchronous result, not a replacement for Task.
How it works
async ValueTask<T>does not allocate when it completes synchronously, and when it completes asynchronously it allocates a state machine box, just likeTask<T>.- A method without
asynccan returnnew ValueTask<T>(value)on the fast path andnew ValueTask<T>(LoadAsync())on the slow path. Then the fast path has no state machine at all.
When to choose what
| Situation | Type |
|---|---|
| An ordinary business method | Task, Task<T> |
| Frequent calls, almost always a synchronous result | ValueTask<T> |
| The result is awaited several times or cached | Task<T> |
| Synchronous success with no result | Task and Task.CompletedTask |
Pitfalls
With a ValueTask you must not:
awaitit twice;awaitit from two places at once;- read
.Resultor.GetAwaiter().GetResult()before it completes; - combine several ways of getting the result.
Breaking these rules gives undefined behavior, not a clear exception.
Rule of thumb
One ValueTask — one consumption: await, or AsTask(), or Result after checking IsCompletedSuccessfully.
Takeaway: ValueTask saves an allocation at the price of strict rules for every consumer.
Under the hood
- A source-backed
ValueTask<T>is a reference to anIValueTaskSource<T>plus ashortversion token. Socketkeeps one such object for receives and one for sends, so back-to-backReceiveAsyncandSendAsynccalls do not allocate.- The token only partly protects against a stale
ValueTaskafter the source is reused. Hence the “oneawait” rule.
Cost
ValueTask<T>has several fields: it is more expensive to copy than a reference and grows the caller’s state machine.WhenAllneedsAsTask()— the very allocation you were trying to avoid.- A custom source is built on
ManualResetValueTaskSourceCore<T>, while the object pool and state reset are up to you.
Trade-offs
- Since .NET 6, the
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]attribute on a method takes the state machine box from a pool. Asynchronous completion then avoids allocation too, but the pool holds on to memory. ValueTaskin a public interface gives implementations room to optimize, but obliges every consumer to follow the rules. That is howIAsyncEnumerator<T>.MoveNextAsyncandIAsyncDisposable.DisposeAsyncare designed.
How to check in production
- BenchmarkDotNet with
[MemoryDiagnoser]on a realistic mix of synchronous and asynchronous completions. dotnet-counters: the allocation rate under load before and after the change.- The gain shows up at millions of calls per second; in ordinary business code it is complexity with no benefit.
Takeaway: ValueTask is a tool for library authors, and switching to it is worth it only on profiling evidence.
using System.Collections.Concurrent;
private readonly ConcurrentDictionary<int, Product> _cache = new();
// The result is almost always in the cache alreadypublic ValueTask<Product> GetProductAsync(int id){ if (_cache.TryGetValue(id, out var product)) return new ValueTask<Product>(product); // no allocation
return new ValueTask<Product>(LoadAndCacheAsync(id)); // miss: an ordinary Task}
private async Task<Product> LoadAndCacheAsync(int id){ Product product = await _repository.LoadAsync(id); _cache[id] = product; return product;}
// ConsumerProduct p = await GetProductAsync(42); // correct: a single await right away
ValueTask<Product> vt = GetProductAsync(42);await vt;await vt; // violation: with IValueTaskSource this is undefined behavior- Why not return
ValueTaskeverywhere? — It is bigger, it cannot be awaited twice, and it only pays off with frequent synchronous completion. - Does
async ValueTask<T>allocate if the method suspends? — Yes, a state machine box, likeTask; allocation-free only with the pooling builder. - What is
IValueTaskSource? — A reusable operation object that letsValueTaskwork without allocations, for example inSocket. - How do you pass a
ValueTasktoTask.WhenAll? — ThroughAsTask(), exactly once. - Why are task caches built on
Task<T>and notValueTask<T>? — ATask<T>can be awaited many times and from different places.