Post

Exploring Common Async/Await Patterns in C#

Asynchronous programming allows you to write efficient and responsive code in C#. By using the async and await keywords, you can perform tasks without blocking the main thread and improve the performance of your applications. However, to master asynchronous programming, you need to understand the common async/await patterns and how to apply them in different scenarios. In this blog post, we will explore some of the most useful async/await patterns and show you how to use them in your C# projects.

This is continuation of my blog on Asynchronous Programming in C# with async/await. You can take a look at that blog post to understand what is Asynchronous Programming in C#. Anyways let me start with the basic introduction.

The Basics of Async/Await

The async and await keywords are the core of asynchronous programming in C#. They enable you to write asynchronous code that looks like synchronous code, but without the drawbacks of blocking the main thread.

The async keyword is used to mark a method as asynchronous. This means that the method can contain one or more await expressions, which indicate that the method will pause its execution until the awaited task is completed. The async keyword also implies that the method will return a Task or a Task object, which represents the asynchronous operation.

The await keyword is used to wait for an asynchronous operation to finish. When you await a task, the compiler generates code that will resume the execution of the method after the task is completed. The await keyword also unwraps the result of the task, so you can access it directly.

Asynchronous programming is crucial for scenarios where you need to perform I/O-bound or long-running operations, such as reading from a file, querying a database, or downloading data from a web service. These operations can take a long time to complete and block the main thread if done synchronously, resulting in poor user experience and reduced performance. By using async and await, you can perform these operations without blocking the main thread and free up resources for other tasks.

To illustrate the difference between synchronous and asynchronous code execution, consider the following example:

1
2
3
4
5
6
7
8
9
// Synchronous code
Console.WriteLine("Starting synchronous operation...");
DoSomething(); // This method takes 10 seconds to complete
Console.WriteLine("Synchronous operation completed.");

// Asynchronous code
Console.WriteLine("Starting asynchronous operation...");
await DoSomethingAsync(); // This method takes 10 seconds to complete
Console.WriteLine("Asynchronous operation completed.");

In the synchronous code, the DoSomething method blocks the main thread for 10 seconds, preventing any other code from running until it finishes. In contrast, in the asynchronous code, the await DoSomethingAsync method returns a task and releases the main thread immediately, allowing other code to run while waiting for the task to complete. After 10 seconds, when the task is completed, the execution of the method resumes and prints “Asynchronous operation completed.”.

Common Use Cases for Async/Await

There are many scenarios where async/await patterns are beneficial for writing efficient and responsive code. Some of the most common use cases are:

  • I/O-bound operations: These are operations that involve reading or writing data from external sources, such as files, databases, web services, etc. These operations are usually slow and unpredictable, depending on factors such as network latency, disk speed, server load, etc. By using async and await, you can perform these operations without blocking the main thread and improve the responsiveness of your application.
  • Long-running CPU-bound operations: These are operations that involve intensive computations or processing, such as complex calculations, image processing, encryption, etc. These operations can consume a lot of CPU resources and take a long time to complete. By using async and await, you can offload these operations to a background thread pool and avoid blocking the main thread. You can also use parallel processing techniques to speed up these operations by distributing them across multiple cores or machines.
  • Parallel processing: This is a technique that allows you to execute multiple tasks simultaneously by using multiple threads or processes. This can improve the performance of your application by utilizing all available resources and reducing the overall execution time. By using async and await, you can simplify parallel processing by using high-level abstractions such as Task Parallel Library (TPL) or Parallel LINQ (PLINQ).

Let’s look at some real-world examples for each use case.

I/O-bound Operations

One of the most common examples of I/O-bound operations is reading or writing data from a file. For instance, suppose you want to read a large text file line by line and process each line somehow. If you use synchronous code, your application will block until each line is read from the disk, which can be very slow and inefficient. A better approach is to use asynchronous code, which will allow your application to continue doing other work while waiting for each line to be read from the disk.

Here is an example of how you can use async and await to read a file asynchronously:

1
2
3
4
5
6
7
8
9
10
// Read a file asynchronously line by line
using (var reader = new StreamReader("data.txt"))
{
    string line;
    while ((line = await reader.ReadLineAsync()) != null)
    {
        // Process each line
        Console.WriteLine(line);
    }
}

Notice how the ReadLineAsync method returns a Task object, which represents the asynchronous operation of reading a line from the file. By using the await keyword, we can wait for the task to complete and get the result of the operation, which is the line itself. The while loop will continue until there are no more lines to read from the file.

