The associated output is essentially the same as Output 20.1, except that additional periods are expected following “Downloading…” and “Searching…”, depending on how long those operations take to execute.
When Listing 20.2 executes, it prints “Downloading…” with additional periods to the console while the page is downloading; much the same occurs with “Searching…”. The result is that instead of simply printing four periods (....) to the console, Listing 20.2 is able to continuously print periods for as long as it takes to download the file and search its text.
Unfortunately, this asynchrony comes at the cost of complexity. Interspersed throughout the code is TPL-related code that interrupts the flow. Rather than simply following the HttpClient.GetByteArrayAsync(url) call with statements that count the occurrences (invocation of an asynchronous version of CountOccurrences()), the asynchronous version of the code requires ContinueWith() statements, Unwrap() invocations for simplicity, and a complicated try/catch handling system. The details are outlined in “Advanced Topic: The Complexity of Asynchronous Requests with TPL.” Suffice it to say, you will be grateful for the task-based asynchronous pattern with async/await.2
The Complexity of Asynchronous Requests with TPL
The first ContinueWith() statement identifies what to execute after the HttpClient.GetByteArrayAsync(url). Notice that the return statement in the first ContinueWith() statement returns CountOccurrencesAsync(downloadData, findText), which returns another task, specifically Task<int>. The return type of the first ContinueWith() statement, therefore, is a Task<Task<byte>.
Hence we have the Unwrap() invocation: Without it, the antecedent in the second ContinueWith() statement is also Task<Task<byte>>, which by itself indicates the complexity. As a result, it would be necessary to call Result twice—once on the antecedent directly, and a second time on the Task<byte>.Result property that antecedent.Result returned. The latter invocation blocks subsequent execution until the GetByteArrayAsync() operation completes. To avoid the Task<Task<TResult>> structure, we preface the second invocation of ContinueWith() with a call to Unwrap(), thereby shedding the outer Task and appropriately handling any errors or cancellation requests.
The complexity doesn’t stop with Tasks and ContinueWith(), however: The exception handling adds an entirely new dimension to the complexity. As mentioned earlier, the TPL generally throws an AggregateException exception because of the possibility that an asynchronous operation could encounter multiple exceptions. However, because we are calling the Result property from within ContinueWith() blocks, potentially we might also throw an AggregateException inside the worker thread.
As you learned in earlier chapters, there are multiple ways to handle these exceptions:
We can add continuation tasks to all *Async methods that return a task along with each ContinueWith() method call. However, doing so would prevent us from using the fluid API in which the ContinueWith() statements are chained together one after the other. Furthermore, this would force us to deeply embed error-handling logic into the control flow rather than simply relying on exception handling.
We can surround each delegate body with a try/catch block so that no exceptions go unhandled from the task. Unfortunately, this approach is also less than ideal. First, some exceptions (like those triggered when calling antecedent.Result) will throw an AggregateException, from which we will need to unwrap the InnerException(s) to handle them individually. Upon unwrapping them, we either rethrow them to catch a specific type or conditionally check for the type of the exception separately from any other catch blocks (even catch blocks for the same type). Second, each delegate body requires its own separate try/catch handler, even if some of the exception types between blocks are the same. Third, Main’s call to task.Wait() could still throw an exception because HttpClient.GetByteArrayAsync() or CountOccurrencesAsync() could potentially throw an exception, and there is no way to surround the latter with a try/catch block. Therefore, there is no way to eliminate the try/catch block in Main that surrounds task.Wait().
In Listing 20.2, we don’t catch any exceptions thrown from GetByteArrayAsync() but instead rely solely on the try/catch block that surrounds Main’s task.Wait(). Given that we know the exception will be an AggregateException, there is a catch for only that exception. Within the catch block, we handle the exception by calling AggregateException.Handle() and throwing each exception using the ExceptionDispatchInfo object so as not to lose the original stack trace. These exceptions are then caught by the expected exception handlers and addressed accordingly. Notice, however, that before handling the AggregateException’s InnerExceptions, we first call AggregateException.Flatten(). This step addresses the issue of an AggregateException wrapping inner exceptions that are also of type Aggregate Exception (and so on). By calling Flatten(), we ensure that all exceptions are moved to the first level and all contained AggregateExceptions are removed.
As shown in Listing 20.2, option 3 is probably the preferred approach because it keeps the exception handling outside the control flow for the most part. This doesn’t eliminate the error-handling complexity entirely; rather, it simply minimizes the occasions on which it is interspersed within the regular control flow.
In conclusion, although the asynchronous version in Listing 20.2 has almost the same logical control flow as the synchronous version in Listing 20.1, both versions attempt to download a resource from a server. If the download succeeds, the result is returned. If the download fails, the exception’s type is interrogated to determine the right course of action. Clearly, the asynchronous version of Listing 20.2 is significantly more difficult to read, understand, and change than the corresponding synchronous version in Listing 20.1. Unlike the synchronous version, which uses standard control flow statements, the asynchronous version is forced to create multiple lambda expressions to express the continuation logic in the form of delegates.
And this is a relatively simple example, especially since we don’t list the implementation of CountOccurrencesAsync()! Imagine what the asynchronous code would look like if, for example, the synchronous code contained a loop that retried the operation three times if it failed, if it tried to contact multiple different servers, if it took a collection of resources rather than a single one, or if all of these possible features occurred together. Adding those features to the synchronous version would be straightforward, but it is not at all clear how to do so in the asynchronous version. Rewriting synchronous methods into asynchronous methods by explicitly specifying the continuation of each task gets very complicated very quickly even if the synchronous continuations are what appear to be very simple control flows.