The use of async/await launched a big boom in asynchronous programming in .NET, mainly because it is so easy to use. But not every developer knows exactly what lies beneath async/await, which can lead to unconscious errors. In this blog series of 3 parts (see the 2nd part, see the 3rd part) we will give you a look under the hood and show you the potentially dangerous place you may encounter when programming with async/await.
What is asynchronous calling and why is it good to use it?
Asynchronous programming mainly concerns threads and how they are blocked. The classic synchronous calling method involves blocking the thread throughout the entire operation, either by executing the code of the method itself or by waiting for the completion of operations taking place outside of the program (such as a database query). This means that the thread cannot do any other work during the operation. In practice, however, it is often necessary for the operation to take place somewhere in the background while the thread from which the operation was called is used to execute another part of the program. When the operation is completed, the caller is informed and can respond. It is this approach to programming that is called asynchronous programming.
Let’s differentiate between two types of asynchronous operations.
- CPU-Bound—the asynchronous operation is performed by another thread of the program; it could be, for example, a demanding long-lasting calculation
- IO-Bound—the asynchronous operation runs outside the thread and processor; for example, reading a drive or network
For desktop applications with a user interface (WinForm, WPF, UWP), both types of asynchronous operations are worthwhile. These applications usually have one thread that takes care of rendering the user interface and its interactions with the user—the UI thread. So, if we block this thread with a longer synchronous call, the application freezes and does not respond to user activity. If we use an asynchronous operation, the calling method does not block the UI thread and the user interface can further interact with the user. Once the asynchronous operation is completed, the program resumes on the UI thread.
For some types of applications, there is no main thread that shouldn’t be blocked. This is the case with web applications in ASP.NET Core and most console applications. IO-bound operations are especially useful in these cases, as they help reduce the number of threads in the application. For this type of operation, no activity is performed on another thread of the application; instead, it waits for a response from another system (such as a database). The calling thread sends the request and can also engage in other activities. When the operating system detects the completion of an asynchronous operation, it informs the program and a code is run that should follow its completion. Of course, no one is preventing you from using a CPU-bound operation with this type of application either. Sometimes it is suitable, but it is definitely less common.
The use of asynchronous calling for web applications reduces the number of threads needed to handle HTTP requests. Especially for ASP.NET Core, this improves the throughput of the webserver. Web applications very often perform operations that can be asynchronous and are typically IO-bound. An example of such an operation is working with a database or calling a web service via SOAP or REST. Each HTTP request on the server is typically handled by a single thread. If the application performs an IO-bound asynchronous operation, the thread is freed to handle the next HTTP request. Once the asynchronous operation is completed, the processing of the original request continues on another thread. The processing can even continue on the same thread, but this isn’t guaranteed or even necessary. When one thread is used for multiple requests, it reduces the total number of threads needed to run the server.
As you can gather from the previous paragraphs, different types of applications have different requirements for how to continue the program after the asynchronous operation is completed. Sometimes it doesn’t matter what kind of thread it is, sometimes it should be a UI thread, etc. For now, it will suffice that the asynchronous call solution in C# has been designed very robustly and allows each type of application to specify its own behaviour regarding planning the continuation of the program. We will explain these mechanisms later.
Asynchronous calling via an async/await constructor
Support for asynchronous programming in .NET isn’t anything new. Previously, solutions such as the IAsyncResult interface or the event-based asynchronous pattern were preferred. In .NET 4.0, the Task Parallel Library (TPL) was added, which has been the preferred option for asynchronous operations in .NET ever since. In TPL, an asynchronous operation represents an instance of the Task class or the Task<T> class. The latter is used when the operation returns a type T return value. Subsequently, in C# 5, the async/await pattern was added, which has greatly simplified working with TPL, making asynchronous programming almost as simple as writing synchronous code. Now let’s take a look at how async/await works.
public async Task<int> DoSomethingAsync(int argument) { //asynchronous operation Data data = await databaseRepository.ReadDataAsync(argument); Data transformedData = transformData(data); //asynchronous operation await databaseRepository.SaveDataAsync(transformedData); return data.Id; }
The example shows a sample method that retrieves data from a database and modifies the data in some way (using the transformData method). It then saves the data back to the database and returns the ID of the read data. If you haven’t encountered async/await yet, you’ll definitely be impressed by the keyword async, the operator await and the return type Task<int>. First, we’ll focus on the type Task. Each asynchronous operation in the TPL is represented by an instance of this class, more precisely by one of its variants. If an asynchronous operation doesn’t return any results, its return type is Task. If it has a return value, its return type is Task<T>, where T is the data type of the result. This class contains information on the status of the asynchronous operation, especially the following.
- Whether the operation is already completed
- The return value of the operation
- Any exceptions thrown by the asynchronous operation
You especially need to be aware of the last point. If the asynchronous operation throws an exception, that exception is stored in a property of the Task class object.
Now let’s focus on the keyword async, which is located in the method declaration. This keyword tells the compiler that the method contains at least one asynchronous call. In the example, there are two asynchronous calls: ReadDataAsync and SaveDataAsync. The keyword async can also be attributed to a method that does not contain any asynchronous calls. The compiler will deal with this but will warn you that the async keyword is being used unnecessarily in the declaration. If async is specified for the method, the compiler automatically compiles the method to return a Task object, which will be marked as completed only after the entire method has been executed. Also, take a look at line 11. Here, using return, it directly returns an ID that could be int. The wrapper for the Task object is again automatically provided by the compiler.
The last thing left to explain is the await operator. It can be understood as a unary operator that is placed before a type Task or Task<T> object. This operator ensures that the program will wait for the operation represented by the given object to complete. Later, you will see that await can also be applied to types other than Task, but so far, we have only needed Task.
It is sometimes incorrectly stated that await initiates an asynchronous call. But that is not true. Let’s take a look at line 4. This line is executed in the following order.
- The ReadDataAsync method of the databaseRepository object is called. This method returns Task <T>, which represents an incomplete asynchronous operation. This method is called synchronously until the await operator is first used inside ReadDataAsync. The ReadDataAsync method, therefore, does not return the read data but only an object representing a running operation that will return the data in the future.
- The await operator now ensures that the program waits asynchronously for the operation returned by the ReadDataAsync method call to complete.
- When the asynchronous operation is completed, it assigns the retrieved data to the variable data.
In other words, the await operator waits for the asynchronous operation to complete, unpacks a return value from the Task object and throws it again if the Task contains an exception. This way of working with exceptions ensures that even if an exception is thrown by a CPU-bound asynchronous operation on another thread, it will be possible to catch it directly in the method that triggered the asynchronous operation. A big advantage of this pattern is that asynchronous calls can be wrapped with try/catch or using blocks, as you are accustomed to.
Now, you may be wondering what happens to an asynchronous call when you wait for an outcome with an await immediately after creating an asynchronous operation. The answer is that await performs waits asynchronously. Simply put, the moment the await operator is used on the Task, the execution of the method ends on the thread and the thread is available to do something else (render a UI, another HTTP request, etc.). Only when the operation completes is the execution of the remaining method code continued. In our case, the assignment is performed on line 4 and then the transformation on line 6, etc.
Since await is not directly tied to a method call, there are other ways it can be used. For example, we can use the await operator later and execute some additional code in the method while waiting for the asynchronous operation to complete. Take a look at the following example.
public async Task<int> DoSomethingAsync(int argument) { Task<Data> dataTask = databaseRepository.ReadDataAsync(argument); DoSomethingElse(); Data data = await dataTask; Data transformedData = TransformData(data); await databaseRepository.SaveDataAsync(transformedData); return data.Id; }
Line 3 starts an asynchronous operation that will read the database. At this point, however, we are not interested in the result because we do not need it yet. While the database is retrieving data, the thread executes the DoSomethingElse method. Once this method is complete, it looks at the status of the asynchronous dataTask operation with the await operator. If it has already run, it picks up its result and assigns it to the variable data. If the operation is still running, it waits asynchronously and performs the assignment once it is completed.
But here, we must be aware of one risk. If the DoSomethingElse method throws an exception, the await dataTask will never occur. The method runs out, but nothing catches the exception. If you are already writing such code, consider using, for example, a try/finally block to ensure await even if there is an exception.
To give you a better understanding of how async/await works, we’ll show you the previous example created with TPL without these keywords. The example isn’t entirely equivalent for the sake of simplicity, but it does roughly the same thing. They differ mainly in the handling of exceptions, the correct handling of which would make the code very obscure. To be thorough, it is also worth noting that the C# compiler uses a different technique to compile methods with the async keyword, which we will discuss in the next article.
public Task<int> DoSomethingAsync(int argument) { // variable for storing the results of reading the DB Data data = null; // we get a Task object representing the operation for reading the DB Task<Data> dataTask = databaseRepository.ReadDataAsync(argument); // we will perform other activities that we want to perform before the dataTask expires DoSomethingElse(); // we set what should happen when the dataTask expires Task<int> task = dataTask.ContinueWith(t => { // we retrieve the result of the asynchronous operation data = t.Result; Data transformedData = TransformData(data); // we return the Task representing the data storage Task saveDataTask = return databaseRepository.SaveDataAsync(transformedData); return saveDataTask; }) .Unwrap().ContinueWith(_ => { // sets what should happen when the data is saved return data.Id; }); // we return a Task representing the entire asynchronous operation return task; }
In the example, you can easily see that the DoSomethingAsync method synchronously starts calling ReadDataAsync and then executes DoSomethingElse. Then it just constructs an object of type Task, which describes what should happen after the asynchronous operation is completed and returns this object to the caller. The caller can use the await operator or, for example, the ContinueWith method over this object. It is no problem to combine these approaches, if necessary, which is another nice feature of TPL and async/await. Nevertheless, you will certainly recognize that in most cases using async/await is significantly more readable and less prone to errors.
Synchronous asynchronous calls
You can also wait synchronously to complete an operation represented by a Task class object. Sometimes this can be a temporary solution for debugging or a solution if you need to use an asynchronous method in a method that is not marked as async and would be too complicated to implement. In general, however, this is not good practice, as you will lose the main benefit of a non-blocking operation. Such a call is more performance-intensive because synchronous waiting for a Task requires some overhead with synchronization. Additionally, in some cases, a deadlock may occur—we’ll talk more about that later. Most good libraries offer both synchronous and asynchronous APIs (usually with the Async suffix in the name). But, if you cannot avoid synchronous waiting for a Task to complete, there are several ways to do it.
// for a Task with or without a return value SomeOperationAsync().Wait(); // for Tasks with a return value var result = SomeOperationAsync().Result; // for a Task with or without a return value SomeOperationAsync().GetAwaiter().GetResult();
As can be seen from the example, the Wait method can be called on each Task object. If the Task is not yet complete, this method blocks the thread until the Task finishes running. If you want to get a return value from a Task<T> class instance, you can find it in the Result property. Reading this property before the asynchronous operation finishes running will also cause a block until the operation completes. However, there is one annoying thing about these two options. If an exception is caught in the Task object, it is automatically thrown again when calling Wait and reading Result. Nevertheless, the exception type will not be the one thrown in the asynchronous method, but it will be wrapped in an AggregateException. This is because the Task class is designed to be very robust for parallel processing. For example, you can create a Task that completes at the moment when two parallel asynchronous operations finish running. Those who are interested can look up the Task.WhenAll method for more details. Thanks to this feature, more exceptions from individual parallel operations can be stored in the Task. Therefore, they are wrapped in a special type that contains all these exceptions. If you forget this fact in your code, it can easily happen that you won’t catch a specific exception because a different type than expected was thrown.
The third option solves this problem to some extent. If there is only one exception in the Task, it is expanded from the AggregateException and thrown. This is because the GetAwaiter method is specifically designed to work with async/await where, in most cases, multiple Tasks are not merged. Thus, async/await behaves more logically from a programmer’s point of view and only uses AggregateException where it has to. We’ll talk more about awaiters later.
Yet again, I ask you to avoid blocking asynchronous code. Ideally, keep the entire application asynchronous. You will avoid a lot of unexpected and insidious problems.
And that’s all for part 1. In the second part, we’ll take a look at how it all works under the hood. In the third part, we’ll take a look at the most common mistakes when using async/await.
Author: Petr Haljuk
Fullstack developer