The pitfalls of asynchronicity in C#

Rafał Świrk
Calendar icon
12 września 2022

Asynchronicity is not a new topic. It has existed with us for years. Over time, a lot has changed in the approach to it. Modern programming languages make it significantly easier to use it. For C#, one of the most important changes was the introduction of the keywords async and await. Since then, asynchronicity has been very widely used in .Net projects. However, this ease can sometimes be deceptive. It is possible to write code that works most of the time, but occasionally generates errors that are difficult to diagnose. By reading this text, you will learn why you should use async/await. We will also look at one of the more common mistakes made when writing asynchronous code. Which will allow you to avoid unpleasant surprises at work and tedious debugging of your application. And you can always use the saved time for something more pleasant.

Why use async/await?

Most programmers start their code adventure by writing synchronous applications. In short, this means that each successive line of code is executed after the previous one has finished. The undoubted advantage of this solution is simplicity. Such code is much easier to read and analyze, but as it happens in life, there is always something, for something. In this case, on the other side of the scale is the performance of the application. By writing asynchronous code, you can gain a lot in this topic. Boiling it down to a simple life example - we want to make breakfast. In a synchronous world, we would start by brewing coffee, then preparing the sandwiches. Taking a closer look at the process, it turns out that it took a lot of time waiting for the water to boil for the little black. Thanks to asynchronicity, this time can be used to perform other tasks. In the example above: to make sandwiches. In the world of programming, you can analogously use the wait to download a file or execute a complex database query. Very simple code using the aforementioned tools looks like this:

1    public static async Task Main(string[] args)
2    {
3        await Task.Delay(500);
4        System.Console.WriteLine("Hello, async world!");
5    }

The two most important things to observe are:

  1. Async in the signature of the Main method. This tells the compiler that the method can be executed asynchronously.
  2. Await. From this point on, magic happens. The code can be executed in parallel. Of course, in the above example, not much happens, there is only a wait of 500 ms. And consequently, the code execution time is about 500 ms. So far, no magic can be seen, although it hides underneath. That's why it's worth moving on to a slightly more elaborate example.
1  public static async Task Main(string[] args)
2    {
3        var stopWatch = new Stopwatch();
4        stopWatch.Start();
5        var delay1 = Task.Delay(500);
6        var delay2 = Task.Delay(500);
7        await Task.WhenAll(delay1, delay2);
8        stopWatch.Stop();
9        System.Console.WriteLine($"Hello, async world! Time: {stopWatch.ElapsedMilliseconds} ms");
10    }

At first glance, no big deal. Task.Delay(500) is called twice. The conclusion is simple. The execution time of the entire code should be just over a second. To quote a classic - nothing could be further from the truth. Doubts can be dispelled by running the code and reading the time recorded with the Stopwatch class. Why Stopwatch and not the usual DateTime.Now is a topic for another article. Back to the topic:

1Hello, async world! Time: 588 ms

The result is 588 ms. Task.Delay(500) commands were executed in parallel. And using await Task.WhenAll(delay1, delay2), the program waits until these tasks finish. In the above codes there is a silent hero scrupulously overlooked until now. This is the Task class. It is the combination of the Task class and the keywords async, await that gives control over asynchronicity. This is also where the first pitfall comes in. From time to time you may encounter a function with the following signature in application codes:async void DoSomethingAsync(...).

Why is async void a necessary evil?

There is a popular saying. If you don't know what's at stake, it's about money. In this case, it's about backward compatibility, but at the end of the day, from a business point of view, this topic in IT projects also comes down to money. The async void syntax should be avoided whenever possible. Its use raises certain consequences. Only when we know the consequences of our actions can we consciously decide whether to take them. Therefore, every time a programmer notices an async void in the application code, he or she should trigger the "Are you sure this is a good idea?" and "What did the code author have in mind?" events. Speaking of events... This is where async void has its natural habitat to live. More specifically, in event handlers. The event handler must match the signature of the event's delegate, for example:public delegate void EventHandler(object sender, EventArgs e);