Another example of I/O-bound operations is querying a database. For instance, suppose you want to query a database for some data and display it on a user interface. If you use synchronous code, your application will block until the query is executed and the data is retrieved, which can cause your user interface to freeze and become unresponsive. A better approach is to use asynchronous code, which will allow your application to update the user interface while waiting for the query to complete.

Here is an example of how you can use async and await to query a database asynchronously:

1
2
3
4
5
6
7
8
9
10
11
12
// Query a database asynchronously
using (var connection = new SqlConnection(connectionString))
{
    await connection.OpenAsync();
    var command = new SqlCommand(query, connection);
    var reader = await command.ExecuteReaderAsync();
    while (await reader.ReadAsync())
    {
        // Display each row
        Console.WriteLine(reader[0]);
    }
}

Notice how the OpenAsync, ExecuteReaderAsync, and ReadAsync methods return Task or Task objects, which represent the asynchronous operations of opening the connection, executing the query, and reading the data. By using the await keyword, we can wait for each operation to complete and get the result of the operation, such as the reader object. The while loop will continue until there are no more rows to read from the data.

Long-running CPU-bound Operations

One of the most common examples of long-running CPU-bound operations is performing complex calculations or processing. For instance, suppose you want to calculate the factorial of a large number. If you use synchronous code, your application will block until the calculation is done, which can take a long time and consume a lot of CPU resources. A better approach is to use asynchronous code, which will allow your application to perform other tasks while waiting for the calculation to finish.

Here is an example of how you can use async and await to calculate the factorial of a large number asynchronously:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Calculate the factorial of a large number asynchronously
public static async Task<BigInteger> FactorialAsync(int n)
{
    // Use Task.Run to offload the calculation to a background thread pool
    return await Task.Run(() =>
    {
        BigInteger result = 1;
        for (int i = 1; i <= n; i++)
        {
            result *= i;
        }
        return result;
    });
}

// Call the FactorialAsync method and display the result
var result = await FactorialAsync(100000);
Console.WriteLine(result);

Notice how we use Task.Run to offload the calculation to a background thread pool. This way, we avoid blocking the main thread and free up resources for other tasks. The Task.Run method returns a Task object, which represents the asynchronous operation of calculating the factorial. By using the await keyword, we can wait for the task to complete and get the result of the operation, which is the factorial itself.

Another example of long-running CPU-bound operations is processing images or videos. For instance, suppose you want to apply some filters or effects to an image or a video. If you use synchronous code, your application will block until the processing is done, which can take a long time and consume a lot of CPU resources. A better approach is to use asynchronous code, which will allow your application to show progress or feedback while waiting for the processing to finish.

Here is an example of how you can use async and await to process an image asynchronously:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Process an image asynchronously
public static async Task<Bitmap> ProcessImageAsync(Bitmap image)
{
    // Use Task.Run to offload the processing to a background thread pool
    return await Task.Run(() =>
    {
        // Apply some filters or effects to the image
        var processedImage = new Bitmap(image);
        // ...
        return processedImage;
    });
}

// Call the ProcessImageAsync method and display the result
var image = new Bitmap("image.jpg");
var processedImage = await ProcessImageAsync(image);
pictureBox.Image = processedImage;

Notice how we use Task.Run to offload the processing to a background thread pool. This way, we avoid blocking the main thread and free up resources for other tasks. The Task.Run method returns a Task object, which represents the asynchronous operation of processing the image. By using the await keyword, we can wait for the task to complete and get the result of the operation, which is the processed image itself.

Parallel Processing

One of the most common examples of parallel processing is performing multiple tasks simultaneously by using multiple threads or processes. For instance, suppose you want to download multiple files from a web service. If you use synchronous code, your application will block until each file is downloaded, which can be very slow and inefficient. A better approach is to use asynchronous code, which will allow your application to download multiple files in parallel and improve the performance of your application.

Here is an example of how you can use async and await to download multiple files in parallel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Download multiple files in parallel
public static async Task DownloadFilesAsync(string[] urls)
{
    // Create a list of tasks to store the download tasks
    var tasks = new List<Task<byte[]>>();

    // Loop through each url and create a download task
    foreach (var url in urls)
    {
        // Use HttpClient to download the file asynchronously
        var client = new HttpClient();
        var task = client.GetByteArrayAsync(url);

        // Add the task to the list of tasks
        tasks.Add(task);
    }

    // Wait for all the tasks to complete
    await Task.WhenAll(tasks);

    // Loop through each task and save the file
    for (int i = 0; i < tasks.Count; i++)
    {
        // Get the result of the task, which is the file content
        var content = await tasks[i];

        // Save the file to disk
        File.WriteAllBytes($"file{i}.bin", content);
    }
}

