Skip to content

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.

Junior+

How is asynchrony different from multithreading and parallelism?

Section titled “How is asynchrony different from multithreading and parallelism?”
Answer

What it is

  • Asynchrony — the thread does not sit idle while an operation waits for an external resource. await returns 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

  1. An asynchronous API hands the I/O operation to the OS and returns an incomplete Task.
  2. The method exits at await, and the thread returns to the pool to pick up other work.
  3. 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 synchronous Read) blocks the thread just as it would without async.
  • A method marked async but without await runs fully synchronously — the compiler issues warning CS1998.
  • For small CPU work, Task.Run and 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> and BackgroundService) 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.

Example
// I/O-bound: the thread is free while the request is in flight
async 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 thread
async 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 cores
var squares = new long[1_000];
Parallel.For(0, squares.Length, i => squares[i] = (long)i * i);
Console.WriteLine(squares[999]); // 998001
Junior+

Why is async void dangerous, and where is it still acceptable?

Section titled “Why is async void dangerous, and where is it still acceptable?”
Answer

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 Task or Task<T>.
  • async void — only for event handlers such as Button_Click.
  • Move the logic into an async Task method and keep only await and try/catch in 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 void with a try/catch inside.
  • Everything else — async Task.

Pitfalls

  • Hidden async void. An async lambda passed as an Action parameter becomes async void. So list.ForEach(async x => await SendAsync(x)) does not wait for the sends.
  • Implementing a void method. An async implementation of an ordinary void method 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 void method 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

  1. When created, AsyncVoidMethodBuilder calls SynchronizationContext.Current?.OperationStarted().
  2. When the method completes, it calls OperationCompleted(). This lets a context, such as a test framework’s, wait for all async void calls.
  3. The builder posts an unhandled exception via Post to 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.UnhandledException only gets a chance to log it.
  • For ASP.NET Core that means an app restart and every in-flight request dropped.
  • TaskScheduler.UnobservedTaskException will 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 DbContext are already disposed, and nobody waits for it on shutdown.
  • Reliable fire-and-forget is a background queue: Channel<T> and BackgroundService with 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.

Example
// Bad: can't wait for completion, can't catch the exception
async void SaveBad() => await File.WriteAllTextAsync("data.txt", "...");
// Good: the logic lives in async Task
async Task SaveAsync() => await File.WriteAllTextAsync("data.txt", "...");
// An event handler is the only legitimate place for async void
private 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 sends
await Task.WhenAll(items.Select(SendAsync)); // correct
Middle+
Answer

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

  1. Code before the first await runs immediately and synchronously on the calling thread.
  2. At await it checks whether the task is complete. If it is, execution simply continues.
  3. If not, the method records the step and returns an incomplete Task to the caller.
  4. When the task completes, the method continues from where it stopped.
  5. return completes the Task with a result, and an exception is stored in it and thrown at await.

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

  1. The compiler generates a stub method and a state machine type. Locals that live across await become its fields.
  2. The stub calls builder.Start, which runs MoveNext synchronously, and then returns builder.Task.
  3. MoveNext is the original code split into states and wrapped in try/catch. An exception goes to builder.SetException, the result to builder.SetResult.
  4. await x calls x.GetAwaiter() and checks IsCompleted.
  5. If the task is not ready, the builder hooks MoveNext up to its completion, and the method exits.
  6. 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 await in ASP.NET Core the thread can change, so [ThreadStatic] fields are unreliable between steps of a method.
  • await inside lock is forbidden (error CS1996): the continuation may run on another thread, while a Monitor is tied to its owning thread.
  • await works with any type that has a suitable GetAwaiter() — 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 await completes synchronously, the struct never leaves the stack. If the result is also cached (Task.CompletedTask, Task<bool>, some small int values), the method allocates nothing.
  • On the first real suspension the struct is copied into an AsyncStateMachineBox<TStateMachine>. That type derives from Task<TResult>, so the box is the returned task.
  • builder.Start saves and restores the ExecutionContext, so changes to AsyncLocal<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. For TaskCompletionSource that means surprising reentrancy, so it is created with TaskCreationOptions.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.

Simplified: what await expands to
// The original method
async 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); }
}
Middle+

What is SynchronizationContext, and why use ConfigureAwait(false)?

Section titled “What is SynchronizationContext, and why use ConfigureAwait(false)?”
Answer

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 await remembers the context and resumes the method in it.
  • await task.ConfigureAwait(false) means “I don’t need the context”: code after await runs on the thread pool.
  • Console apps and ASP.NET Core have no context: SynchronizationContext.Current is null.

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 every await: it may be called from a UI app. Analyzer CA2007 helps you remember.

Pitfalls

  • The first await alone is not enough. If the first task completes synchronously, the context is not dropped, and the next await captures 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 Task awaiter captures SynchronizationContext.Current if it is not the base class. Otherwise, TaskScheduler.Current if 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.
  • ExecutionContext flows through any await: AsyncLocal<T> and Activity.Current do not depend on ConfigureAwait.

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: HttpContext is 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.

