Skip to content

Understanding the Asynchronous Programming Model in C#

.NET has had three different ways (patterns) to write asynchronous code. Today we use only the newest one, but you will still meet the older ones in old projects and interview questions, so it is good to recognise them.

PatternIntroducedHow you recognise itStatus today
APM: Asynchronous Programming Model.NET Framework 1.0BeginXxx / EndXxx methods and IAsyncResultLegacy
EAP: Event-based Asynchronous Pattern.NET Framework 2.0XxxAsync method plus an XxxCompleted eventLegacy
TAP: Task-based Asynchronous Pattern.NET Framework 4.0 (Task), C# 5 (async/await)Methods return Task / Task<T>Recommended

1. APM: Begin and End methods

FileStream stream = new FileStream("data.txt", FileMode.Open);
byte[] buffer = new byte[1024];

stream.BeginRead(buffer, 0, buffer.Length, result =>
{
    int bytesRead = stream.EndRead(result);   // get the result in a callback
    Console.WriteLine($"Read {bytesRead} bytes");
    stream.Dispose();
}, null);

The result arrives in a callback. Error handling and chaining several steps quickly become messy.

2. EAP: an Async method plus a Completed event

// WebClient is obsolete in .NET 6+, shown only to explain the pattern
WebClient client = new WebClient();
client.DownloadStringCompleted += (sender, e) =>
{
    if (e.Error != null)
        Console.WriteLine($"Error: {e.Error.Message}");
    else
        Console.WriteLine($"Downloaded {e.Result.Length} characters");
};
client.DownloadStringAsync(new Uri("https://example.com"));

Better than APM for UI apps, but you still subscribe to events and the logic is split.

3. TAP: Task + async/await (the modern model)

using HttpClient client = new HttpClient();
try
{
    string html = await client.GetStringAsync("https://example.com");
    Console.WriteLine($"Downloaded {html.Length} characters");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"Error: {ex.Message}");
}

Same job, written top to bottom with a normal try/catch. When people say "the asynchronous programming model in C#" today, they almost always mean TAP with async and await.

The rules of TAP

Every well-written TAP method follows these conventions:

  • It returns Task or Task<T> (or ValueTask).
  • Its name ends with Async: SaveChangesAsync, ReadAsync.
  • It optionally accepts a CancellationToken as the last parameter.
  • It starts its work and returns quickly; it does not block the caller.
  • Errors are reported through the returned task (thrown when awaited).

Two kinds of async work: I/O-bound and CPU-bound

The first question to ask about any slow code is: is it waiting, or is it calculating?

I/O-boundCPU-bound
The program is...Waiting for something outside: database, disk, networkBusy calculating with the CPU
ExamplesSQL query, Web API call, reading a file, sending an emailImage resizing, big report calculation, encryption
What to writeawait the library's async methodawait Task.Run(() => ...) (in UI apps) or Parallel
Example codeawait context.Orders.ToListAsync()await Task.Run(() => CalculateReport(data))
// I/O-bound: just await the async API
private async void btnLoad_Click(object sender, EventArgs e)
{
    lblStatus.Text = "Loading...";
    List<Order> orders = await _orderService.GetOrdersAsync();
    gridOrders.DataSource = orders;
    lblStatus.Text = $"{orders.Count} orders loaded";
}

// CPU-bound in a desktop app: move the heavy work off the UI thread
private async void btnReport_Click(object sender, EventArgs e)
{
    lblStatus.Text = "Calculating...";
    decimal total = await Task.Run(() => CalculateYearlyTotal(_allSales));
    lblStatus.Text = $"Total: {total:C}";
}

The main building blocks of TAP

Task.WhenAll: wait for all tasks

Task<Customer> customerTask = GetCustomerAsync(id);
Task<List<Order>> ordersTask = GetOrdersAsync(id);

await Task.WhenAll(customerTask, ordersTask);

Customer customer = customerTask.Result;   // safe: the task is already complete
List<Order> orders = ordersTask.Result;

Task.WhenAny: continue when the first task finishes

Task<string> downloadTask = client.GetStringAsync(url);
Task timeoutTask = Task.Delay(5000);

Task finished = await Task.WhenAny(downloadTask, timeoutTask);
if (finished == timeoutTask)
{
    Console.WriteLine("Taking too long, showing cached data");
}
else
{
    Console.WriteLine(await downloadTask);
}

CancellationToken: stop work that is no longer needed

static async Task Main()
{
    using CancellationTokenSource cts = new CancellationTokenSource();
    cts.CancelAfter(2500);   // cancel automatically after 2.5 seconds

    try
    {
        await DownloadAsync(cts.Token);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Download was cancelled");
    }
}

static async Task DownloadAsync(CancellationToken token)
{
    for (int part = 1; part <= 5; part++)
    {
        await Task.Delay(1000, token);   // stops early if the token is cancelled
        Console.WriteLine($"Downloaded part {part} of 5");
    }
}
Output
Downloaded part 1 of 5
Downloaded part 2 of 5
Download was cancelled

In ASP.NET Core you can add a CancellationToken parameter to a controller action. It is cancelled automatically when the user closes the browser, so you can stop an expensive query nobody is waiting for.

IAsyncEnumerable: async streams (C# 8+)

static async IAsyncEnumerable<int> GetNumbersAsync()
{
    for (int i = 1; i <= 3; i++)
    {
        await Task.Delay(500);   // e.g. read the next page of data
        yield return i;
    }
}

await foreach (int number in GetNumbersAsync())
{
    Console.WriteLine(number);   // prints 1, 2, 3 as each one arrives
}

Summary

  • .NET has three async patterns: APM (Begin/End), EAP (events) and TAP (Task).
  • TAP with async/await is the modern, recommended model.
  • TAP methods return Task, end with Async and can accept a CancellationToken.
  • I/O-bound work: await the async API. CPU-bound work: Task.Run (UI apps) or Parallel.
  • Useful tools: Task.WhenAll, Task.WhenAny, CancellationToken and IAsyncEnumerable.