// Call the DownloadFilesAsync method with some urls
var urls = new string[]
{
    "https://example.com/file1",
    "https://example.com/file2",
    "https://example.com/file3"
};
await DownloadFilesAsync(urls);

Notice how we use HttpClient to download the file asynchronously. The GetByteArrayAsync method returns a Task<byte[]> object, which represents the asynchronous operation of downloading the file. By using the await keyword, we can wait for the operation to complete and get the result of the operation, which is the file content.

Notice also how we use Task.WhenAll to wait for all the tasks to complete. This method returns a Task object, which represents the asynchronous operation of waiting for all the tasks. By using the await keyword, we can wait for this operation to complete and proceed with saving the files.

Another example of parallel processing is processing a large dataset by using multiple cores or machines. For instance, suppose you want to perform some analysis or transformation on a large collection of data. If you use synchronous code, your application will process each item sequentially, which can be very slow and inefficient. A better approach is to use asynchronous code, which will allow your application to process multiple items in parallel and improve the performance of your application.

Here is an example of how you can use async and await to process a large dataset in parallel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Process a large dataset in parallel
public static async Task ProcessDataAsync(IEnumerable<Data> data)
{
    // Create a list of tasks to store the processing tasks
    var tasks = new List<Task>();

    // Loop through each data item and create a processing task
    foreach (var item in data)
    {
        // Use Task.Run to offload the processing to a background thread pool
        var task = Task.Run(() =>
        {
            // Perform some analysis or transformation on the data item
            var result = ProcessItem(item);
            // ...
            return result;
        });

        // Add the task to the list of tasks
        tasks.Add(task);
    }

    // Wait for all the tasks to complete
    await Task.WhenAll(tasks);

    // Loop through each task and display the result
    foreach (var task in tasks)
    {
        // Get the result of the task, which is the processed data item
        var result = await task;

        // Display the result
        Console.WriteLine(result);
    }
}

// Call the ProcessDataAsync method with some data
var data = GetData();
await ProcessDataAsync(data);

Notice how we use Task.Run to offload the processing to a background thread pool. This way, we avoid blocking the main thread and free up resources for other tasks. The Task.Run method returns a Task object, which represents the asynchronous operation of processing the data item. By using the await keyword, we can wait for the operation to complete and get the result of the operation, which is the processed data item.

Notice also how we use Task.WhenAll to wait for all the tasks to complete. This method returns a Task object, which represents the asynchronous operation of waiting for all the tasks. By using the await keyword, we can wait for this operation to complete and proceed with displaying the results.

Fire-and-Forget Pattern

The fire-and-forget pattern is a pattern where you fire off an asynchronous operation without waiting for its completion. This means that you do not use the await keyword to wait for the task to finish, and you do not care about the result or the status of the operation.

The fire-and-forget pattern is appropriate for situations where you want to perform a background operation that does not affect the main flow of your application, such as logging, auditing, or sending notifications. However, this pattern also has some potential pitfalls that you need to be aware of, such as:

  • You cannot handle any exceptions that may occur in the fire-and-forget operation. If an exception is thrown in the task, it will be swallowed and ignored, unless you attach a continuation or a handler to the task.
  • You cannot cancel the fire-and-forget operation. Once you start the task, it will run until it finishes or fails, regardless of whether you need it or not.
  • You cannot monitor the progress or the status of the fire-and-forget operation. You have no way of knowing if the task is running, completed, or failed, unless you use some external mechanism to communicate with the task.

Here is an example of how you can use the fire-and-forget pattern to log some information asynchronously:

1
2
3
4
5
6
7
8
9
// Log some information asynchronously
public static async Task LogAsync(string message)
{
    // Use File.AppendAllTextAsync to write the message to a log file asynchronously
    await File.AppendAllTextAsync("log.txt", message + Environment.NewLine);
}

// Call the LogAsync method without awaiting it
LogAsync("Some information");

Notice how we do not use the await keyword to wait for the LogAsync method to complete. This means that we fire off the logging operation and continue with the main flow of our application, without blocking or waiting for the operation to finish.

