The Task-Based Asynchronous Pattern with async and await

To address the complexity problem, Listing 20.3 also provides asynchronous execution but instead uses task-based asynchrony, which leverages an async/await feature.3 With async/await, the compiler takes care of the complexity, allowing the developer to focus on the business logic. Rather than worrying about chaining ContinueWith() statements, retrieving antecedents.Results, Unwrap() invocations, complex error handling, and the like, async/await allows you to just decorate the code with simple syntax that informs the compiler that it should handle the complexity. In addition, when a task completes and additional code needs to execute, the compiler automatically takes care of invoking the remaining code on the appropriate thread. You cannot have two different threads interacting with a single threaded UI platform, for example, and async/await addresses this issue. (See the section on the use of the async/await feature with the Windows UI later in the chapter.)

In other words, the async/await syntax tells the compiler to reorganize the code at compile time, even though it is written relatively simply, and to address the myriad of complexities that developers would otherwise have to consider explicitly—see Listing 20.3.

Listing 20.3: Asynchronous High-Latency Invocation with the Task-Based Asynchronous Pattern
using System;
using System.IO;
using System.Threading.Tasks;
 
public static class Program
{
    public static HttpClient HttpClient { getset; } = new();
    public const string DefaultUrl = "https://IntelliTect.com";
 
    public static async Task Main(string[] args)
    {
        if (args.Length == 0)
        {
            Console.WriteLine("ERROR: No findText argument specified.");
            return;
        }
        string findText = args[0];
 
        string url = DefaultUrl;
        if (args.Length > 1)
        {
            url = args[1];
            // Ignore additional parameters
        }
        Console.WriteLine(
            $"Searching for '{findText}' at URL '{url}'.");
 
        Task<byte[]> taskDownload =
            HttpClient.GetByteArrayAsync(url);
 
        Console.Write("Downloading...");
        while (!taskDownload.Wait(100))
        {
            Console.Write(".");
        }
 
        byte[] downloadData = await taskDownload;
 
        Task<int> taskSearch = CountOccurrencesAsync(
         downloadData, findText);
 
        Console.Write($"{Environment.NewLine}Searching...");
 
        while (!taskSearch.Wait(100))
        {
            Console.Write(".");
        }
 
        int textOccurrenceCount = await taskSearch;
 
        Console.WriteLine(
            @$"{Environment.NewLine}'{findText}' appears {
                textOccurrenceCount} times at URL '{url}'.");
    }
 
 
    private static async Task<int> CountOccurrencesAsync(
        byte[] downloadData, string findText)
    {
        int textOccurrenceCount = 0;
        using MemoryStream stream = new(downloadData);
        using StreamReader reader = new(stream);
 
        int findIndex = 0;
        int length = 0;
        do
        {
            char[] data = new char[reader.BaseStream.Length];
            length = await reader.ReadAsync(data);
            for (int i = 0; i < length; i++)
            {
                if (findText[findIndex] == data[i])
                {
                    findIndex++;
                    if (findIndex == findText.Length)
                    {
                        // Text was found
                        textOccurrenceCount++;
                        findIndex = 0;
                    }
                }
                else
                {
                    findIndex = 0;
                }
            }
        }
        while (length != 0);
 
        return textOccurrenceCount;
    }
}

Notice there are relatively small differences between Listing 20.1 and Listing 20.3. Furthermore, while the number of periods will vary between executions, the output should match that from Listing 20.2. This is one of the key points about the async/await pattern: It lets you write code with only minor modifications from the way you would write it synchronously.

