I gave a talk at CodeMash 2019 that I originally submitted as a joke. It was called "Hold Up, Wait a Minute, Let Me Put Some Async In It," about refactoring an existing synchronous C# and ASP.NET application into one which utilizes the async and await keywords and asynchronous programming in general.
If I'm being totally honest, I submitted that abstract primarily because I couldn't stop giggling at that ridiculous title; but to my great surprise it was accepted!
I realized shortly after delivering that asynchronous talk that it would work equally well as a set of blog posts, and here we are.
Here's what we're going to do: we're going to learn about some of the fundamentals of asynchronous programming in C# and ASP.NET, and talk about a major guideline that will help us implement async/await in an existing synchronous app. Along the way we'll see some of the common pitfalls that can occur when doing so.
Let's get started!
Why Implement Asynchronous Programming?
One of the most misunderstood aspects of asynchronous programming in ASP.NET is that it supposedly improves "performance". This is often taken to mean "it makes my code go faster." I'm here to tell you that this statement is unequivocally false; asynchronous programming will not make your code execute faster.
What asynchronous programming truly does is increase the amount of requests that can be handled at the same time with the same resources. The same amount of threads can handle many more simultaneous requests in an asynchronous system than in a synchronous one. In short, Asynchronous programming doesn't improve performance, it improves throughput.
(From a ten-thousand-foot view, the result of asynchronous code indeed looks like the code is running faster, which gives rise to the misconception from earlier. But in reality the system is just doing more at once, allowing more throughput).
Let's demonstrate this by using an analogy, which is always a foolproof way of demonstrating a complex idea. :)
Say we have a restaurant. In this restaurant, when orders are placed, they are delivered to the kitchen for cooking. The kitchen has a bunch of cooks whose job it is to make the food needed for each order. There are two kinds of kitchens we can use: synchronous, and asynchronous.
In a synchronous kitchen, each cook gets one order at any given time. They must then work that order to completion entirely on their own. That means that if an item needs to go in the oven, they will
stare intently into it wait patiently while the item is cooking and not do anything else during that time. Hence this kitchen can handle only X amount of orders simultaneously, where X is the number of cooks in the kitchen.
In an asynchronous kitchen, cooks will not have idle time. They may receive an order, and start to make it, but if there comes a point at which they must wait for something to be done (cooking in the oven, etc.) they must go and do something else. This kitchen can handle significantly more orders than they have cooks, because no cook will ever be standing around waiting for something to happen. In this way, the number of orders fulfilled (aka the throughput) of the kitchen increases, even though each cook doesn't actually work any faster than they normally do.
As an aside, guess which kitchen more accurately represents the real world...
Fundamentals of Asynchronous Programming
As we keep in mind the idea that the goal of asynchronous programming in ASP.NET is to improve throughput, we can start to talk about some of the fundamentals we must keep in mind when attempting to implement it. Here's a few things to remember:
Asynchronous Is Not Multithreading
It is vitally important to remember that code which is asynchronous is NOT the same as code which uses multithreading.
In multithreading, work is done on totally separate threads, with no interaction between individual threads. In asynchronous programming, we define points from which threads can leave and return to at some point in the future.
During the process of executing an asynchronous task, .NET will create something called a SynchronizationContext which stores the context necessary for a thread to resume execution at that point. Note that the thread which does this may or may not be the same thread that executed the code before that point.
Eric Lippert has a fantastic explanation post over on StackOverflow if you want more detail on the difference between asynchrony and multithreading. The most important thing to take away from his description is this:
"Threading is about workers; asynchrony is about tasks."
Async Works on I/O Bound, Not CPU-Bound, Tasks
Because asynchronous programming is about tasks, we need to specify what kinds of tasks benefit from using it. Such tasks are said to be I/O-bound, so as to differentiate them from CPU-bound tasks.
CPU-bound tasks are tasks which rely on the computation speed of the machine to execute quickly, such as complex mathematical calculations. Said computations will occupy the processor's time, and while they are being executed the processor does not need to wait for any other inputs. These kinds of tasks do not benefit from asynchronous programming.
I/O-bound tasks are those that require a response from outside sources. Such outside sources might include a database, a service, a REST API, or others. When making calls to these sources, the processor often has to "wait" for them to respond.
In a "normal" environment (i.e. a synchronous one) the thread executing the code will just sit and wait for the outside source to respond after having called it. Asynchronous programming allows the thread to leave a "marker" at the point where it would normally have to wait so that a thread can return to that point at a later time, when the outside source will have responded. (It is this marker which includes the SynchronizationContext from earlier.)
Asynchronous programming provides zero benefits to CPU-bound tasks; there's no "waiting" involved.
Async Spreads Like a Virus
When one begins to implement asynchronous programming in .NET codebases, one tends to notice how rapidly it propagates to nearby code. It's comparable to a virus; it likes to infect things that it comes in contact with.
What this means is that we shouldn't combine synchronous and asynchronous code without knowing exactly what consequences arise. In fact, in our walkthrough converting a synchronous app to an asynchronous one (to be published as a separate post), we will see what kinds of issues arise when we mix synchronous and asynchronous code.
Asynchronous code in .NET uses only three return types:
- Task: Represents work being done that will eventually return control to the caller.
- Task<T>: Represents work being done that will eventually return an object of type T to the caller.
- void: Makes the method a true fire-and-forget method.
However, the number of valid use-cases for returning void is remarkably small. This is primarily due to the fact that, when returning void, the system will have no idea when (or even if) the method ever completes. Further, exception handling gets really stupid when returning void. Therefore the best recommendation is to not return void from asynchronous tasks (although there is one notable exception, and that is event handlers).
All of the above fundamentals are true whether you're writing a new asynchronous ASP.NET application from scratch or refactoring a synchronous app toward async and await. But there is an additional guideline that we want to observe when refactoring a synchronous app into an asynchronous one.
Refactor "Bottom-Up" For Less Dependencies
Because asynchronous programming spreads like a virus, your best bet when refactoring toward it is to start at the lowest possible level of your data architecture, and work up.
Here's a slide from my talk at CodeMash, which we'll use to illustrate what this guideline means.
In this data model, the "root" object is a User. Users have Albums and Posts, and Albums further contain Photos.
The guideline states that we should begin the refactoring at the lowest level of the data architecture, which in this case is Photo (though you can make an argument for starting with Post). This is because Photo does not have any dependencies on other data objects. Since no such dependencies exist, when we refactor Photo we most likely won't have to worry about the virus problem quite yet.
Implementing asynchronous programming (AKA async/await) in ASP.NET allows for a system to handle many more requests on the same hardware, increasing the throughput (and not the performance) of said system. We do this by encapsulating I/O-bound tasks. Asynchronous spreads like a virus, and though this is normally a good thing, you still need to keep that in mind when deciding where in your application to begin implementation.
This guide is open for suggestions! I intend for this to truly be the "ultimate" guide to implementing asynchronous programming in ASP.NET. If you have suggestions for what should be included here, let me know in the comments!
Bonus points to anyone who figures out what those very odd picture captions are about (without using Google). Answers are in the comments below!
In my next post, we will take a working synchronous ASP.NET web application and fully convert it to an asynchronous app. Along the way, we'll see a couple of the common problems that can arise when doing so. Come along to see precisely how badly we can screw up!
Thanks for reading, and happy coding!