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.
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(
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 =
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.
There are a couple of important misconceptions that Figure 20.1 helps to dismiss:
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