Task Coordination
By the Syncfusion DevTutorials team · Last updated
Run many tasks together: Task.WhenAll, Task.WhenAny, results, errors from many tasks, timeouts, throttling, retries and common async patterns.
Introduction to Task Coordination
Imagine a restaurant kitchen during dinner time.
One order includes a soup, a curry, and a dessert. Three cooks prepare them at the same time. The waiter does not serve the soup alone. The waiter waits until all three dishes are ready, then serves them together.
Sometimes, the kitchen works differently. The manager calls three suppliers for more rice and buys from whichever answers first.
This is task coordination: starting several tasks, then deciding how to wait for them. You can wait for all of them, or just the first one.
In simple words: task coordination means running many async operations together and collecting their results in a controlled way.
In the last lesson, you started two tasks and awaited them one by one. That works for two. Real programs often start ten or a thousand: one per file, one per customer, one per web page. This lesson shows the tools that handle a whole group at once.
Easy Way to Remember: WhenAll means "wait for everyone". WhenAny means "first one wins".
What You Will Learn
- How to run many tasks at the same time.
Task.WhenAllandTask.WhenAny.- How to collect and process results from many tasks.
- How to handle exceptions when several tasks fail.
- How to put a time limit on a task.
- How to combine independent tasks and run work in parallel.
- How to write async code that scales, and the common async patterns.
- Why coordination makes programs faster.
About the Run buttons in this lesson. The examples run in your browser on .NET in WebAssembly. Real web calls cannot run there, so Task.Delay stands in for the slow part. It waits just like a network call would.
Start With a Working Example
Press Run.
Three dishes cook at once
using System;
using System.Threading.Tasks;
Console.WriteLine("Order received");
Task<string> soup = CookAsync("Soup", 300);
Task<string> curry = CookAsync("Curry", 500);
Task<string> dessert = CookAsync("Dessert", 200);
string[] dishes = await Task.WhenAll(soup, curry, dessert);
Console.WriteLine($"Serving: {string.Join(", ", dishes)}");
static async Task<string> CookAsync(string dish, int ms)
{
Console.WriteLine($"Start {dish}");
await Task.Delay(ms);
Console.WriteLine($"{dish} ready");
return dish;
}
All three dishes start right away. They finish in order of speed, but WhenAll gives back the results in the order you passed the tasks in, not the order they finished.
Try This
Change the curry's time from 500 to 100. The curry now finishes first, but the "Serving" line stays in the same order.
Running Multiple Tasks
To run tasks at the same time, start them all first, then wait.
Calling an async method starts it. So a loop that calls the method and stores each task in a list starts every operation almost at once.
Start every task in a loop
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
string[] files = { "a.txt", "b.txt", "c.txt", "d.txt" };
var tasks = new List<Task<int>>();
foreach (string file in files)
{
tasks.Add(GetSizeAsync(file)); // starts now, does not wait
}
Console.WriteLine($"Started {tasks.Count} tasks");
int[] sizes = await Task.WhenAll(tasks);
for (int i = 0; i < files.Length; i++)
Console.WriteLine($"{files[i]}: {sizes[i]} KB");
static async Task<int> GetSizeAsync(string file)
{
await Task.Delay(100 * file.Length);
return file.Length * 10;
}
A shorter way to start one task per item is LINQ's Select:
Task<int>[] tasks = files.Select(file => GetSizeAsync(file)).ToArray();
If you put await inside the loop, the tasks run one after another, which is what you are trying to avoid. Start the tasks in the loop, and await once after the loop.
Task.WhenAll
Task.WhenAll takes a group of tasks and returns one task that finishes when every task in the group has finished.
| You pass in | await Task.WhenAll(...) gives you |
|---|---|
Task objects (no results) | Nothing, it just waits |
Task<T> objects | A T[] array, in the same order as the tasks |
The total time is the time of the slowest task, not the sum of all of them.
WhenAll with and without results
using System;
using System.Threading.Tasks;
// Tasks with no result: WhenAll just waits.
await Task.WhenAll(
SendAsync("Asha", 200),
SendAsync("Ravi", 100),
SendAsync("Meena", 300));
Console.WriteLine("All emails sent");
// Tasks with a result: WhenAll gives back an array.
int[] scores = await Task.WhenAll(ScoreAsync(70), ScoreAsync(85), ScoreAsync(92));
Console.WriteLine($"Scores: {string.Join(", ", scores)}");
static async Task SendAsync(string name, int ms)
{
await Task.Delay(ms);
Console.WriteLine($"Email sent to {name}");
}
static async Task<int> ScoreAsync(int score)
{
await Task.Delay(100);
return score;
}
Task.WhenAny
Task.WhenAny returns as soon as one task in the group finishes. The result is the task that finished first, so you await it a second time to get its value.
The fastest server wins
using System;
using System.Threading.Tasks;
Task<string> serverA = AskServerAsync("Server A", 400);
Task<string> serverB = AskServerAsync("Server B", 150);
Task<string> serverC = AskServerAsync("Server C", 300);
Task<string> winner = await Task.WhenAny(serverA, serverB, serverC);
string answer = await winner; // unwrap the winning task's result
Console.WriteLine($"First answer: {answer}");
Console.WriteLine($"Was it Server B? {winner == serverB}");
static async Task<string> AskServerAsync(string name, int ms)
{
await Task.Delay(ms);
return $"data from {name}";
}
The other two servers keep running in the background. WhenAny does not stop them. Lesson 3 shows how to cancel the others.
Task.WhenAll | Task.WhenAny | |
|---|---|---|
| Finishes when | Every task has finished | One task has finished |
await gives you | All results, as an array | The finished task (await it again for its value) |
| Typical use | Load everything a page needs | Fastest server, timeouts, "first reply wins" |
Waiting for Multiple Operations
Sometimes you want to react to each task as soon as it finishes, instead of waiting for the whole group.
The pattern is a loop with WhenAny: wait for any task, handle it, remove it from the list, repeat until the list is empty.
Handle each download the moment it finishes
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
var pending = new List<Task<string>>
{
DownloadAsync("video.mp4", 500),
DownloadAsync("photo.jpg", 100),
DownloadAsync("song.mp3", 300),
};
int done = 0;
while (pending.Count > 0)
{
Task<string> finished = await Task.WhenAny(pending);
pending.Remove(finished);
done++;
Console.WriteLine($"[{done}/3] {await finished} finished");
}
Console.WriteLine("All downloads complete");
static async Task<string> DownloadAsync(string file, int ms)
{
await Task.Delay(ms);
return file;
}
This is how a progress bar can move forward as each file arrives, instead of jumping from 0% to 100% at the end.
Processing Results from Multiple Tasks
WhenAll gives you an array of results. From there, it is ordinary C#: loop over it, add it up, or use LINQ.
Add up prices from three shops
using System;
using System.Linq;
using System.Threading.Tasks;
string[] shops = { "Shop A", "Shop B", "Shop C" };
decimal[] prices = await Task.WhenAll(shops.Select(GetPriceAsync));
for (int i = 0; i < shops.Length; i++)
Console.WriteLine($"{shops[i]}: {prices[i]}");
Console.WriteLine($"Cheapest: {prices.Min()}");
Console.WriteLine($"Average: {prices.Average():0.00}");
int cheapestIndex = Array.IndexOf(prices, prices.Min());
Console.WriteLine($"Buy from {shops[cheapestIndex]}");
static async Task<decimal> GetPriceAsync(string shop)
{
await Task.Delay(100);
return shop switch
{
"Shop A" => 499m,
"Shop B" => 459m,
_ => 520m,
};
}
Because WhenAll keeps the original order, prices[i] always belongs to shops[i].
Handling Exceptions in Multiple Tasks
When one task in a WhenAll group fails, await Task.WhenAll(...) throws. Two things to know:
WhenAllstill waits for every task to finish, even after one has failed.awaitthrows only the first exception. The full list is on theWhenAlltask's.Exception.InnerExceptions.
Two of four uploads fail
using System;
using System.Threading.Tasks;
Task[] uploads =
{
UploadAsync("a.jpg", 100, fail: false),
UploadAsync("b.jpg", 200, fail: true),
UploadAsync("c.jpg", 300, fail: false),
UploadAsync("d.jpg", 400, fail: true),
};
Task all = Task.WhenAll(uploads);
try
{
await all;
}
catch (Exception first)
{
Console.WriteLine($"await threw: {first.Message}");
Console.WriteLine($"Total failures: {all.Exception!.InnerExceptions.Count}");
foreach (Exception ex in all.Exception.InnerExceptions)
Console.WriteLine($" - {ex.Message}");
}
int succeeded = Array.FindAll(uploads, t => t.IsCompletedSuccessfully).Length;
Console.WriteLine($"Succeeded: {succeeded} of {uploads.Length}");
static async Task UploadAsync(string file, int ms, bool fail)
{
await Task.Delay(ms);
if (fail) throw new InvalidOperationException($"{file} upload failed");
Console.WriteLine($"{file} uploaded");
}
Keep the WhenAll task in a variable (all above) before awaiting it. That is the only way to reach the full list of errors.
If you want every task to report its own result, success or failure, catch inside each task instead, and return a status:
Each task reports success or failure
using System;
using System.Threading.Tasks;
string[] results = await Task.WhenAll(
TryUploadAsync("a.jpg", fail: false),
TryUploadAsync("b.jpg", fail: true),
TryUploadAsync("c.jpg", fail: false));
foreach (string r in results)
Console.WriteLine(r);
static async Task<string> TryUploadAsync(string file, bool fail)
{
try
{
await Task.Delay(100);
if (fail) throw new InvalidOperationException("server error");
return $"{file}: OK";
}
catch (Exception ex)
{
return $"{file}: FAILED ({ex.Message})";
}
}
Task Timeouts
A slow server should not make your program wait forever. A timeout says: "if this is not done in 2 seconds, give up".
There are two common ways.
1. WaitAsync(timeout) (.NET 6 and later). It throws a TimeoutException if the task is not done in time.
WaitAsync throws when time runs out
using System;
using System.Threading.Tasks;
foreach (int ms in new[] { 100, 900 })
{
try
{
string data = await LoadAsync(ms).WaitAsync(TimeSpan.FromMilliseconds(500));
Console.WriteLine($"{ms} ms request: got {data}");
}
catch (TimeoutException)
{
Console.WriteLine($"{ms} ms request: timed out after 500 ms");
}
}
static async Task<string> LoadAsync(int ms)
{
await Task.Delay(ms);
return "data";
}
2. WhenAny with Task.Delay. Race the real task against a timer. Whichever finishes first wins.
Race the task against a timer
using System;
using System.Threading.Tasks;
Task<string> request = LoadAsync(800);
Task timer = Task.Delay(300);
Task first = await Task.WhenAny(request, timer);
if (first == request)
Console.WriteLine($"Got: {await request}");
else
Console.WriteLine("Too slow, showing cached data instead");
static async Task<string> LoadAsync(int ms)
{
await Task.Delay(ms);
return "fresh data";
}
A timeout stops you from waiting. It does not stop the slow task itself, which keeps running in the background. To really stop the work, use cancellation (lesson 3).
Combining Independent Tasks
Two tasks are independent when neither needs the other's result. Independent tasks should always run together.
A product page is a good example. The product details, the reviews, and the stock level all come from different places, and none of them needs the others.
Load a product page: three independent calls
using System;
using System.Threading.Tasks;
Task<string> detailsTask = GetDetailsAsync(42);
Task<double> ratingTask = GetRatingAsync(42);
Task<int> stockTask = GetStockAsync(42);
await Task.WhenAll(detailsTask, ratingTask, stockTask);
// All three are finished, so these awaits return at once.
string details = await detailsTask;
double rating = await ratingTask;
int stock = await stockTask;
Console.WriteLine(details);
Console.WriteLine($"Rating: {rating} / 5");
Console.WriteLine(stock > 0 ? $"In stock: {stock}" : "Out of stock");
static async Task<string> GetDetailsAsync(int id) { await Task.Delay(300); return $"Product {id}: Desk lamp"; }
static async Task<double> GetRatingAsync(int id) { await Task.Delay(200); return 4.5; }
static async Task<int> GetStockAsync(int id) { await Task.Delay(100); return 12; }
The three tasks return different types, so they cannot share one array. Keep each task in its own variable, use WhenAll to wait for all, then await each one to read its value.
When tasks depend on each other, keep the order, and only run the independent parts together:
User user = await GetUserAsync(id); // needed first
Task<Order[]> orders = GetOrdersAsync(user.Id); // both need user,
Task<Address> address = GetAddressAsync(user.Id); // but not each other
await Task.WhenAll(orders, address);
Parallel Async Operations
Starting a thousand tasks at once can overload a server, or hit a rate limit such as "at most 3 requests at a time".
SemaphoreSlim works like a set of entry tickets. Each task takes a ticket before it starts and gives it back when it finishes. When all tickets are taken, the next task waits.
At most 2 downloads at a time
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
var gate = new SemaphoreSlim(2); // 2 tickets
int running = 0, maxRunning = 0;
string[] files = { "f1", "f2", "f3", "f4", "f5", "f6" };
await Task.WhenAll(files.Select(DownloadAsync));
Console.WriteLine($"All {files.Length} files done");
Console.WriteLine($"Most running at the same time: {maxRunning}");
async Task DownloadAsync(string file)
{
await gate.WaitAsync(); // take a ticket (wait if none left)
try
{
running++;
maxRunning = Math.Max(maxRunning, running);
await Task.Delay(100);
running--;
}
finally
{
gate.Release(); // give the ticket back
}
}
.NET 6 and later also has Parallel.ForEachAsync, which does the same thing in one call. It uses real background threads, so run it on your own computer:
await Parallel.ForEachAsync(
files,
new ParallelOptions { MaxDegreeOfParallelism = 3 },
async (file, token) =>
{
await DownloadAsync(file, token);
});
Writing Scalable Async Code
Scalable code keeps working well when the load grows from 10 users to 10,000.
| Rule | Why it matters |
|---|---|
| Start independent work together | Total time is the slowest task, not the sum |
| Limit how many run at once | Protects servers, databases, and rate limits |
Never block with .Result or .Wait() | A blocked thread cannot serve anyone else |
| Use real async I/O methods | ReadAsync, GetAsync, SaveChangesAsync free the thread while waiting |
| Keep CPU work out of hot async paths | Heavy calculation holds a thread just like blocking does |
| Always add a timeout to network calls | One slow server should not freeze every request |
A small example of the difference. Ten requests of 100 ms each:
Ten requests: in a row, or all together
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
var sw = Stopwatch.StartNew();
for (int i = 0; i < 10; i++)
await RequestAsync();
long inARow = sw.ElapsedMilliseconds;
sw.Restart();
await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => RequestAsync()));
long together = sw.ElapsedMilliseconds;
Console.WriteLine($"In a row is slower: {inARow > together}");
Console.WriteLine($"In a row took at least 1 second: {inARow >= 1000}");
Console.WriteLine($"Together took under half a second: {together < 500}");
static Task RequestAsync() => Task.Delay(100);
Common Async Patterns
These patterns appear again and again in real code.
| Pattern | What it does | Tool |
|---|---|---|
| Fan-out / fan-in | Start many tasks, then collect all results | Select + WhenAll |
| First wins | Use whichever source answers first | WhenAny |
| Timeout | Give up after a time limit | WaitAsync or WhenAny + Task.Delay |
| Process as completed | Handle each result as it arrives | WhenAny in a loop |
| Throttling | Limit how many run at once | SemaphoreSlim or Parallel.ForEachAsync |
| Retry | Try again after a failure, with a pause | loop + try/catch + Task.Delay |
The retry pattern is worth seeing in full, because network calls often fail once and then succeed:
Retry up to 3 times
using System;
using System.Threading.Tasks;
int attempts = 0;
string result = await RetryAsync(async () =>
{
attempts++;
await Task.Delay(50);
if (attempts < 3) throw new InvalidOperationException("server busy");
return "order placed";
}, maxTries: 3);
Console.WriteLine($"Result: {result} (after {attempts} attempts)");
static async Task<T> RetryAsync<T>(Func<Task<T>> action, int maxTries)
{
for (int attempt = 1; ; attempt++)
{
try
{
return await action();
}
catch (Exception ex) when (attempt < maxTries)
{
Console.WriteLine($"Attempt {attempt} failed: {ex.Message}. Retrying...");
await Task.Delay(100 * attempt); // wait a little longer each time
}
}
}
The when (attempt < maxTries) filter lets the last failure escape to the caller, so a request that never succeeds still reports its error.
Performance Benefits of Task Coordination
Coordination does not make any single operation faster. It removes the waiting in between.
| Approach | 3 calls of 200, 300 and 500 ms | Why |
|---|---|---|
| Await one by one | about 1000 ms | The waits add up |
WhenAll | about 500 ms | The waits overlap, only the slowest counts |
WhenAny | about 200 ms | Only the fastest counts |
On a web server, the benefit is even bigger. While a request waits for the database, its thread is free to start other requests. The same server can handle many more users without any extra hardware.
Coordination pays off for waiting work (network, disk, database, timers). For heavy calculation, the limit is the number of CPU cores, and the tools are Parallel.For and PLINQ, covered in the Multithreading chapter.
Common mistakes
| Mistake | Wrong | Correct | Why |
|---|---|---|---|
| Awaiting inside the loop | foreach (...) await LoadAsync(f); | await Task.WhenAll(tasks); | The tasks run one at a time |
| Using the WhenAny result as the value | await Task.WhenAny(a, b) | await await Task.WhenAny(a, b) | WhenAny returns the finished task |
| Expecting finishing order from WhenAll | results[0] is the fastest | Use WhenAny in a loop | WhenAll keeps the order you passed in |
| Losing all but the first error | catch after await WhenAll | Read all.Exception.InnerExceptions | await rethrows only the first error |
| Thinking a timeout cancels the work | await t.WaitAsync(time); | Also pass a CancellationToken | A timeout only stops the waiting |
| Starting too many at once | 10,000 tasks in one WhenAll | Limit with SemaphoreSlim | Servers reject floods of requests |
| Blocking on a group | Task.WaitAll(tasks); | await Task.WhenAll(tasks); | Blocks the thread; fails in the browser |
What to Remember
- Start independent tasks first, then wait for them together.
Task.WhenAllwaits for every task and returns all results in the order you passed them in.Task.WhenAnyreturns the first task to finish;awaitit again for its value.WhenAnyin a loop lets you handle each result as soon as it arrives.- When tasks in
WhenAllfail,awaitthrows the first error;.Exception.InnerExceptionsholds them all. - Use
WaitAsyncorWhenAnywithTask.Delayfor timeouts. A timeout stops the waiting, not the task. - Limit how many tasks run at once with
SemaphoreSlimorParallel.ForEachAsync. - Common patterns: fan-out/fan-in, first wins, timeout, process as completed, throttling, retry.
- Coordination saves time by overlapping waits: the total is the slowest task, not the sum.
Next: Cancellation