Async/Await and Cancellation Tokens in C#

Asynchronous programming has become an essential part of modern software development, allowing applications to perform tasks concurrently without blocking the main thread. In C#, the async and await keywords simplify asynchronous code, making it more readable and maintainable. However, when dealing with long-running asynchronous operations, it's crucial to have a way to cancel them gracefully. This is where cancellation tokens come into play.

In this article, we'll explore the use async/await as well as cancellation tokens in C#.

What is Async/Await ?

Asynchronous programming allows you to write code that doesn't block the main thread while waiting for some operation to complete. These operations can include tasks such as network requests, file I/O, database queries, and more. By making your code asynchronous, you can keep your application responsive and utilize system resources more efficiently.

Basics of Async/Await

In C#, the async and await keywords work together to create asynchronous methods. Here's a brief overview of each:

  • async: This keyword is used to define a method as asynchronous. It tells the compiler that the method contains asynchronous operations.

  • await: This keyword is used within an async method to pause the method's execution until the awaited task is completed. It allows you to work with asynchronous tasks in a sequential manner.

Example 1: Simple Async/Await Method

Let's start with a straightforward example. Suppose you want to create an asynchronous method that simulates downloading a file from the internet. Here's how you can do it:

using System;
using System.Net.Http;
using System.Threading.Tasks;

async Task DownloadFileAsync()
{
    using (HttpClient client = new HttpClient())
    {
        string url = "https://example.com/file.zip";
        byte[] fileData = await client.GetByteArrayAsync(url);

        // Process the downloaded file data
        Console.WriteLine($"Downloaded {fileData.Length} bytes.");
    }
}

In this example, the DownloadFileAsync method is marked as async. Inside the method, we use await to call client.GetByteArrayAsync(url), which fetches the file asynchronously. The method then continues its execution once the download is complete.

Example 2: Running Multiple Tasks Concurrently

Async/await is particularly valuable when you need to execute multiple tasks concurrently. Consider this example where you fetch data from multiple web services and aggregate the results:

using System;
using System.Net.Http;
using System.Threading.Tasks;

async Task<string> FetchDataAsync(string apiUrl)
{
    using (HttpClient client = new HttpClient())
    {
        return await client.GetStringAsync(apiUrl);
    }
}

async Task AggregateDataAsync()
{
    string apiUrl1 = "https://api.service1.com/data";
    string apiUrl2 = "https://api.service2.com/data";

    Task<string> task1 = FetchDataAsync(apiUrl1);
    Task<string> task2 = FetchDataAsync(apiUrl2);

    // Await both tasks concurrently
    await Task.WhenAll(task1, task2);

    // Process the aggregated data
    string data1 = task1.Result;
    string data2 = task2.Result;

    // Process the aggregated data
    Console.WriteLine($"Data from Service 1: {data1}");
    Console.WriteLine($"Data from Service 2: {data2}");
}

In this example, AggregateDataAsync asynchronously fetches data from two different URLs concurrently using the Task.WhenAll() method. It awaits both tasks, and once both are complete, it processes the results.

Example 3: Exception Handling with Async/Await

Async/await also simplifies exception handling in asynchronous code. Here's an example where we catch and handle exceptions in an asynchronous method:

using System;
using System.Net.Http;
using System.Threading.Tasks;

async Task<string> FetchDataAsync(string apiUrl)
{
    using (HttpClient client = new HttpClient())
    {
        try
        {
            return await client.GetStringAsync(apiUrl);
        }
        catch (HttpRequestException ex)
        {
            // Handle the exception
            Console.WriteLine($"An error occurred: {ex.Message}");
            return null;
        }
    }
}

In this example, if an exception is thrown during the HTTP request, it's caught within the FetchDataAsync method, allowing you to handle it gracefully.

Example 4: Async/Await chain and thread switching

Consider the example below where you have a chain of async/await function calls. How will .NET spawn the threads? Will it create a new thread for each async/await call?

//Program.cs