To understand the pattern, focus first on the CountOccurrencesAsync() method and the differences from Listing 20.1. First, we change the signature of the CountOccurrences() method declaration by adding the new contextual async keyword as a modifier on the signature. Next, we return a Task<int> rather than just int. Any method decorated with the async keyword must return a valid async return type. This can be a void, Task, Task<T>, ValueTask<T>,4 or IAsyncEnumerable<T>/IasyncEnumerator<T> (as of C# 8.0).5 In this case, since the body of the method does not return any data but we still want the ability to return information about the asynchronous activity to the caller, CountOccurrencesAsync() returns Task. By returning a task, the caller has access to the state of the asynchronous invocation and the result (the int) when it completes. Next, the method name is suffixed with “Async” by convention, to indicate that it can be invoked with an await method. Finally, everywhere that we need to asynchronously wait for a task of an asynchronous method within CountOccurrencesAsync(), we include the await operator. In this case, this occurs only for the invocation of reader.ReadAsync(). Like CountOccurrencesAsync(), StreamReader.ReadAsync() is an async method with the same characteristics.

Back in Main(), the same sorts of differences occur. When we invoke the new CountOccurencesAsync() method, we do so with the await contextual keyword. In so doing, we can write code that is neutered of the explicit task complexities. When we add the await method, we can assign the result to an int (rather than a Task<int>) or assume there is no return if the invoked method returns a Task.

For emphasis, remember that the signature for CountOccurrencesAsync() is as follows:

private static async Task<int> CountOccurrencesAsync(

   byte[] downloadData, string findText)

Even though it returns a Task<int>, the await invocation of CountOccurrencesAsync() would return an int:

int textOccurrenceCount = await CountOccurrencesAsync(

   downloadData, findText);

This example shows some of the magic of the await feature: It unwraps the result from task and returns it directly.

If you wish to execute code while the asynchronous operation is executing, you can postpone the await invocation until the parallel work (writing to the console) has completed. The code prior to invoking CountOccurrencesAsync() invokes HttpClient.GetByteArrayAsync(url) similarly. Rather than assigning a byte[] and leveraging the await operator, the await invocation is also postponed while periods are written to the console in parallel.

Task<byte[]> taskDownload =

   HttpClient.GetByteArrayAsync(url);

while (!taskSearch.Wait(100)){Console.Write(“.”);}

byte[] downloadData = await taskDownload;

In addition to unwrapping the Task, the await operator will tell the C# compiler to generate code for ensuring the code following the await invocation will execute on the appropriate thread. This is a critical benefit that avoids what can be challenging defects to find.

To better explain the control flow, Figure 20.1 shows each task in a separate column along with the execution that occurs on each task.

Figure 20.1: Control flow within each task

There are a couple of important misconceptions that Figure 20.1 helps to dismiss:

Misconception #1: A method decorated with the async keyword is automatically executed on a worker thread when called. This is absolutely not true; the method is executed normally, on the calling thread, and if the implementation doesn’t await any incomplete awaitable tasks, it will complete synchronously on the same thread. The method’s implementation is responsible for starting any asynchronous work. Just using the async keyword does not change on which thread the method’s code executes. Also, there is nothing unusual about a call to an async method from the caller’s perspective; it is a method typed as returning one of the valid async return types and it is called normally. In Main(), for example, the return from CountOccurrencesAsync() is assigned a Task<int>, just like it would for a non-async method. We then await the task.
Misconception #2: The await keyword causes the current thread to block until the awaited task is completed. That is also absolutely not true. If you have no other choice except for the current thread to block until the task completes, call the Wait() method (while being careful to avoid a deadlock), as we have described in Chapter 19. The await keyword evaluates the expression that follows it, a valid async return type such as Task, Task<T>, or ValueTask<T>; adds a continuation to the resultant task; and then immediately returns control to the caller. The creation of the task has started asynchronous work; the await keyword means that the developer wants the caller of this method to continue executing its work on this thread while the asynchronous work is processed. At some point after that asynchronous work is complete, execution will resume at the point of control following the await expression.

In fact, the principal reasons why the async keyword exists in the first place are twofold. First, it makes it crystal clear to the reader of the code that the compiler will automatically rewrite the method that follows. Second, it informs the compiler that usages of the await contextual keyword in the method are to be treated as asynchronous control flow and not as an ordinary identifier.

It is possible to have an async Main method.6 As a result, Listing 20.3’s Main signature is public static async Task Main(string[] args). This allows the user of the await operator to invoke the asynchronous methods. Without an async Main() method, we would have to rely on working with valid async return types explicitly, along with explicitly waiting for the task completion before exiting the program, to avoid unexpected behavior.7

________________________________________

3. Introduced in C# 5.0.
4. Starting in C# 7.0.
5. Technically, you can also return any type that implements a GetAwaiter() method. See “Advanced Topic: Valid Async Return Types Expanded” later in the chapter.
6. Starting with C# 7.1.
7. C# 5.0 and 6.0 included a restriction that await operators couldn’t appear within exception handling catch or finally statements. However, this restriction was removed starting with C# 7.0.
{{ snackbarMessage }}
;