asynchronous programming
async methods
concurrency
parallel execution
async/await

Using async to run multiple methods

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Using async does not automatically make multiple methods run at the same time. In C#, async mostly changes how you wait for work. If you want several independent asynchronous operations to overlap, you need to start them first and then await their completion together.

The standard pattern is Task.WhenAll. It is simple, but the details matter: sequential await is still sequential, exceptions are aggregated differently, and CPU-bound work is a separate problem from I/O-bound async calls.

Start Tasks Before Awaiting Them

This code looks asynchronous, but from the caller's point of view it still runs one step after another:

csharp
await LoadUserAsync();
await LoadOrdersAsync();
await LoadRecommendationsAsync();

Each method starts only after the previous one has completed. To allow the operations to overlap, create the tasks first:

csharp
1Task<string> userTask = LoadUserAsync();
2Task<string> ordersTask = LoadOrdersAsync();
3Task<string> recommendationsTask = LoadRecommendationsAsync();
4
5await Task.WhenAll(userTask, ordersTask, recommendationsTask);
6
7string user = await userTask;
8string orders = await ordersTask;
9string recommendations = await recommendationsTask;

This works well when the methods are independent and mostly waiting on I/O such as HTTP calls, database queries, or file access.

A Runnable Example with Task.WhenAll

The pattern is clearer in a small console example:

csharp
1using System;
2using System.Threading.Tasks;
3
4class Program
5{
6    static async Task Main()
7    {
8        Task<string> a = FetchAsync("A", 1000);
9        Task<string> b = FetchAsync("B", 1500);
10        Task<string> c = FetchAsync("C", 500);
11
12        string[] results = await Task.WhenAll(a, b, c);
13
14        foreach (string result in results)
15        {
16            Console.WriteLine(result);
17        }
18    }
19
20    static async Task<string> FetchAsync(string name, int delayMs)
21    {
22        await Task.Delay(delayMs);
23        return $"Finished {name}";
24    }
25}

The total runtime is close to the longest delay rather than the sum of all delays because the waits overlap.

Limit Concurrency When Needed

Sometimes you do not want to start every operation at once. If you are making network calls to an external service or reading many files, unbounded concurrency can create throttling or memory pressure. A SemaphoreSlim lets you cap the number of simultaneous operations.

csharp
1using System.Collections.Generic;
2using System.Linq;
3using System.Threading;
4using System.Threading.Tasks;
5
6static async Task<List<string>> FetchAllAsync(IEnumerable<string> ids)
7{
8    using SemaphoreSlim gate = new SemaphoreSlim(3);
9
10    var tasks = ids.Select(async id =>
11    {
12        await gate.WaitAsync();
13        try
14        {
15            return await LoadItemAsync(id);
16        }
17        finally
18        {
19            gate.Release();
20        }
21    });
22
23    return (await Task.WhenAll(tasks)).ToList();
24}

This keeps the non-blocking behavior while protecting the system from excessive fan-out.

Distinguish Async I/O from CPU Parallelism

async is best for operations that spend time waiting. It does not automatically spread CPU-heavy work across multiple cores. If the workload is CPU-bound, Task.Run or another parallelism tool may be appropriate.

csharp
int result = await Task.Run(() => ExpensiveCalculation());

That is a different decision from normal async I/O. Use it deliberately. Wrapping everything in Task.Run is not good async design.

Common Pitfalls

The biggest mistake is awaiting each async method immediately and expecting overlap. If you await one call before starting the next, the caller still experiences sequential execution.

Another issue is using Task.WhenAll when operations are not really independent. If method B needs the result of method A, forcing concurrency only makes the code harder to reason about.

Error handling also changes. If one task fails inside Task.WhenAll, the combined await fails as well. That may be exactly what you want, but you should decide that consciously.

Finally, do not confuse non-blocking I/O with CPU parallelism. They solve related but different performance problems.

Summary

  • Start independent tasks first, then await them together.
  • Use Task.WhenAll to overlap asynchronous operations cleanly.
  • Apply concurrency limits when external services or memory usage matter.
  • Use Task.Run only for intentional CPU-bound offloading.
  • Keep the code sequential when the operations depend on each other.

Course illustration
Course illustration

All Rights Reserved.