Console.WriteLine($"Main: BeforeAwait: Thread: {Thread.CurrentThread.ManagedThreadId}");
Test t = new Test();
await t.A();
Console.WriteLine($"Main: AfterAwait: Thread: {Thread.CurrentThread.ManagedThreadId}");

public class Test
{
    public async Task<int> A()
    {
        Console.WriteLine($"A: BeforeAwait: Thread: {Thread.CurrentThread.ManagedThreadId}");
        int a = 1;
        int resp = await B(a);
        Console.WriteLine($"A: AfterAwait: Thread: {Thread.CurrentThread.ManagedThreadId}");
        return resp;
    }

    public async Task<int> B(int a)
    {
        Console.WriteLine($"B: BeforeAwait: Thread: {Thread.CurrentThread.ManagedThreadId}");
        int b = a * 2;
        int resp = await C(b);
        Console.WriteLine($"B: AfterAwait: Thread: {Thread.CurrentThread.ManagedThreadId}");
        return resp;
    }

    public async Task<int> C(int b)
    {
        Console.WriteLine($"C: BeforeAwait: Thread: {Thread.CurrentThread.ManagedThreadId}");
        int c = b + 10;
        await new HttpClient().GetAsync("https://www.google.com");
        Console.WriteLine($"C: AfterAwait: Thread: {Thread.CurrentThread.ManagedThreadId}");
        return c;
    }
}

Here's how it works - When you call an Async function, it starts executing like a regular method. As long as the async function contains synchronous code or asynchronous code that has already completed, it will execute on the same thread without suspending.

For example, setting up variables or performing calculations can be done without suspension. Even if the first async function calls another async function, the execution will continue on the same thread until the flow encounters an await for an asynchronous operation that is not yet complete (long running). The runtime doesn't suspend the entire function just because of the presence of await. It only suspends the function when it encounters an await for an asynchronous operation that is not yet complete.

Local function calls and calculations generally happen quickly and don't involve blocking I/O or waiting for external resources. These operations are considered synchronous and don't require suspension. The runtime assumes that such operations won't block the thread for an extended period, so there's no need to suspend the function.

The output of the above code will look something like this:

Main: BeforeAwait: Thread: 7
A: BeforeAwait: Thread: 7
B: BeforeAwait: Thread: 7
C: BeforeAwait: Thread: 7
C: AfterAwait: Thread: 16
B: AfterAwait: Thread: 16
A: AfterAwait: Thread: 16
Main: AfterAwait: Thread: 16

If you notice, the flow goes like this:

  1. The main function starts on thread 7. It makes an awaitable call to A().

  2. The function A() starts executing on same thread 7. It executes int a = 1; (local variable assignment) and then makes an awaitable call to B().

  3. The function B() starts executing on same thread 7. It executes int b = a * 2; and then makes an awaitable call to C().

  4. The function C() starts executing on same thread 7. It executes int c = b + 10; and then makes an HTTP call. This is where the runtime notices that the HttpClient.GetAsync() method has not completed immediately, hence, the C() function is suspended immediately, and the thread 7 is released to be used elsewhere.

  5. Once the awaited operation (HTTP request) is complete, the continuation of C() (the code that comes after the await) is scheduled to run. The continuation might run on the same thread or a different thread, depending on availability as well as other factors like synchronization context and task scheduler in use. In our case, the function resumes on Thread 16. The function C() returns reponse to B().

  6. Thereafter, B() continues its execution on the same thread 16 and returns to A() which in turn returns to the main function, all on the same thread.

As you can see, the main function, followed by A(), B() and C() all executed on the same thread 7 until the runtime encountered a long running task like Http call. That is when the current function C() was suspended. Once the Http call was complete, it was assigned another thread 16 to continue execution.

What Are Cancellation Tokens?

A cancellation token is a mechanism provided by the .NET Framework that enables the cancellation of asynchronous operations in a safe and cooperative manner. It allows you to request the cancellation of an ongoing operation and provides a means for the operation to observe and respond to the cancellation request.

