Asynchronous programming is awesome, and C# makes it very easy with the .NET Task Parallel Library. At a high level, asynchronous programming is all about not letting independent tasks block each other so you can do more than one thing at a time. One common analogy used to describe the asynchronous pattern is cooking. Let’s say I am a chef in a restaurant, and a breakfast order comes in for sunny side up eggs, toast, and juice. If I were to prepare this meal synchronously, I would crack an egg on the griddle, wait for it to cook, and put it on the plate. Then I would put bread in the toaster, wait for it to pop up, and put the toast on the plate. Finally, I would get the juice from the refrigerator, and pour it in the cup to complete the order. But this is clearly an inefficient way to get the work done, and in the real world we would never do this – there’s no need to wait for the eggs to complete before starting the toast, then waiting for the bread to toast before pouring the juice. It makes more sense to prepare the meal asynchronously. To more efficiently complete all the work required to prepare the meal, I would crack the egg on the griddle, then put the bread in the toaster, then get the juice from the fridge and pour it in the cup. The execution of any of the tasks I have to complete before breakfast is ready is not dependent on the completion of the others. I’ve created a significant gain in efficiency by starting two independent and longer running tasks at the same, and doing the other comparatively shorter task while waiting for those to complete.
Let’s take this example of waiting for long running tasks to complete into some code. Suppose your application needs to call three methods to get the data required for constructing a new object. Following the same synchronous approach as the first breakfast order, we call each of the three methods one after the other, and use the data returned to construct the new SampleData object. Here’s a quick example:
There is nothing inherently wrong with this code, but what if one or more of those methods take a significant amount of time to execute? Suppose Foo() makes an HTTP request to an API that is responding slowly, Bar() is application code that performs a lengthy calculation or database query, and Baz() is something that executes very quickly. Since none of these three methods are dependent on the return values of each other, we can gain significant performance by making the longer running methods asynchronous and executing them at the same time, and waiting for them to complete before creating our SampleData object – this is asynchronous programming. It might look something like this:
There are many, many documents, articles, and blog posts out there that do an excellent job of dissecting and explaining asynchronous programming with C# in detail, so I’m not going to rehash that same information here. One of my favorites is Async and Await by Stephen Cleary. I would also recommend Asynchronous programming with async and await (C#) from Microsoft for a more in-depth look at what happens behind the scenes with asynchronous method execution. And without trying to further complicate the issue, I think it’s important to point out that asynchronous programming is not the same thing as multi-threaded programming in C# – see: What is the difference between asynchronous programming and multithreading?
This post is for people like myself – programmers who learn better from jumping right into code and stepping through each line to see how a program executes. When I’m first exploring an unfamiliar concept, reading documents and looking at sample code snippets can sometimes be a bit confusing or overwhelming. In many cases, I learn best when I can see a concept in a working, concrete form. I’ve created a small .NET Core 3.1 console application that will walk you through asynchronous programming with C# using a complete and practical example. The repository is accessible here:
Let’s get started! Clone the repository, and open up the solution. You will see theww source code files inside, Program.cs, SampleData.cs, and SampleDataLayer.cs.
Let’s look at the Main() method in Program.cs – this is our application entry point. You will notice that the method declaration of Main() includes the “async” keyword. This is because inside Main() we will be calling and awaiting on some asynchronous methods. Always remember that the “async” keyword simply enables the use of the “await” keyword inside a method. Inside Main(), we are going to run a small experiment by calling two methods: RunDemoSynchronous() and RunDemoAsync(). The methods both contain nearly the same code, but RunDemoSynchronous() will execute synchronously and RunDemoAsync() will execute asynchronously. We will compare the performance of each. Here is the entire Main() method:
To begin, let’s run the application and see the output:
Inside Main(), we create a Stopwatch object and use it to capture the elapsed execution time when calling RunDemoSynchronous() and RunDemoAsync(). We can see from the console output that RunDemoAsync() ran about 3 seconds quicker than RunDemoSynchronous(). So what’s happening when each of these methods are called?
The first half of the experiment starts by calling RunDemoSynchronous() and a message is logged to the console to indicate we have begun execution of this method (“RunDemoSynchronous() start.”). Inside, three methods are called to fetch the data necessary to construct our SampleData object:
GetDelayedApiResponse() calls an end point from my Sample API which waits for a specified number of seconds before returning a response. Here is the GetDelayedApiResponse() method as it is written in SampleDataLayer.cs:
The program also outputs a message to the console here when we enter the method, and again after the WebClient received the response. The app will output similar console messages throughout so we can see the current status of the program as it is executing – this is just some good old fashioned printf() debugging.
After exiting GetDelayedApiResponse(), we move on to the next line in RunDemoSynchronous(), calling SimulateLongProcess(). This method simulates long running application logic in our code by simply pausing for the specified number of seconds. This method could represent a CPU-intensive calculation, a database query, or any other lengthy process in your code.
The last method we call before we can construct or SampleData object is ShortRunningCalculation(). This method simulates short running work in application code, and will return almost immediately.
Back inside RunDemoSynchronous(), we have now called three methods and stored their return data in three variables. We can now create our SampleData object, exit the method, and stop the currently running Stopwatch.
The three methods we just called, GetDelayedApiResponse(), SimulateLongProcess(), and ShortRunningCalculation(), were blocking because they were called synchronously, one after the other. We can verify this by looking at the console output:
GetDelayedApiResponse() starts and completes, then SimulateLongProcess() starts and completes, and finally ShortRunningCalculation() starts and completes. RunDemoSynchronous() took about 7 seconds because we set the delay time for GetDelayedApiResponse() and SimulateLongProcess() to 4 and 3 seconds, respectively. The execution time of ShortRunningCalculation() is comparatively much quicker, adding only a fraction of a second to the total execution time.
We can achieve a significant performance gain by implementing an asynchronous pattern and running those methods all at once, waiting for each to complete before we construct the SampleData object. GetDelayedApiResponse() and SimulateLongProcess() do not need to know about each others’ output, so we can execute both at the same time. While we wait for the output of those two methods, we can call ShortRunningCalculation().
Back in the Main() method, the second part of the experiment is run by calling RunDemoAsync(). This method will demonstrate asynchronously executing the exact same workflow as we did above. As a result, we will see a significant performance gain. First, we reset the stopwatch and call RunDemoAsync(). Notice the change in syntax when calling this method:
RunDemoAsync() is a an async method, and we need to wait for it to complete before stopping our stopwatch by using the await keyword. Once we’re inside RunDemoAsync(), we get variables dataA, dataB, and dataC in a similar fashion as we did above, but we are now calling GetDelayedApiResponseAsync() and SimulateLongProcessAsync() instead. These are asynchronous versions of GetDelayedApiResponse() and SimulateLongProcess(). Async methods usually return Task, which is just an asynchronous operation that can return a value. (If the async method returns null, Task is returned).
Let’s take a closer look again at the console output and see how the execution flow differs inside RunDemoAsync().
Notice this time that GetDelayedApiResponseAsync() is started, but instead of the next console line reading “GetDelayedApiResponseAsync() complete” as we saw in the first example, we see “SimulateLongProcessAsync() start” and “ShortRunningCalculation() start” on the next lines. From this output, we can see that the next two lines of code calling these methods were executed without waiting for the GetDelayedApiResponseAsync() to finish. Let’s take a look at GetDelayedApiResponseAsync():
The method return value is now a Task, and the method declaration also includes the “async” keyword. Remember that using the async keyword enables the use of the “await” keyword in a method. We’re using the WebClient class to make the request to the API here, and remember that GetDelayedApiResponse() used the WebClient.DownloadString() method to make the API request. But in GetDelayedApiResponseAsync(), we will use WebClient.DownloadStringTaskAsync() instead. Because we made our method async, we can now call async methods such as DownloadStringTaskAsync(). The program will execute up until this line, and while awaiting for DownloadStringTaskAsync() to come back with our API response, the execution flow will return to the next line in RunDemoSynchronous().
SimulateLongProcessAsync() is written in a similar fashion – the return type is now a Task, and we’ve added the async keyword to the method declaration:
We are just pausing execution of the program here as we did in SimulateLongProcess(), only this time we will call an async method inside, awaiting on Task.Delay() rather than calling Thread.Sleep(). Once again, making our method async simply means enabling the use of the “await” keyword within. Just like GetDelayedApiResponseAsync(), once we reach this line:
The execution flow of the program returns to the method that called our async method. Now that we have called those two async methods, we’re back at this line in RunDemoAsync():
ShortRunningCalculation() remains unchanged in the second part of our experiment – it is still a synchronous method and we still call it synchronously. We know that ShortRunningCalculation() gets called and executes very quickly, so we will need to wait for the other two methods to return before we can create our object:
As soon as the program is done awaiting taskDataA and taskDataB, the SampleData object will be constructed with the result of those tasks and the value of dataC. The Console will output the “SampleData object ‘sample’ created” message along with the values of each property in the object. Method RunDemoAsynchronous() will exit, and we return to the Main() method where the final messages indicating that RunDemoAsynchronous() is complete are written to the console. The elapsed time to complete the second half of the experiment is just over 4 seconds – a little bit more than a 3 second improvement in total execution time over the RunDemoSynchronous() method!
This represents quite a significant improvement in the application’s performance. By implementing an asynchronous pattern in the program, we get where we need to be more quickly than we did before, and the benefit is easy to see. This is just one example of asynchronous programming, and it only scratches the surface. There are many ways to implement the asynchronous programming pattern and I hope this post serves as a jumping off point for others to leverage similar patterns in their C# applications.