Asynchronous programming in C# and .NET is a very powerful tool. And just like any other tool, it can be used incorrectly. This can result in errors in the program that are difficult to debug. In the last part of this series, we’ll take a look at the most common mistakes programmers make when using async/await.
Await is missing
What happens if you forget to use the await operator before calling an asynchronous method? The method starts as usual and returns an incomplete Task, but the program doesn’t wait for Task to complete and continues to run. Also, the program does not respond to any of the exceptions in the asynchronous operation. Visual Studio and most other IDEs will alert you to this error with a warning, but even so, this error can be pretty tormentful, especially if you are doing more extensive code refactoring where you are implementing asynchronous methods in locations that were previously synchronous.
Await is misplaced
Sometimes a programmer wants to avoid unnecessary use of the keyword async and the unnecessary overheads of asynchronous method compilation that come with it. This is done by directly returning Task to other asynchronous methods. This isn’t necessarily wrong because if a method doesn’t need to wait for the result of an asynchronous method, you can write it this way.
The problem occurs when you wrap a call with a using or try/catch block. Take a look at the following example.
In this case, a well-intentioned optimization effort failed. The try block only includes the beginning of the asynchronous method call, and the block ends before the entire asynchronous operation DoSomethingElseAsync ends. So, if DoSomethingElseAsync throws an exception, try might not catch it. The exception can be thrown in the next part of the method, which is run long after the program jumps out of the try/catch block.
Decide for yourself whether it makes sense to perform such an optimization on your project. In most cases, you won’t save much. Additionally, you run the risk that if someone extends the method, they will overlook the missing async/await creating a problematic situation like the one in the example. This can lead to more serious problems that could have been avoided.
Deadlock is the scarecrow of any code that performs synchronization between multiple threads. As we have shown you, even an asynchronous call using async/await can perform such synchronization if SynchronizationContext is defined for that thread. If you mix SynchronizationContext with blocking calls to asynchronous methods, you can easily cause a deadlock, as you can see in the following example.
A good rule of thumb for preventing deadlock is not to block calls to asynchronous methods, at least not where there is a threat of SynchronizationContext use. Of course, it is best not to block calls at all, but this isn’t always possible. This rule is very poorly followed by libraries, where you have no idea what type of application your code will run in. Therefore, it is recommended to always use ConfigureAwaiter(false) for libraries, which prevents the scheduling of parts of asynchronous methods from your library via SynchronizationContext.
Await is missing for an asynchronous lambda method
This is a fairly serious bug because it is very easy to overlook in your code and can cause a lot of unexpected problems. In C#, even the lambda method can be written asynchronously. Unfortunately, this even includes a lambda method without a return value (for example, Action). Such a lambda method is then the equivalent of an asynchronous method with a return type of void. This means there is no Task to wait for and possibly a thrown exception. The example below demonstrates the forEach method, which executes the specified lambda method for each element of the collection. This lambda is marked as asynchronous and everything looks fine. Unfortunately, there is no await anywhere for these asynchronous lambdas. So, the individual calls start in parallel and the program does not care if they ever finish running.
A more appropriate solution, in this case, is to use a forEach loop to avoid the asynchronous lambda method. But there are cases when you simply need an asynchronous lambda. In these cases, the delegate for the lambda method should always have the return type Task or Task<T>. For this, you can use, for example, a type Func<Task, T> lambda. The await operator can be used for each call and the method returns Task, which allows you to wait for the calls of individual asynchronous lambda methods. This approach is demonstrated at the end of the example in the extension method.
ConfigureAwait is not used in libraries
As mentioned above, in library code, it is recommended to use ConfigureAwaiter(false) for each await for asynchronous operations. This ensures that the code won’t cause a deadlock when the library is used in an application that uses SynchronizationContext.
Apart from deadlock, there is another reason to ignore SynchronizationContext in libraries. The example shows it on a desktop application. However, this problem occurs on any application that uses a synchronization context. If we do not ignore the context, each continuation is synchronized back to the UI thread after the asynchronous call is completed. But the UI thread does not continue immediately, but rather whenever it completes what it is doing. This allows your library code to wait for synchronization, which it usually doesn’t need, after each asynchronous call. If you use ConfigureAwaiter(false), all continuations of your method will run where TaskScheduler scheduled them. In most cases, this is on one of the ThreadPool threads. As a result, the UI thread is not unnecessarily burdened.
The application that called your library method will not use ConfigureAwaiter(false), so after the asynchronous method is completed, the continuation is rescheduled to the UI thread. But that is where the desired behaviour already is.
Task.WhenAll is used when making a parallel call
This isn’t exactly an error, but it is worth showing the consequences of writing such code. In the example, two asynchronous methods are called in parallel. Both will start and only then do we wait for the completion of the first and then the second. But what happens if DoSomethingAsync throws an exception? The exception is stored in task1, and the first await unpacks it from Task and throws it further. This ends the execution of the method. Await task2 will never happen. Therefore, if task2 contains an exception, it will never be thrown and will remain uncaught.
You are probably wondering what would happen if the exception in Task remained uncaught and if it would mean that it would not even be logged. It doesn’t have to be that way. If it is determined at the time the Task class object is deleted from memory that the exception was not picked up, a TaskScheduler.UnobservedTaskException event is fired, giving you one last chance to log the exception.
Therefore, it is good practice to use this event. You will avoid application errors without writing to the log. To be thorough, it is also worth adding that older versions of the .NET Framework caused the entire application to crash if the exception was not handled through TaskScheduler.UnobservedTaskException. For Web applications, this made the entire application unavailable until the IIS restarted the Web.
As you can see, asynchronous programming has many benefits to offer. But if we dive in deeper, we also discover the risks of using it. I hope this article will help you avoid these risks and make the most of asynchronous programming. C# development will certainly continue on this path, and the possibilities of asynchronous programming will be further expanded. C# and .NET also include many more async/await tools that are worth exploring such as the IAsyncDisposable and IAsyncEnumerable interfaces with the associated await forEach constructor. The uses of the CancelationToken structure and the Progress class are also interesting. But that will have to wait until next time. Happy asynchronous programming, everyone!
Author: Petr Haljuk