The signature is simple and clear. Here you can't use the aforementioned Task by reducing the delegate to the following form:public delegate Task EventHandler(object sender, EventArgs e);

Unfortunately, this signature does not match the events commonly used in C#. For those working with WinForms or WPF libraries on a daily basis, this will be the daily bread. Any attempt to use an async Task, for example, when pinning a button click action, is doomed to failure topped with compilation errors. In summary, async void has its use in event handlers. Using this syntax in other places is a very poor idea. Probably the question now arises why?

The answer, despite appearances, is quite logical. In order to perform await on a method, we need a Task object. EventHandlers by their nature do not return anything. By using the async void syntax in situations other than the one previously mentioned, we impose an unnecessary restriction on ourselves. And the inability to perform await on a method has some consequences. The first is to start executing a method without waiting for it to finish.

1 public static async Task Main(string[] args)
2    {
3        System.Console.WriteLine("App started");
4        var stopWatch = new Stopwatch();
5        stopWatch.Start();
6        System.Console.WriteLine("Delay1 started");
7        var delay1 = Task.Delay(500);
8        LongBackgroundJob();
9        System.Console.WriteLine("Delay2 started");
10        var delay2 = Task.Delay(500);
11        await Task.WhenAll(delay1, delay2);
12        stopWatch.Stop();
13        System.Console.WriteLine($"Hello, async world! Time: {stopWatch.ElapsedMilliseconds} ms");
14    }
15
16    public static async void LongBackgroundJob()
17    {
18        System.Console.WriteLine("LongBackgroundJob started");
19        await Task.Delay(500);
20        System.Console.WriteLine("LongBackgroundJob finished");
21    }

The result of executing the above code is the following response in the console:

1App started
2Delay1 started
3LongBackgroundJob started
4Delay2 started
5LongBackgroundJob finished
6Hello, async world! Time: 525 ms
The LongBackgroundJob method was run in parallel with the rest of the code. At first glance, nothing terrible is happening. The same is true for the delay1 and delay2 shuffles. It's just that in the case of the aforementioned shuffles, you can execute await - see line #18, in the code snippet above. Attempting to execute await on the LongBackgroundJob method will end with a compilation error:
cs-bledy-blog.webp
The upshot is that we end up with a "fire and forget" type of call. This is not necessarily a bad thing, but such procedures should be done in full awareness. Otherwise, they can lead to very difficult-to-diagnose errors in the application logic, such as data corruption, or simply crashing the application. In the second case, async void will also have its two cents. When an exception occurs, the process will simply terminate its operation. Such an effect will not be observed when using async Task. You can say a big thing... Exception has laid out the application. The following code generates an exception when executing the LongBackgroundJob method:
1public static async Task Main(string[] args)
2    {
3        System.Console.WriteLine("App started");
4        var stopWatch = new Stopwatch();
5        stopWatch.Start();
6        System.Console.WriteLine("Delay1 started");
7        var delay1 = Task.Delay(500);
8        LongBackgroundJob();
9        System.Console.WriteLine("Delay2 started");
10        var delay2 = Task.Delay(500);
11        await Task.WhenAll(delay1, delay2);
12        stopWatch.Stop();
13        System.Console.WriteLine($"Hello, async world! Time: {stopWatch.ElapsedMilliseconds} ms");
14    }
15
16    public static async void LongBackgroundJob()
17    {
18        System.Console.WriteLine("LongBackgroundJob started");
19        await Task.Delay(100);
20        throw new Exception("Exception from LongBackgroundJob");
21        System.Console.WriteLine("LongBackgroundJob finished");
22    }
23}

}

The magic begins when we decide to surround a whimsical method using a try...catch block:

