Running Work Concurrently
By the end of this lesson
Start several operations together with Task.WhenAll and know when not to.
You have seen that starting work and collecting its result are separate acts. Task.WhenAll makes that separation easy to express: start several operations, then wait for all of them.
The decision that matters is not how to write it. It is whether the operations are independent. Two calls that do not need each other's results can overlap. A call that needs the answer from the previous one cannot, no matter how it is written.
Getting this wrong in either direction costs you. Awaiting independent calls one at a time makes a page slower than it needs to be. Firing off hundreds of dependent or heavy calls at once can take down the thing you are calling.
Two shapes, and the data decides which one you are allowed to use:
| Independent work | Dependent work | |
|---|---|---|
| The test | No call needs a result from another | A later call needs an earlier result as input |
| How you write it | Start all of them, then await Task.WhenAll | await each one in order |
| Time taken | About as long as the slowest call | The sum of all the calls |
| Example | Fetching a customer, their open orders and a currency rate for one page | Find the order, then charge its payment method, then mark it paid |
| What goes wrong if you pick the other one | The page is needlessly slow | A compile error at best, a null or a wrong value at worst |
| Load on the other end | All calls arrive at once, which the target has to be able to take | One call at a time, steady and predictable |
public async Task<OrderPage> BuildPageAsync(int orderId, CancellationToken ct)
{
// Dependent. Everything below needs the order, so this one is on its own.
Order? order = await _orders.FindAsync(orderId, ct);
if (order is null)
{
throw new OrderNotFoundException(orderId);
}
// Independent. None of these three needs a result from the others.
Task<Customer> customerTask = _customers.GetAsync(order.CustomerId, ct);
Task<List<Shipment>> shipmentsTask = _shipments.ForOrderAsync(order.Id, ct);
Task<decimal> rateTask = _rates.GetAsync(order.Currency, ct);
await Task.WhenAll(customerTask, shipmentsTask, rateTask);
// Every task has finished, so reading Result here cannot block.
return new OrderPage(
order,
customerTask.Result,
shipmentsTask.Result,
rateTask.Result);
}- The first await stands alone because it has to. The customer id, the order id and the currency all come out of the order, so nothing else can start until it has arrived.
- The next three lines start three operations without awaiting them. All three are in flight by the time the fourth line runs.
- Task.WhenAll returns a task that completes when all three have. If the three calls take 120, 300 and 90 milliseconds, this step takes about 300 rather than 510.
- Reading .Result after Task.WhenAll is safe, because every task has already completed. This is the one place .Result does not block. If you find it uncomfortable, await each task individually after the WhenAll instead — those awaits cost nothing since the work is done.
- There is an overload of Task.WhenAll for tasks of the same type that returns an array of results, which is neater when you have a list of identical calls. Here the three types differ, so separate variables are clearer.
- The cancellation token goes into all four calls. Concurrency does not change that, and a cancelled page should stop all three outstanding requests, not just the next one.
public async Task<int> RevalueOrdersAsync(
IReadOnlyList<int> orderIds,
CancellationToken ct)
{
int updated = 0;
// Four at a time is enough to hide the latency without flooding the API.
ParallelOptions options = new()
{
MaxDegreeOfParallelism = 4,
CancellationToken = ct,
};
await Parallel.ForEachAsync(orderIds, options, async (orderId, token) =>
{
// A context per operation, because DbContext is not thread-safe.
await using AppDbContext db = await _dbFactory.CreateDbContextAsync(token);
Order? order = await db.Orders.FindAsync(new object?[] { orderId }, token);
if (order is null)
{
return;
}
decimal rate = await _rates.GetAsync(order.Currency, token);
order.SetLocalTotal(order.Total * rate);
await db.SaveChangesAsync(token);
Interlocked.Increment(ref updated);
});
return updated;
}- Parallel.ForEachAsync runs an asynchronous body over a collection with a ceiling on how many are in flight. It is the readable way to say "concurrent, but not unlimited".
- MaxDegreeOfParallelism is the ceiling. Four is a starting guess, not a rule; the right number comes from the limits of whatever you are calling and from measurement.
- CreateDbContextAsync gives each iteration its own context. This is the part that is not optional — sharing one context across concurrent iterations is a bug even when it appears to work in testing.
- await using disposes each context when its iteration finishes, returning its connection to the pool. Without this, four concurrent iterations over 10,000 orders would hold on to 10,000 contexts.
- Interlocked.Increment is needed because several iterations may update the counter at the same moment. updated++ is not atomic: it reads, adds and writes, and two iterations can read the same value.
- The body receives its own token rather than closing over ct. They are the same cancellation here, but using the supplied one keeps the body honest if the options change.
- If you do not have a DbContext factory available, SemaphoreSlim with WaitAsync and Release achieves the same throttling by hand. Parallel.ForEachAsync is less code and harder to get wrong.
Summary
- Task.WhenAll suits work where no operation needs another's result; sequential awaits are required when one does
- Overlapping independent calls costs about as long as the slowest one instead of the sum
- Concurrency moves load onto the database or API you are calling, and their limits decide what is safe
- Bound the concurrency for large inputs — Parallel.ForEachAsync or a semaphore — rather than starting everything at once
- A DbContext is not thread-safe: concurrent database work needs a context per operation
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Think about it
A dashboard endpoint loops over 300 customers and, for each one, queries the database for their outstanding balance. It takes 24 seconds. A developer changes the loop to build 300 tasks and await Task.WhenAll.
The endpoint now fails after 8 seconds with connection pool timeouts. What happened, and what are two better options?
Show solution
The database connection pool ran out. A default pool holds around a hundred connections, and 300 concurrent queries all want one. The requests beyond the pool size wait, hit the pool timeout, and throw. Nothing was wrong with the async code; the change demanded a resource that does not exist in that quantity.
The first better option is to bound the concurrency — four, eight, perhaps sixteen simultaneous queries. That captures most of the benefit of overlapping without exceeding the pool. It needs a context per query, since a DbContext cannot serve concurrent operations.
The second, and usually the right one, is to stop making 300 queries. One query that groups balances by customer returns the same data in a single round trip. That is not a concurrency fix at all: it removes the work rather than rearranging it.
The general lesson is that 300 sequential queries and 300 concurrent queries are two answers to the wrong question. Concurrency is for work that genuinely has to happen separately. When the work is 300 variations of the same query, the shape of the query is the problem.
Try it yourself
Try it yourself
An invoice screen needs four things: the invoice, the customer who owns it, the payment history for that invoice, and the tax rate for the customer's country.
Decide which of these can overlap and which cannot, then write the method. State your reasoning before you write any code.
Show solution
The invoice has to come first, because the customer id and the invoice id used below come from it. That is a data dependency, so it gets its own await.
The customer and the payment history can then overlap. Both only need values the invoice already gave you.
The tax rate is the interesting one. It needs the country, which is on the customer, not the invoice — so it cannot start until the customer has arrived. That makes three rounds of waiting, not two: invoice, then customer and payments together, then the rate.
It is tempting to put all three in one Task.WhenAll. That compiles only if you pass the country from somewhere, and if you take it from an unfinished task you get a wrong answer or an exception. Working out the dependency chain before writing the code is what stops that.
If the extra round trip mattered enough, you would change the query so the invoice response includes the country. Removing a dependency is a better fix than trying to run around one.
public async Task<InvoiceScreen> BuildAsync(int invoiceId, CancellationToken ct)
{
// Round 1: everything below needs the invoice.
Invoice invoice = await _invoices.GetAsync(invoiceId, ct)
?? throw new InvoiceNotFoundException(invoiceId);
// Round 2: independent of each other.
Task<Customer> customerTask = _customers.GetAsync(invoice.CustomerId, ct);
Task<List<Payment>> paymentsTask = _payments.ForInvoiceAsync(invoice.Id, ct);
await Task.WhenAll(customerTask, paymentsTask);
Customer customer = customerTask.Result;
// Round 3: needs the customer's country, so it could not have started earlier.
decimal taxRate = await _tax.GetRateAsync(customer.CountryCode, ct);
return new InvoiceScreen(invoice, customer, paymentsTask.Result, taxRate);
}Saved in this browser only.