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.
| Pattern | Introduced | How you recognise it | Status today |
|---|---|---|---|
| APM: Asynchronous Programming Model | .NET Framework 1.0 | BeginXxx / EndXxx methods and IAsyncResult | Legacy |
| EAP: Event-based Asynchronous Pattern | .NET Framework 2.0 | XxxAsync method plus an XxxCompleted event | Legacy |
| 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
TaskorTask<T>(orValueTask). - Its name ends with Async:
SaveChangesAsync,ReadAsync. - It optionally accepts a
CancellationTokenas 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-bound | CPU-bound | |
|---|---|---|
| The program is... | Waiting for something outside: database, disk, network | Busy calculating with the CPU |
| Examples | SQL query, Web API call, reading a file, sending an email | Image resizing, big report calculation, encryption |
| What to write | await the library's async method | await Task.Run(() => ...) (in UI apps) or Parallel |
| Example code | await 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");
}
}
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/awaitis the modern, recommended model. - TAP methods return
Task, end with Async and can accept aCancellationToken. - I/O-bound work: await the async API. CPU-bound work:
Task.Run(UI apps) orParallel. - Useful tools:
Task.WhenAll,Task.WhenAny,CancellationTokenandIAsyncEnumerable.