1 public static async Task Main(string[] args)
2    {
3        System.Console.WriteLine("App started");
4        var stopWatch = new Stopwatch();
5        stopWatch.Start();
6        System.Console.WriteLine("Delay1 started");
7        var delay1 = Task.Delay(500);
8        try
9        {
10            LongBackgroundJob();
11        }
12        catch(Exception)
13        {
14            System.Console.WriteLine("Exception from LongBackgroundJob catched");
15        }
16        System.Console.WriteLine("Delay2 started");
17        var delay2 = Task.Delay(500);
18        await Task.WhenAll(delay1, delay2);
19        stopWatch.Stop();
20        System.Console.WriteLine($"Hello, async world! Time: {stopWatch.ElapsedMilliseconds} ms");
21    }
22
23    public static async void LongBackgroundJob()
24    {
25        System.Console.WriteLine("LongBackgroundJob started");
26        await Task.Delay(100);
27        throw new Exception("Exception from LongBackgroundJob");
28        System.Console.WriteLine("LongBackgroundJob finished");
29    }
30}

}

You might expect the exception to be caught and the application to continue executing. Nothing could be further from the truth:

1App started
2Delay1 started
3LongBackgroundJob started
4Delay2 started
5Unhandled exception. System.Exception: Exception from LongBackgroundJob
6   at DemoConsoleApp.Program.LongBackgroundJob() in C:\Users\rafal\source\repos\rafalswirk\CommonAsyncMistakes\DemoConsoleApp\Program.cs:line 34
7   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__128_1(Object state)
8   at System.Threading.QueueUserWorkItemCallbackDefaultContext.Execute()

Again, the exception kills the application. The above example is trivial. It's just a few lines of code. We have the development environment bootstrapped. In the case of large legacy applications, where a single class can have several thousand lines of code, additionally running on end-user machines, without the ability to run everything from VisualStudio - then it's no longer fun. Especially if the error occurs from time to time and there are no clear steps to reproduce it. So how to catch such an exception? A try-catch block just needs to be placed inside the unfortunate method:

1 public static async Task Main(string[] args)
2    {
3        System.Console.WriteLine("App started");
4        var stopWatch = new Stopwatch();
5        stopWatch.Start();
6        System.Console.WriteLine("Delay1 started");
7        var delay1 = Task.Delay(500);
8        LongBackgroundJob();
9        System.Console.WriteLine("Delay2 started");
10        var delay2 = Task.Delay(500);
11        await Task.WhenAll(delay1, delay2);
12        stopWatch.Stop();
13        System.Console.WriteLine($"Hello, async world! Time: {stopWatch.ElapsedMilliseconds} ms");
14    }
15
16    public static async void LongBackgroundJob()
17    {
18        try
19        {
20            System.Console.WriteLine("LongBackgroundJob started");
21            await Task.Delay(100);
22            throw new Exception("Exception from LongBackgroundJob");
23            System.Console.WriteLine("LongBackgroundJob finished");
24        }

Calling the above code will be a little more predictable:

1Loaded 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.7\System.Text.Encoding.Extensions.dll'. Skipped loading symbols. Module is optimized and the debugger option 'Just My Code' is enabled.
2App started
3Delay1 started
4LongBackgroundJob started
5Delay2 started
6Exception thrown: 'System.Exception' in DemoConsoleApp.dll
7Exception from LongBackgroundJob catched
8Hello, async world! Time: 543 ms
9The program '[4024] DemoConsoleApp.dll' has exited with code 0 (0x0).

Summary

Async and await significantly facilitate the work with multithreaded code. While in most cases the use of this mechanism is clear, some developers fall into the trap of using async void. It is good practice to avoid this construct whenever possible. The only place where it finds use is in event handlers, but even in this case it is very easy to write code that generates quite unexpected behavior.

Read also

Calendar icon

27 wrzesień

Omega-PSIR and the Employee Assessment System at the Warsaw School of Economics

Implementation of Omega-PSIR and the Employee Evaluation System at SGH. See how our solutions support university management and resea...

Calendar icon

12 wrzesień

Playwright vs Cypress vs Selenium: which is better?

Playwright, Selenium or Cypress? Discover the key differences and advantages of each of these web application test automation tools. ...

Calendar icon

22 sierpień

A new era of knowledge management: Omega-PSIR at Kozminski University

Kozminski University in Warsaw, one of the leading universities in Poland, has been using the Omega-PSIR system we have implemented t...