However, this also means that we cannot handle any exceptions that may occur in the logging operation. For instance, if the log file is locked by another process, or if there is not enough disk space, an exception will be thrown in the task and ignored. To avoid this, we can attach a continuation or a handler to the task, such as:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Call the LogAsync method without awaiting it
LogAsync("Some information")
    // Attach a continuation to handle any exceptions
    .ContinueWith(t =>
    {
        // Check if the task has faulted
        if (t.IsFaulted)
        {
            // Get the exception from the task
            var exception = t.Exception;

            // Handle or report the exception
            Console.WriteLine(exception);
        }
    });

Asynchronous Streams

Asynchronous streams are a feature introduced in C# 8.0 that allows you to create and consume streams of data asynchronously. A stream is a sequence of data that can be read or written incrementally, such as a file, a network connection, or a collection.

Asynchronous streams are useful for scenarios where you need to process large amounts of data efficiently and responsively, such as reading from a database, downloading data from a web service, or processing a large dataset.

To create an asynchronous stream, you need to use the async and yield keywords. The async keyword indicates that the method is asynchronous and returns an IAsyncEnumerable object, which represents the asynchronous stream. The yield keyword allows you to return each element of the stream asynchronously, using the yield return statement.

Here is an example of how you can create an asynchronous stream that generates some random numbers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Create an asynchronous stream that generates some random numbers
public static async IAsyncEnumerable<int> GenerateRandomNumbersAsync(int count)
{
    // Create a random number generator
    var random = new Random();

    // Loop through the count parameter
    for (int i = 0; i < count; i++)
    {
        // Generate a random number
        var number = random.Next(1, 100);

        // Return the number asynchronously
        yield return number;

        // Simulate some delay
        await Task.Delay(1000);
    }
}

Notice how we use the async keyword to mark the method as asynchronous and return an IAsyncEnumerable object. We also use the yield return statement to return each random number asynchronously. We use Task.Delay to simulate some delay between each number generation.

To consume an asynchronous stream, you need to use the await foreach statement. This statement allows you to iterate over each element of the stream asynchronously, using the await keyword.

Here is an example of how you can consume the asynchronous stream that we created above:

1
2
3
4
5
6
// Consume the asynchronous stream that generates some random numbers
await foreach (var number in GenerateRandomNumbersAsync(10))
{
    // Display each number
    Console.WriteLine(number);
}

Notice how we use the await foreach statement to iterate over each element of the stream asynchronously. We also use the await keyword to wait for each element to be generated and returned.

Asynchronous Initialization

Asynchronous initialization is a pattern where an object’s setup involves asynchronous operations. This means that the object’s constructor or initializer cannot complete synchronously, and requires some additional steps to finish the initialization process.

Asynchronous initialization is relevant for scenarios where you need to perform some I/O-bound or long-running operations during the object’s creation, such as initializing database connections, HTTP clients, or configuration settings.

To implement asynchronous initialization, you need to use a factory method or a builder pattern. A factory method is a static method that creates and returns an instance of a class asynchronously. A builder pattern is a class that provides a fluent interface for creating and configuring an instance of another class asynchronously.

Here is an example of how you can use a factory method to implement asynchronous initialization for a class that represents a database connection:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// A class that represents a database connection
public class DatabaseConnection
{
    // A private constructor that accepts a connection string
    private DatabaseConnection(string connectionString)
    {
        ConnectionString = connectionString;
    }

    // A public property that exposes the connection string
    public string ConnectionString { get; }

    // A public property that exposes the connection state
    public ConnectionState State { get; private set; }

    // A public method that opens the connection asynchronously
    public async Task OpenAsync()
    {
        // Simulate opening the connection
        await Task.Delay(5000);
        State = ConnectionState.Open;
    }

    // A public method that closes the connection asynchronously
    public async Task CloseAsync()
    {
        // Simulate closing the connection
        await Task.Delay(5000);
        State = ConnectionState.Closed;
    }

    // A static factory method that creates and returns an instance of DatabaseConnection asynchronously
    public static async Task<DatabaseConnection> CreateAsync(string connectionString)
    {
        // Create an instance of DatabaseConnection using the private constructor
        var connection = new DatabaseConnection(connectionString);

        // Open the connection asynchronously
        await connection.OpenAsync();

        // Return the connection
        return connection;
    }
}