Example
// WinForms/WPF: after await we are back on the UI thread
private 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 needed
public 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 exception
await backgroundTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
Middle+

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?”
Answer

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

  1. A button click handler calls LoadAsync().Result — the UI thread is blocked.
  2. LoadAsync contains an await. When the operation completes, the continuation has to run on the UI thread.
  3. 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:

  1. A single-threaded context: WinForms, WPF, classic ASP.NET, Blazor.
  2. A blocking wait in that context: .Result, .Wait(), GetAwaiter().GetResult().
  3. An await without ConfigureAwait(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 an IHostedService.

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

  1. In ASP.NET Core a request calling .Result holds a pool thread.
  2. The continuation that would complete the task also waits for a free pool thread.
  3. The pool quickly ramps up to a few threads per core, then adds roughly 1–2 per second.
  4. 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.SetMinThreads masks 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

  1. dotnet-counters monitor: dotnet.thread_pool.thread.count grows slowly, dotnet.thread_pool.queue.length is large, and CPU stays well below 100%. In older versions these are the threadpool-thread-count and threadpool-queue-length counters.
  2. dotnet-stack report: many pool threads sit in Task.Wait or GetResultCore.
  3. 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.

Example
// WinForms: deadlock
private 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 way
private async void LoadButtonFixed_Click(object sender, EventArgs e)
{
resultBox.Text = await LoadAsync(); // "done"
}
Middle+

How do you cancel asynchronous operations correctly?

Section titled “How do you cancel asynchronous operations correctly?”
Answer

What it is

  • CancellationTokenSource is the “remote control”: it has Cancel() and CancelAfter(...).
  • CancellationToken is the “receiver”: it has IsCancellationRequested and ThrowIfCancellationRequested().

Why

To stop work nobody needs anymore: the user closed the window, the client dropped the request, a timeout expired.

How to use

  1. Create the source with using.
  2. Pass cts.Token to the method as the last parameter.
  3. Inside, pass the token to every async call: ReadAsync(buffer, ct), SaveChangesAsync(ct).
  4. Catch OperationCanceledException separately 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 Canceled status, and await on it throws OperationCanceledException.
  • 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

  • WaitAsync stops 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 TaskCanceledException or OperationCanceledException. 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 Canceled on any OperationCanceledException, regardless of the token.
  • Task.Run(work, ct) ends up Canceled only if the exception’s token matches ct and is canceled. Otherwise the task is Faulted.
  • For Task.WhenAll, one task being Faulted outweighs another being Canceled.

Cost

  • CancellationToken is a struct, and default costs nothing.
  • CancelAfter creates a timer, and Register adds an entry to the callback list. In frequently called code a non-canceled source is reused via TryReset() (.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.None after it.
  • In ASP.NET Core a CancellationToken action parameter is HttpContext.RequestAborted: the client has gone. App shutdown is signaled by the BackgroundService.ExecuteAsync token and IHostApplicationLifetime.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.

Example
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 itself
static 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;
}
Middle+

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?”
Answer

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

  1. Start the tasks without await: var a = GetUserAsync(id); and var b = GetOrdersAsync(id);.
  2. Wait for all of them: await Task.WhenAll(a, b);.
  3. Get the results with await a and await 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 WhenAll task waits for every task, even if one has already failed.
  • If any is Faulted, the result is Faulted, and Exception.InnerExceptions holds every error. await rethrows only the first.
  • WhenAny returns the first task to complete and does not rethrow its exception itself — you need to await the 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.ForEachAsync is limited to Environment.ProcessorCount, which is often too low for I/O. Set MaxDegreeOfParallelism.
  • Parallel.ForEach with an async lambda turns it into async void: the loop does not wait for the operations.
  • A loop of WhenAny that removes the finished task from a list runs in quadratic time. That is what WhenEach is 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

  • WhenAll does not cancel sibling tasks. For fail-fast, pass a shared CancellationTokenSource token to every task and call Cancel() on the first failure.
  • On an exception, Parallel.ForEachAsync cancels the token passed to the body, stops handing out items and completes Faulted with all the exceptions. If canceled by the external token, the result is Canceled.

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 SemaphoreSlim or a rate limiter from the System.Threading.RateLimiting package.
  • 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.

Example
using System.Collections.Concurrent;
// At most 8 requests at a time
var 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 first
Task<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 order
await foreach (Task<int> done in Task.WhenEach(tasks))
Console.WriteLine(done.Status); // RanToCompletion or Faulted
Senior+

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?”
Answer

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 Task and Task<T>.
  • await a ValueTask right 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 like Task<T>.
  • A method without async can return new ValueTask<T>(value) on the fast path and new 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:

  • await it twice;
  • await it from two places at once;
  • read .Result or .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 an IValueTaskSource<T> plus a short version token.
  • Socket keeps one such object for receives and one for sends, so back-to-back ReceiveAsync and SendAsync calls do not allocate.
  • The token only partly protects against a stale ValueTask after the source is reused. Hence the “one await” rule.

Cost

  • ValueTask<T> has several fields: it is more expensive to copy than a reference and grows the caller’s state machine.
  • WhenAll needs AsTask() — 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.
  • ValueTask in a public interface gives implementations room to optimize, but obliges every consumer to follow the rules. That is how IAsyncEnumerator<T>.MoveNextAsync and IAsyncDisposable.DisposeAsync are 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.

Example
using System.Collections.Concurrent;
private readonly ConcurrentDictionary<int, Product> _cache = new();
// The result is almost always in the cache already
public 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;
}
// Consumer
Product 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