Cancellation tokens are often used in scenarios where an operation may take a long time to complete, such as network requests, database queries, or other I/O-bound tasks. By using cancellation tokens, you can avoid unnecessary resource consumption and ensure that your application remains responsive.

Creating a Cancellation Token

In C#, you can create a cancellation token using the CancellationTokenSource class. Here's how you can create one:

CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

Now that we have our cancellation token, let's see how to use it in asynchronous operations.

Using Cancellation Tokens in Async/Await Operations

1. Cancellation in a Simple Async Method

Suppose you have an asynchronous method that performs a time-consuming task. You can use a cancellation token to stop the operation prematurely if needed. Here's an example:

async Task DoWorkAsync(CancellationToken token)
{
    Console.WriteLine("Starting work...");

    // Check for cancellation before performing any work
    if (token.IsCancellationRequested)
    {
        Console.WriteLine("Work canceled before starting.");
        return;
    }

    try
    {
        await Task.Delay(5000, token); // Simulating a time-consuming task

        // Check for cancellation again after the await operation
        if (token.IsCancellationRequested)
        {
            Console.WriteLine("Work canceled.");
        }
        else
        {
            Console.WriteLine("Work completed.");
        }
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Work canceled during execution.");
    }
}

// Usage:
CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

// Start the async operation
var task = DoWorkAsync(token);

// Cancel the operation after 2 seconds
Thread.Sleep(2000);
cts.Cancel();

await task; // Ensure the task is completed

In this example, we create a cancellation token token and pass it to the DoWorkAsync method. After two seconds, we cancel the operation by calling cts.Cancel(). Inside the DoWorkAsync method, we first check if the cancellation token is requested before starting any work. If it's canceled, we handle the cancellation immediately. Within the await operation, we pass the cancellation token to Task.Delay to allow it to be canceled if necessary. After the await, we again check for cancellation and handle it accordingly.

The catch (OperationCanceledException) block will catch any exceptions thrown due to cancellation during the execution of the asynchronous operation.

2. Cancellation in Parallel Async Operations

Cancellation tokens are particularly useful when working with multiple asynchronous operations in parallel. You can use them to cancel all operations when one of them signals cancellation. Here's an example:

async Task<string> DownloadAsync(string url, CancellationToken token)
{
    using (HttpClient client = new HttpClient())
    {
        var response = await client.GetAsync(url, token);
        return await response.Content.ReadAsStringAsync();
    }
}

async Task<IEnumerable<string>> DownloadUrlsAsync(List<string> urls, CancellationToken token)
{
    var downloadTasks = urls.Select(url => DownloadAsync(url, token)).ToList();

    try
    {
        return await Task.WhenAll(downloadTasks);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Downloads canceled.");
        return null;
    }
}

// Usage:
CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

List<string> urls = new List<string>
{
    "https://example.com/page1",
    "https://example.com/page2",
    "https://example.com/page3"
};

var downloadTask = DownloadUrlsAsync(urls, token);

// Cancel all downloads after 2 seconds
Thread.Sleep(2000);
cts.Cancel();

var results = await downloadTask;

In this example, we create a list of URLs to download and pass the cancellation token token to each DownloadAsync method call. We use Task.WhenAll to execute all download tasks concurrently. If any of the downloads are canceled, an OperationCanceledException is caught, and we handle it gracefully.

Conclusion

Async/await is a powerful feature in C# that enables developers to write responsive and efficient code in an elegant and maintainable way. By using these keywords, you can harness the full potential of asynchronous programming to perform tasks concurrently without blocking the main thread. Whether you're working with web requests, file I/O, or other time-consuming operations, async/await is an essential tool in your C# toolbox.

Cancellation tokens are a crucial tool when working with asynchronous operations in C#. They provide a way to cancel tasks safely and efficiently, ensuring that your application remains responsive and resource-efficient. By following the examples and guidelines in this article, you can implement cancellation tokens in your async/await operations to create more robust and user-friendly applications.