Notice how we use a private constructor to prevent direct instantiation of DatabaseConnection. We also use a static factory method called CreateAsync to create and return an instance of DatabaseConnection asynchronously. The factory method uses the private constructor to create the instance and then calls the OpenAsync method to open the connection asynchronously.

Here is an example of how you can use a builder pattern to implement asynchronous initialization for a class that represents an HTTP client:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// A class that represents an HTTP client
public class HttpClient
{
    // A private constructor that accepts a base address and a timeout
    private HttpClient(string baseAddress, TimeSpan timeout)
    {
        BaseAddress = baseAddress;
        Timeout = timeout;
    }

    // A public property that exposes the base address
    public string BaseAddress { get; }

    // A public property that exposes the timeout
    public TimeSpan Timeout { get; }

    // A public method that sends an HTTP request asynchronously
    public async Task<HttpResponse> SendAsync(HttpRequest request)
    {
        // Simulate sending the request and receiving the response
        await Task.Delay(1000);
        var response = new HttpResponse();
        // ...
        return response;
    }

    // A class that represents a builder for HttpClient
    public class Builder
    {
        // A private field that stores the base address
        private string _baseAddress;

        // A private field that stores the timeout
        private TimeSpan _timeout;

        // A public method that sets the base address
        public Builder WithBaseAddress(string baseAddress)
        {
            _baseAddress = baseAddress;
            return this;
        }

        // A public method that sets the timeout
        public Builder WithTimeout(TimeSpan timeout)
        {
            _timeout = timeout;
            return this;
        }

        // A public method that creates and returns an instance of HttpClient asynchronously
        public async Task<HttpClient> BuildAsync()
        {
            // Validate the base address and the timeout
            if (string.IsNullOrEmpty(_baseAddress))
            {
                throw new ArgumentException("Base address cannot be null or empty.");
            }
            if (_timeout <= TimeSpan.Zero)
            {
                throw new ArgumentException("Timeout must be positive.");
            }

            // Create an instance of HttpClient using the private constructor
            var client = new HttpClient(_baseAddress, _timeout);

            // Perform some additional initialization asynchronously
            await Task.Delay(1000);
            // ...

            // Return the client
            return client;
        }
    }
}

Notice how we use a private constructor to prevent direct instantiation of HttpClient. We also use a nested class called Builder to provide a fluent interface for creating and configuring an instance of HttpClient asynchronously. The Builder class has methods to set the base address and the timeout, and a method called BuildAsync to create and return the instance asynchronously. The BuildAsync method uses the private constructor to create the instance and then performs some additional initialization asynchronously.

Error Handling in Async Code

Error handling is an important aspect of asynchronous programming, as you need to deal with exceptions that may occur in your async code. Exceptions in async code can be handled in a similar way as in synchronous code, by using try-catch blocks. However, there are some differences and best practices that you need to be aware of, such as:

  • You need to use await when calling asynchronous methods that may throw exceptions. If you do not use await, the exception will not be caught by your try-catch block, but will be propagated to the caller of your async method.
  • You need to use try-catch blocks in both the caller and the callee of asynchronous methods. If you only use try-catch blocks in the callee, the exception will be wrapped in an AggregateException object and rethrown by the task. If you only use try-catch blocks in the caller, you will not be able to handle any exceptions that occur before the task is returned by the callee.
  • You need to use ConfigureAwait(false) when calling asynchronous methods from a library or a non-UI context. This will prevent your async code from capturing and resuming on the original synchronization context, which may cause deadlocks or performance issues.

Here is an example of how you can use try-catch blocks in async methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// An asynchronous method that may throw an exception
public static async Task<int> DivideAsync(int x, int y)
{
    try
    {
        // Simulate some delay
        await Task.Delay(1000);

        // Perform some validation
        if (y == 0)
        {
            throw new DivideByZeroException();
        }

        // Return the result of the division
        return x / y;
    }
    catch (Exception ex)
    {
        // Handle or log the exception
        Console.WriteLine(ex);

        // Rethrow the exception or return a default value
        throw;
    }
}

// An asynchronous method that calls DivideAsync and handles its exception
public static async Task TestDivideAsync(int x, int y)
{
    try
    {
        // Call DivideAsync and await its result
        var result = await DivideAsync(x, y);

        // Display the result
        Console.WriteLine(result);
    }
    catch (Exception ex)
    {
        // Handle or log the exception
        Console.WriteLine(ex);
    }
}

