In this part, we’ll go over the more advanced uses of asynchronous programming with async/await in C# and take a look under the hood to see how it all works.
What exactly is an asynchronous method?
So far, we’ve shown you how to create an asynchronous method that calls other asynchronous methods. This is the most common case in practice, but you have surely wondered how to create a method that performs a real asynchronous operation. We’ll start with how to implement a CPU-bound asynchronous method. In this case, the TPL offers several options with different levels of settings. The common denominator, in this case, is the lambda method. Simply put, you pass the TPL a lambda or a delegate with the code that you want to execute asynchronously, and the TPL returns a Task that will be completed when the code finishes running.
Task.Run is basically a wrapped Task.Factory.StartNew call with commonly used parameters. Additionally, it even contains an overload that allows you to specify an asynchronous lambda method: that is, a lambda that uses await and returns Task. See the documentation for details on the StartNew setting. To describe it here would be beyond the scope of this article. We will focus more on TaskScheduler later.
If you are creating an IO-bound operation, in most cases, you will use a pre-existing asynchronous .NET API (to work with the network, file system, etc.). But we can describe the principle underlying IO-bound operations. At the lowest level of the .NET API, in most cases, these are operating system API calls. A callback is written to the system. The callback is basically a delegate that is called when the operating system detects the completion of an asynchronous operation. This callback then changes the status of Task to Completed, which starts the continuation of the method that initiated the asynchronous call. If you are interested in getting more details on this subject, I recommend this very detailed article by Stephen Cleary.
Sometimes, however, even outside the low-level API, it is useful to be able to create a Task that will be marked as completed by the programmer. It is not possible to directly change the Task status. The TaskCompletionSource<T> class is used for this. This class is especially useful if you want to migrate old asynchronous event-based APIs to Task. Its use is relatively straightforward, and we will show you how it works on a simple console application that asynchronously waits for Ctrl+C to be pressed.
In the example, you can see that even if you do not want the asynchronous method to return anything, you still have to use some type of parameter for TaskCompletionSource<T>. In this case, object is used and the return value is always set to null. Task<T> class inherits from Task. This makes it possible to use Task as the method return type, even if tcs.Task is of the type Task<object>. The new .NET 5.0 even has a non-generic variant TaskCompletionSource, so you no longer need to enter an unnecessary type parameter.
You may need to implement an asynchronous interface, even if the implementation cannot be asynchronous. A typical example is a situation where you only want to return a constant. Sometimes you may find that the programmer has solved this problem using the Task.Run method. This is a bad solution because, as a result of this one constant, the code is run on another thread. Furthermore, once the lambda is completed, it reschedules which thread the rest of the method will run on. A much more elegant option and, more importantly, the optimal solution is to use the static method Task.FromResult. This method creates an instance of the class Task<T>, which is marked as completed immediately after creation and, as a result, contains a defined value of type T. Since Task is terminated immediately, no other threads are taken, nor is a continuation of the method scheduled. Therefore, the rest of the method runs on the same thread as explained above.
In the above example, you may be surprised that the method returns Task even though it does not include async or await. The asynchronous method must return Task. Async and await only help you simplify the notation if you are working with multiple asynchronous methods at once or if you need to obtain and further modify the result of the asynchronous method. In this case, you just pass the asynchronous operation on without modifying it at all. You are not creating a new Task, so it is enough to just pass it up a level. This will save the compiler some work and make the code that the compiler generates simpler. But that doesn’t mean you can have Task without await. Each Task should have its own await. In this case, however, it will reach a level higher.
Asynchronous methods that do not return Task
So far, we have assumed everywhere that an asynchronous method must return Task or Task<T>. This is not entirely true. In some special cases, the asynchronous method may not have a return type. Nevertheless, be aware that before you use something like this, you need to be sure of what you are doing. Otherwise, prepare for future problems.
C# allows you to use the keyword async even for methods that return no value (void). So, the await operator cannot be used for such methods, and if an error occurs (i.e., an exception is thrown), the caller cannot catch it using the try/catch. This behaviour can lead to errors that are very difficult to debug and, in the past, could have caused the entire application to crash. For this reason, it is recommended not to use async void methods. However, there is one place where you cannot avoid using them: event handling (event). For example, if you want to call an asynchronous method after pressing a button in a WinForm application, you have no other option. In this case, the behaviour regarding exceptions is acceptable, because it is no longer possible to catch the exception somewhere higher. The rule for using async void methods is:
Use async void exclusively when handling top-level events.
At the top level, it is thought that the event cannot be triggered by any application code other than the framework. Any other scenario is, without exaggeration, a trip to hell.
C# does not define async/await directly above the Task class. If another class meets the rules defined in the language standard, any class other than Task can be used in conjunction with the keyword async. The definition of such a class is beyond the scope of this article. Creating your own Task equivalent is really very unusual and you rarely come across anything that Task cannot solve. Nevertheless, even .NET includes a second type that can be used instead of Task and Task<T>: the ValueTask structure. This structure is used to optimize very frequently called methods, which do not always run asynchronously (e.g., they often read from the cache). In this case, re-instantiating the Task class on the heap may hamper performance. ValueTask partially solves this problem because it is a structure. If a value is returned without performing an asynchronous operation, only the structure with the return value is created. However, this is a very minor issue with a few limitations, so in this article, this brief description will suffice. If you are interested, you can find the details in the documentation or read this detailed article by Stephen Toub.
How it works under the hood
Now that we know how to use async/await. In this section, we will look at the important building blocks that make it all work. We will also see how different types of applications can affect how the rest of the method is scheduled to run.
You’ll probably never directly encounter this class when programming, but indirectly, you encounter it constantly. ExecutionContext is present every time any application is run in .NET. It provides information about what kind of user is using the application, the user’s cultural settings, etc. All classes that provide code execution on different threads work with ExecutionContext. This ensures that when the code changes the thread it runs on, the context from the previous thread is saved, which is then refreshed on the new thread. ExecutiuonContext also contains a lot of other information, but you just need to know that it is there and that, as a result, you do not have to be afraid to jump from thread to thread.
So far, we have limited our explanation to the assertion that the continuation after the completion of the asynchronous operation runs on another thread. If the system always created a new thread, it would not be optimal, because creating a thread requires considerable overhead. And this is the problem solved by the static class ThreadPool. This class maintains a certain number of threads and increases or decreases the number as needed. Using the ThreadPool.QueueUserWorkItem method, you can pass to the pool the delegate that you want to execute on one of the threads. ThreadPool queues this request, and if a thread is free, it executes the required code on it (with the ExecutionContext of the calling thread). When the thread completes the specified request, it checks the queue to see if any other work needs to be done. If not, it sleeps until needed again. This mechanism considerably simplifies running parts of the methods on other threads while avoiding unnecessary overhead.
ThreadPool is not just for scheduling asynchronous operations to run on different threads. This class has been part of .NET almost since its inception. In addition to running code on threads, it also offers the ability to set up a callback to complete an asynchronous operation at the operating system level. This is often used for low-level parts of IO-bound asynchronous operations. But we won’t go that deep. If you are interested, you can read the detailed documentation on the Microsoft website.
This class is nothing new in .NET either, but thanks to async/await you will use it without even knowing it. It is basically an abstraction of how the application should behave after the asynchronous operation is completed. It allows you to run a delegate in a way that matches the desired behaviour in that type of application. For example, WinForm technology requires that the continuation of an asynchronous method run on a UI thread be performed again on the same thread.
SynchronizationContext is assigned for each thread separately. The synchronization context is set for the current thread using the SynchronizationContext.SetSynchronizationContext method. If you want to find out which synchronization context is set for the current thread, you can use the static property SynchronizationContext.Current. This makes it possible to save the synchronization context of the current thread at the beginning of an asynchronous operation. Once the operation is completed, it is possible to run the rest of the asynchronous method using the saved context. You can use the Post method to run the delegate in context. How the delegate will run is entirely up to the specific implementation of the stored SynchronizationContext.
The default synchronization context for each thread is null. This can be interpreted as meaning that the thread has no requests to run follow-up code after the asynchronous operation finishes. Therefore, this code can be run on any thread. Different types of applications set their sync context to the thread at initialization. For example, WinForm sets up a UI thread instance of class WindowsFormsSynchronizationContext that uses the Control.Invoke mechanism internally. Similarly, WPF and UWP technologies, which use other mechanisms to synchronize with the UI thread, have their synchronization contexts, but their principle is very similar. However, not only desktop applications use the synchronization context. Prior to the release of .NET Core, ASP.NET also had its own context, which ensured that HTTP context was passed to other threads. The new ASP.NET Core has been designed so that a synchronization context is no longer needed. By default, console applications do not use the synchronization context either.
As with any synchronization mechanism, there is a risk of deadlock with the SynchronizationContext: that is, a situation where the thread you are trying to run your code on is blocked and unblocked only by running the required code on that thread. So, if you work with technology that uses the synchronization context, you have to be careful about deadlocks. Later, when we explain the function of the awaiter, we will show you a simple principle to prevent the risk of deadlock.
TaskScheduler is part of the TPL and is tightly bound to the Task and Task<T> classes. It allows you to define how tasks represented by Task will be performed, including their embedded asynchronous calls. Each part of the asynchronous method is run via a TaskScheduler, whose specific implementation can affect, for example, the priority of operations, which thread it runs on, the total number of threads used, etc.
The default scheduler is ThreadPoolTaskScheduler, which runs all tasks using ThreadPool. For example, if you want to ensure that the entire asynchronous task is performed on only a limited number of threads, you can use a different TaskScheduler. You can either create it yourself or rely on one of the existing libraries.
The TaskScheduler concept is not entirely designed to work with async/await. The only way you can pass the required scheduler to Task is to use the Task.Factory.StartNew method parameter. This makes sense, especially if you are running a CPU-bound operation and you want to influence how it will be performed. However, async/await cannot completely ignore TaskScheduler. If you use the await operator in an asynchronous task running with a specific scheduler, that scheduler must be considered when scheduling the continuation of the method.
All the building blocks we have described so far fit together in an object called Awaiter. Awaiter is an object that provides a connection between the TPL and the await operator. Its meaning can be compared to an enumerator, which ensures the same interface for different classes so that the foreach can work with it. The await operator can be used on any object that has a GetAwaiter method. This method must return an instance of a class that meets the following conditions.
- Implements the INotifyCompletion interface or its ICriticalNotifyCompletion extension.
- Has the IsCompleted method, which returns bool.
- Has the GetResult method, which can have any return type (including void).
If a class meets these conditions, it is called “awaitable” and the await operator can be applied to it. Therefore, if you use await on a Task object, the compiler generates code that uses GetAwaiter to obtain an instance of the awaiter. It then uses the IsComplete method to verify whether the task is already completed. If it is not completed yet, use INotifyCompletion.OnComplete to schedule what should happen after completion. The SynchronizationContext or TaskScheduler is used in the OnComplete method. If a SynchronizationContext instance was set on the thread when creating the awaiter, this instance is saved in Task. The delegate, which runs the continuation over the saved synchronization context, is passed using OnComplete. If the context has not been defined, the continuation is run via the TaskScheduler stored in the given Task.
As already indicated, there are more awaitable objects in .NET. For example, the Task.Yield method uses a special awaiter whose IsComplete method always returns false. As a result, the rest of the method is always scheduled asynchronously. Another frequently-used example of a special awaitable object is the ConfigureAwaiter method for Task class objects. This method returns an awaitable object whose awaiter ignores the captured SynchronizationContext when scheduling the method to continue. Therefore, TaskScheduler is always used.
Async method compilation
Now, let’s take a closer look at how the C# compiler handles the async keyword and the await operator. This constructor is not part of the Common Intermediate Language (CIL), the language that C# code is translated into. Therefore, the compiler must compile the async/await statement into equivalent code without these keywords. We have already outlined how the TPL can be used without async/await. However, the compiler makes this transformation a little more complex because it must ensure the correct behaviour of try/catch and using blocks, loops, etc. A new class is generated for each asynchronous method. When translated in production mode, it is even a structure. This class/structure represents the state machine of a given asynchronous method. The state always represents one part of the method between asynchronous calls. All local variables of the asynchronous method are also part of this class/structure. Each continuation of an asynchronous method is a call to the same method of that generated object. It is decided during the call, according to the state, which part of the method needs to be executed. Subsequently, the state changes so that another part of the method is executed again the next time the call is made.
We will not go into any more detail regarding the principle of asynchronous method translation. If you want to learn more about this topic, you can try writing an asynchronous method in the fabulous tool SharpLab.io and see what the compiler generates. Of particular interest is a method with at least two asynchronous calls and a try/catch block or loop. In such a case, the resulting code becomes complicated relatively quickly.
And that’s all for this part. Next time we’ll take a look at the most common mistakes when using async/await.
Author: Petr Haljuk