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

  1. How to run many tasks at the same time.
  2. Task.WhenAll and Task.WhenAny.
  3. How to collect and process results from many tasks.
  4. How to handle exceptions when several tasks fail.
  5. How to put a time limit on a task.
  6. How to combine independent tasks and run work in parallel.
  7. How to write async code that scales, and the common async patterns.
  8. 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 inawait Task.WhenAll(...) gives you
Task objects (no results)Nothing, it just waits
Task<T> objectsA 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.WhenAllTask.WhenAny
Finishes whenEvery task has finishedOne task has finished
await gives youAll results, as an arrayThe finished task (await it again for its value)
Typical useLoad everything a page needsFastest 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:

  • WhenAll still waits for every task to finish, even after one has failed.
  • await throws only the first exception. The full list is on the WhenAll task'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.

RuleWhy it matters
Start independent work togetherTotal time is the slowest task, not the sum
Limit how many run at onceProtects servers, databases, and rate limits
Never block with .Result or .Wait()A blocked thread cannot serve anyone else
Use real async I/O methodsReadAsync, GetAsync, SaveChangesAsync free the thread while waiting
Keep CPU work out of hot async pathsHeavy calculation holds a thread just like blocking does
Always add a timeout to network callsOne 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.

PatternWhat it doesTool
Fan-out / fan-inStart many tasks, then collect all resultsSelect + WhenAll
First winsUse whichever source answers firstWhenAny
TimeoutGive up after a time limitWaitAsync or WhenAny + Task.Delay
Process as completedHandle each result as it arrivesWhenAny in a loop
ThrottlingLimit how many run at onceSemaphoreSlim or Parallel.ForEachAsync
RetryTry again after a failure, with a pauseloop + 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.

Approach3 calls of 200, 300 and 500 msWhy
Await one by oneabout 1000 msThe waits add up
WhenAllabout 500 msThe waits overlap, only the slowest counts
WhenAnyabout 200 msOnly 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

MistakeWrongCorrectWhy
Awaiting inside the loopforeach (...) await LoadAsync(f);await Task.WhenAll(tasks);The tasks run one at a time
Using the WhenAny result as the valueawait Task.WhenAny(a, b)await await Task.WhenAny(a, b)WhenAny returns the finished task
Expecting finishing order from WhenAllresults[0] is the fastestUse WhenAny in a loopWhenAll keeps the order you passed in
Losing all but the first errorcatch after await WhenAllRead all.Exception.InnerExceptionsawait rethrows only the first error
Thinking a timeout cancels the workawait t.WaitAsync(time);Also pass a CancellationTokenA timeout only stops the waiting
Starting too many at once10,000 tasks in one WhenAllLimit with SemaphoreSlimServers reject floods of requests
Blocking on a groupTask.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.WhenAll waits for every task and returns all results in the order you passed them in.
  • Task.WhenAny returns the first task to finish; await it again for its value.
  • WhenAny in a loop lets you handle each result as soon as it arrives.
  • When tasks in WhenAll fail, await throws the first error; .Exception.InnerExceptions holds them all.
  • Use WaitAsync or WhenAny with Task.Delay for timeouts. A timeout stops the waiting, not the task.
  • Limit how many tasks run at once with SemaphoreSlim or Parallel.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