Notice how we use await when calling DivideAsync from TestDivideAsync. This way, we can catch any exception that may occur in the DivideAsync method. This way, we can handle any exception that may occur in the division operation.

Notice also how we use try-catch blocks in both the DivideAsync and TestDivideAsync methods. This way, we can handle or log the exception in the callee, and rethrow or handle the exception in the caller.

Notice also how we use ConfigureAwait(false) when calling Task.Delay from DivideAsync. This way, we prevent our async code from capturing and resuming on the original synchronization context, which may be unnecessary or harmful in a library or a non-UI context.

Cancellation Tokens

Cancellation tokens are a feature that allows you to cancel ongoing asynchronous operations. Cancellation tokens are useful for scenarios where you need to abort an asynchronous operation that is no longer needed or desired, such as when the user cancels a request, when a timeout occurs, or when an error happens.

To use cancellation tokens, you need to use the CancellationToken and CancellationTokenSource classes. A CancellationToken is a struct that represents a cancellation request. A CancellationTokenSource is a class that creates and controls a CancellationToken. You can use the CancellationTokenSource to cancel the CancellationToken, and pass the CancellationToken to the asynchronous methods that support cancellation.

Here is an example of how you can use cancellation tokens to cancel an asynchronous operation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Create a cancellation token source
var cts = new CancellationTokenSource();

// Get the cancellation token from the source
var ct = cts.Token;

// Start an asynchronous operation that supports cancellation
var task = DoSomethingAsync(ct);

// Cancel the cancellation token after some time
cts.CancelAfter(5000);

// Wait for the task to complete or cancel
try
{
    // Await the task and get its result
    var result = await task;

    // Display the result
    Console.WriteLine(result);
}
catch (OperationCanceledException ex)
{
    // Handle or log the cancellation exception
    Console.WriteLine(ex);
}

Notice how we create a CancellationTokenSource and get its CancellationToken. We then pass the CancellationToken to the DoSomethingAsync method, which supports cancellation. We also use the CancelAfter method to cancel the CancellationToken after some time.

Notice also how we use a try-catch block to handle the OperationCanceledException that may be thrown by the task if it is canceled. We can also check the IsCanceled property of the task or the token to determine if the operation was canceled.

Wrapping Up and Best Practices

In this blog post, we have explored some of the common async/await patterns and how to use them in your C# projects. We have seen how async and await can help you write efficient and responsive code for scenarios such as I/O-bound operations, long-running CPU-bound operations, parallel processing, fire-and-forget operations, asynchronous streams, asynchronous initialization, error handling, and cancellation.

To wrap up, here are some of the best practices for writing clean and maintainable asynchronous code:

  • Use async and await consistently and correctly. Always use await when calling asynchronous methods that may throw exceptions or return results. Always mark your methods as async if they contain await expressions or return tasks.
  • Use ConfigureAwait(false) when calling asynchronous methods from a library or a non-UI context. This will prevent your async code from capturing and resuming on the original synchronization context, which may cause deadlocks or performance issues.
  • Use Task.Run to offload long-running CPU-bound operations to a background thread pool. This will avoid blocking the main thread and free up resources for other tasks.
  • Use Task.WhenAll or Task.WhenAny to wait for multiple tasks to complete or complete first. This will allow you to perform parallel processing or handle multiple results efficiently.
  • Use IAsyncEnumerable and await foreach to create and consume asynchronous streams. This will allow you to process large amounts of data incrementally and responsively.
  • Use factory methods or builder patterns to implement asynchronous initialization. This will allow you to perform some I/O-bound or long-running operations during the object’s creation.
  • Use try-catch blocks in both the caller and the callee of asynchronous methods. This will allow you to handle or propagate exceptions in your async code properly.
  • Use cancellation tokens to cancel ongoing asynchronous operations. This will allow you to abort an asynchronous operation that is no longer needed or desired.

Conclusion

Asynchronous programming is a powerful technique that allows you to write efficient and responsive code in C#. By using the async and await keywords, you can perform tasks without blocking the main thread and improve the performance of your applications. However, to master asynchronous programming, you need to understand the common async/await patterns and how to apply them in different scenarios.

I hope that this blog post has helped you learn more about common async/await patterns in C# and how to use them in your projects. Asynchronous programming is one of the most important skills for modern software development, as it enables you to create fast and scalable applications that can handle complex and concurrent scenarios. By using common async/await patterns, you can write clean and maintainable asynchronous code.

Happy Coding!

This post is licensed under CC BY 4.0 by the author.