Asynchronous Programming in .NET - Introduction, Misconceptions, and Problems

July 26, 2022

image

I’ve found it’s easy to write asynchronous code that runs successfully, but small issues can have profound effects on the scalability and performance of your applications, making it challenging to get right. Simply put, it’s a hard concept to understand. In some cases, developer’s may not have a great foundation for why they are writing asynchronous code in the first place. In .NET, asynchronous programming has different objectives based on the type of application you’re working on. Are you working on a desktop client application? Are you working on a web application? In these two classes of applications you will utilize asynchronous programming, but why and how are different. This leads to some confusion, where code and concepts used for desktop applications are then used in the context of a web application and vice versa.

After asynchronous code is written, it can be hard to reason about and test. When you run your web application on your laptop everything works fine. After it’s initially deployed it also behaves as you would expect. Then it gets popular. You start running into issues. Problems can be hard to identify until it’s too late and you have a production outage. This article attempts to explain some of the common misconceptions and problems that can occur when writing asynchronous code. Additionally, it is meant to be a detailed introduction to asynchronous concepts and programming via async / await in C#. Without the right mental model for asynchronous programming concepts, developers may continue to make the same mistakes. Much of the advice and technical descriptions are specific to ASP.NET web applications, as I’ve found this is where the most confusion lies. UI applications will be discussed in order to highlight the differences between the two types of applications. As a developer, it’s important to understand the asynchronous code you write may differ depending on the type of application you’re building.

Synchronous vs. Asynchronous Programming

In a program, synchronous execution means the first task in a program must finish processing before moving on to executing the second task. In contrast, with asynchronous execution, the program can move to other tasks before the previous one finishes. Determining when you should write asynchronous code becomes easier when you understand the concepts of “I/O-bound” and “CPU-bound” code.

CPU-Bound vs. I/O-Bound

The phrase “CPU-bound” describes a scenario where the execution of code is dependent mainly on the CPU. For example, calculating the digits of PI to a certain length would be CPU-bound. In this example, if our CPU was faster our code would in turn run faster. In contrast, a task or program is “I/O-bound” if its execution is dependent on the input/output system, such as disk drives when reading a file or a networking adapter when making an HTTP request. When executing an I/O-bound task, the computing device spends its time performing input/output operations, and other resources, such as the CPU, are used in small amounts or potentially not at all. Both scenarios may require asynchronous code to be written using async / await in C#, but depending on the context, the implementation may differ.

Identifying I/O-Bound and CPU-Bound Code

Microsoft’s article, Asynchronous Programming has great advice about identifying code that is I/O-bound vs CPU-bound and how you should approach the implementation for each.

It’s key that you can identify when a job you need to do is I/O-bound or CPU-bound because it can greatly affect the performance of your code and could potentially lead to misusing certain constructs.

Here are two questions you should ask before you write any code:

  1. Will your code be “waiting” for something, such as data from a database? If your answer is “yes”, then your work is I/O-bound.
  2. Will your code be performing an expensive computation? If you answered “yes”, then your work is CPU-bound.

If the work you have is I/O-bound, use async and await without Task.Run. You should not use the Task Parallel Library. The reason for this is outlined in Async in Depth.

If the work you have is CPU-bound and you care about responsiveness, use async and await, but spawn off the work on another thread with Task.Run. If the work is appropriate for concurrency and parallelism, also consider using the Task Parallel Library.*

Additionally, you should always measure the execution of your code. For example, you may find yourself in a situation where your CPU-bound work is not costly enough compared with the overhead of context switches when multithreading.

*This advice about using Task.Run for CPU-bound code is less applicable in the context of ASP.NET web applications.

An I/O-Bound Async / Await Example in .NET

Imagine you want to execute a request to a weather API that returns forecast data from an ASP.NET web application. You might use the HttpClient class and its asynchronous methods to do so. An example of how to do that can be seen below:

public async Task<Forecast> DownloadForecastAsync()
{
    using HttpClient client = new HttpClient();
    HttpResponseMessage response = await client.GetAsync("https://api.weather/forecast");

    if (!response.IsSuccessStatusCode)
    {
        throw new Exception("Unable to get weather data!");
    }

    string content = await response.Content.ReadAsStringAsync();
    return JsonSerializer.Deserialize<Forecast>(content);
}

An async method should return a Task or Task<T> where T is the type of data we want to return. In our example, our method’s return type is Task<Forecast>. The HttpClient method GetAsync is asynchronous, so we use the await keyword when executing the method. In order to use the await keyword, we need to make sure our method specifies the async modifier before the return type. Specifying the async modifier is what enables the await keyword within the DownloadForecastAsync method. When we await the GetAsync method, our code will not proceed further until the HTTP request initiated by the GetAsync method completes. This is the I/O-bound part of our code. Once the GetAsync method completes, our method will continue to execute. When we reach the ReadAsStringAsync method our code will await again before processing further because ReadAsStringAsync is an async method. Finally, we deserialize the forecast data returned from the API to the Forecast type and return it from our method.

This method might not seem very different than a synchronous method you might write, except for the fancy new keywords we had to add to our method. One thing that should be kept in mind is the await keyword is doing some special work behind the scenes. Heres a brief explanation of the await keyword from the C# reference.

The await operator suspends evaluation of the enclosing async method until the asynchronous operation represented by its operand completes. When the asynchronous operation completes, the await operator returns the result of the operation, if any. When the await operator is applied to the operand that represents an already completed operation, it returns the result of the operation immediately without suspension of the enclosing method. The await operator doesn’t block the thread that evaluates the async method. When the await operator suspends the enclosing async method, the control returns to the caller of the method.

A simple example, but most I/O-bound code you come across in ASP.NET web applications will be very similar.

A CPU-Bound Async / Await Example in .NET

Imagine you wanted to calculate the cosine for each number between 0 and 99 million and store the results in a list. You might write the following code.

public List<double> CalculateCosine()
{
    var list = new List<double>();
    for (var i = 0; i <= 99_000_000; i++)
    {
        list.Add((int)Math.Cos(i));
    }
    return list;
}

The work required to calculate the cosine for each number will take a considerable amount of time. However, the work does not require waiting like the I/O-bound code example. The CPU is going to be doing all the work in this example. If we wanted to make this method asynchronous, we would wrap the CPU-bound code with Task.Run which returns a Task that can be awaited.

public async Task<List<double>> CalculateCosine()
{
    var results = await Task.Run(() =>
    {
        var list = new List<double>();
        for (var i = 0; i <= 99_000_000; i++)
        {
            list.Add((int)Math.Cos(i));
        }
        return list;
    });

    return results;
}

Now, similar to the I/O-bound example, our method specifies the async modifier and returns a Task<List<double>> that can be awaited. We’ve successfully updated our CPU-bound synchronous method so that it is asynchronous.

Why Should You Use Asynchronous Programming?

So you’ve read through the two examples above and you may be wondering, what’s the benefit of making your code asynchronous? Stephen Toub describes the benefits of asynchronous programming in his article, Should I expose asynchronous wrappers for synchronous methods?.

There are two primary benefits I see to asynchrony: scalability and offloading (e.g. responsiveness, parallelism). Which of these benefits matters to you is typically dictated by the kind of application you’re writing. Most client apps care about asynchrony for offloading reasons, such as maintaining responsiveness of the UI thread, though there are certainly cases where scalability matters to a client as well. Most server apps care about asynchrony for scalability reasons, though there are cases where offloading matters, such as in achieving parallelism in back-end compute servers.

In general, we use async / await in ASP.NET web applications to ensure the application works efficiently as possible in order to scale. For UI applications, we use async / await to ensure the main thread is never blocked which provides a responsive interface for our users. We’ll go into more detail for each scenario after we get some technical concepts out of the way.

Asynchrony is Viral

The phrase “asynchrony is viral” is an important one to remember when you first start writing async code. Once you write and call an async method, all of your callers should be async otherwise efforts to be async achieve nothing unless the entire call stack is async. In most cases, being partially async is even worse than being entirely synchronous. To really achieve the benefits of async code, you must ensure the entire chain of method calls are async. If not, this can lead to a host of different problems we’ll cover later.

Threads and the Thread-Pool

In C#, a thread is a basic unit of execution within the process, and it is responsible for executing the application logic. The thread-pool is a collection of threads created during the initialization of an application and provides a pool of reusable threads for new tasks. Every process has a fixed number of threads depending on the amount of memory available and other factors. Every thread in the pool can be given a specific task. When the work is completed, the thread returns to the pool and waits for more work. Async methods are intended to be non-blocking operations. Using the await expression when calling an async method does not block the currently executing thread while the awaited task is executing. Instead, the await expression signs up the rest of the method as a “continuation” and returns control to the caller of the async method. This frees the thread up to perform other work.

The thread-pool is controlled by the .NET runtime and additional threads may be created and added to the pool as the runtime sees fit. When all the threads in the thread-pool are busy, the work will be queued for processing until more threads return to the pool or are created.

The Task Type

Microsoft’s article Async in Depth has a good explanation of the Task type.

Tasks are constructs used to implement what is known as the Promise Model of Concurrency. In short, they offer you a “promise” that work will be completed at a later point, letting you coordinate with the promise with a clean API.

  • Task represents a single operation that does not return a value.
  • Task<T> represents a single operation that returns a value of type T.

It’s important to reason about tasks as abstractions of work happening asynchronously, and not an abstraction over threading. By default, tasks execute on the current thread and delegate work to the operating system, as appropriate. Optionally, tasks can be explicitly requested to run on a separate thread via the Task.Run API.

Tasks expose an API protocol for monitoring, waiting upon, and accessing the result value (in the case of Task<T>) of a task. Language integration, with the await keyword, provides a higher-level abstraction for using tasks.

Using await allows your application or service to perform useful work while a task is running by yielding control to its caller until the task is done. Your code does not need to rely on callbacks or events to continue execution after the task has been completed. The language and task API integration does that for you. If you’re using Task<T>, the await keyword will additionally “unwrap” the value returned when the Task is complete.

Tasks serve many purposes, but their main purpose is functioning as a “promise”. A Promise represents the eventual completion (or failure) of an asynchronous operation and its resulting value. When you initiate an operation you get back a Task and that Task will complete when the asynchronous operation completes. Completion of the task may happen synchronously as part of initiating the operation (accessing some data that was already buffered), asynchronously but complete very quickly by the time the Task is returned (accessing some data that wasn’t yet buffered but that was very fast to access), or asynchronously and complete after the Task has been returned (accessing making an HTTP request and waiting for a response).

How Do the Async / Await Keywords Work?

Stephen Cleary provides a great explanation for how async / await works in his article, Async and Await.

The async keyword enables the await keyword in the method and changes how method results are handled. That’s all the async keyword does! It does not run this method on a thread-pool thread, or do any other kind of magic. The async keyword only enables the await keyword (and manages the method results).

The beginning of an async method is executed just like any other method. That is, it runs synchronously until it hits an await (or throws an exception). The await keyword is where things can get asynchronous. Await is like a unary operator: it takes a single argument, an awaitable (an “awaitable” is an asynchronous operation). Await examines that awaitable to see if it has already completed; if the awaitable has already completed, then the method just continues running (synchronously, just like a regular method).

If await sees that the awaitable has not completed, then it acts asynchronously. It tells the awaitable to run the remainder of the method when it completes, and then returns from the async method.

Later on, when the awaitable completes, it will execute the remainder of the async method. If you’re awaiting a built-in awaitable (such as a task), then the remainder of the async method will execute on a “context” that was captured before the await returned.

I like to think of await as an “asynchronous wait”. That is to say, the async method pauses until the awaitable is complete (so it waits), but the actual thread is not blocked (so it’s asynchronous).

Microsoft’s article, Asynchronous Programming gives a good overview of the async, await, and Task keywords as well.

The core of async programming is the Task and Task<T>objects, which model asynchronous operations. They are supported by the async and await keywords. The model is fairly simple in most cases:

  • For I/O-bound code, you await an operation that returns a Task or Task<T> inside of an async method.
  • For CPU-bound code, you await an operation that is started on a background thread with the Task.Run method.

The await keyword is where the magic happens. It yields control to the caller of the method that performed await, and it ultimately allows a UI to be responsive or a service to be elastic.

Why is it Important for Web Applications to Use Async / Await for I/O-Bound Work?

Suppose we have an ASP.NET application with an endpoint to download weather forecast data as shown below.

[ApiController]
[Route("api/weather/forecast")]
public class WeatherController : ControllerBase
{
    private readonly IWeatherService service;

    public WeatherController(IWeatherService service)
    {
        this.service = service;
    }

	[HttpGet]
    public IActionResult DownloadForecast()
    {
        Forecast data = service.GetForecastData();

        return Ok(data);
    }
}

The code for the weather service looks like this.

public class WeatherService : IWeatherService
{
    private readonly HttpClient client;

    public WeatherService(HttpClient client)
    {
        this.client = client;
    }

    public Forecast GetForecastData()
    {
        HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "http://api.weather.com/forecast");

        // This is the point where the slow weather API makes us wait 4 seconds...
        HttpResponseMessage response = client.Send(request);

        using var reader = new StreamReader(response.Content.ReadAsStream());
        string content = reader.ReadToEnd();

        return JsonSerializer.Deserialize<Forecast>(content);
    }
}

In the example above, our weather service makes a request to a weather API to retrieve the forecast data. The Send method of the HttpClient is synchronous. Suppose our application’s thread-pool contains an upper limit of 100 threads. Our application is written synchronously without using the async / await keywords. Normally, We expect each request to our application to take ~5 seconds to complete. Why does it take 5 seconds? The weather API that we need to call in our code is quite slow and we don’t control it (this is a large chunk of our I/O-bound code). Our code adds very minimal processing time to deserialize the results. On average, the weather API takes ~5 seconds to return a response. During those 5 seconds, the thread that was assigned to execute the code is waiting for the results to return, unable to proceed further. If 100 users simultaneously make requests to the application, then all 100 worker threads will be busy for the next 5 seconds. During this period, you’ll notice the CPU isn’t used very much.

Now, if a new request is received (the 101st request) our application will not be able to process this request right away because all worker threads are already busy. Our application will wait until a thread is freed from the initial burst of 100 requests, which can take ~5 seconds. Once a thread is freed, the 101st request will be processed, and as we know this will take ~5 seconds to complete. Most of this time is wasted waiting for a slow weather API to return. When the 101st request completes, the total time elapsed might be close to 10 seconds. Partly because the application couldn’t process the request right away, and partly because the weather API is slow.

In this scenario, our server is wasting resources waiting and we’re providing a bad user experience. As our API receives more traffic, it will be unable to efficiently use all the server resources. We may find ourselves setting up additional servers to deal with all the traffic, even though the CPU and memory are not used to the fullest extent. How would this scenario change if we updated our code so that it is asynchronous? The changes are shown below.

[ApiController]
[Route("api/weather")]
public class WeatherController : ControllerBase
{
    private readonly IWeatherService service;

    public WeatherController(IWeatherService service)
    {
        this.service = service;
    }

    // We've updated the method to return Task<IActionResult> and added the async modifier.
    [HttpGet]
    public async Task<IActionResult> DownloadForecastAsync()
    {
        // We've also updated the call to GetForecastDataAsync to use the await keyword.
        // Further down below you'll see how we updated the WeatherService.
        Forecast data = await service.GetForecastDataAsync();
        return Ok(data);
    }
}

public class WeatherService : IWeatherService
{
    private readonly HttpClient client;

    public WeatherService(HttpClient client)
    {
        this.client = client;
    }

    // We updated the method to return a Task<Forecast> from Forecast and added the async modifier.
    public async Task<Forecast> GetForecastDataAsync()
    {
        HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "http://api.weather.com/forecast");
        // We're using the SendAsync method now which is asynchronous and returns a Task.
        // We need to use the await keyword when calling the SendAsync method.
        // The await keyword signifies that we don't want to proceed further until this code completes.
        HttpResponseMessage response = await client.SendAsync(request);
        // The stream reading code has also been updated to use the asynchronous versions of the methods.
        using var reader = new StreamReader(await response.Content.ReadAsStreamAsync());
        string content = await reader.ReadToEndAsync();

        return JsonSerializer.Deserialize<Forecast>(content);
    }
}

For starters, you can see we’ve renamed the method to DownloadForecastAsync and updated the return type to Task<IActionResult>. We’ve also marked the method with the async modifier. When we call the GetForecastDataAsyncmethod of theWeatherService we’re also using the await keyword. We’ve made similar changes to the WeatherService GetForecastDataAsync method. Because we’ve made these changes, our action method is asynchronous and our service method is asynchronous as well (asynchrony is viral).

Suppose our application’s thread-pool still contains an upper limit of 100 threads. Our application is written asynchronously using async / await now. We know each request to our application takes ~5 seconds to complete because of the slow weather API. Because our API and service are now asynchronous, when we execute the request to the weather API, instead of waiting with nothing to do, the thread that was executing the work is returned to thread-pool to service other requests. When the slow request finally returns a response, a new thread will be chosen from the thread-pool to complete the remainder of the work.

If 100 users simultaneously make requests to the application, then all 100 worker threads will start executing code up until the very slow weather API is called. Once that happens, all 100 threads will be returned to the thread-pool while we asynchronously wait for the slow weather API to return. If a 101st request is received, there will be plenty of threads in the thread-pool to service the request. A thread will be chosen from the thread-pool and it will execute the code until the slow weather API is called. When that happens, that thread will also be returned to the thread-pool while we wait for the API to return.

Finally, for our original 100 requests, the slow weather API completes processing and returns a response. For each request, a thread will be chosen from the thread-pool to complete the remainder of the method. There isn’t much work to be done after the slow weather API returns, so the 100 requests sent to our API receive responses shortly thereafter. Our 100 threads are returned to the thread-pool, ready to perform additional work and service more requests.

In this simple example, we’re more efficiently using the resources of our server and our users get a much better experience. The article ASP.NET Core Performance Best Practices by Mike Rousos goes into more detail about ASP.NET applications and performance.

ASP.NET Core apps should be designed to process many requests simultaneously. Asynchronous APIs allow a small pool of threads to handle thousands of concurrent requests by not waiting on blocking calls. Rather than waiting on a long-running synchronous task to complete, the thread can work on another request.

A common performance problem in ASP.NET Core apps is blocking calls that could be asynchronous. Many synchronous blocking calls lead to thread-pool starvation and degraded response times.

Do not:

  • Block asynchronous execution by calling Task.Wait or Task.Result.
  • Acquire locks in common code paths. ASP.NET Core apps are most performant when architected to run code in parallel.
  • Call Task.Run and immediately await it. ASP.NET Core already runs app code on normal thread-pool threads, so calling Task.Run only results in extra unnecessary thread-pool scheduling. Even if the scheduled code would block a thread, Task.Run does not prevent that.

Why is it Important for UI Applications to Use Async / Await for I/O-Bound and CPU-Bound Work?

In the case of .NET UI applications, the primary reason we need to write asynchronous code is to keep our user interface responsive while it completes lengthy tasks. As an example, a UI application that makes requests to an API might spend several seconds waiting for the response to return. If you execute a synchronous method on the UI thread (sometimes referred to as the main thread) to do this work, your application is blocked until the request completes and the response is returned. In this scenario, the app will not respond to user interaction and may frustrate the user. Here’s what requesting data in a UI application might look like.

private async void DownloadData_Click(object sender, EventArgs e)
{
    // Since we're awaiting the async method, the UI thread will remain unblocked.
    await DownloadDataAsync();

    dataTextBox.Text = "Data has been downloaded!";
}

In the example above, we await the call to our async method which keeps the UI thread unblocked for the duration of the request. When the request completes, the remainder of the method will continue to execute.

Doing Multiple Things at Once

One benefit of using async / await that might not be apparent so far is that it allows you to write code that can start multiple operations concurrently. In the example below, we have an asynchronous method GetEmployeeAsync. Imagine an HTTP request is required to retrieve an employee and the method is I/O-bound. In our program, we want to retrieve three employees and display their names. Using what we’ve learned about the async / await keywords we might write it as shown below.

Employee employee1 = await GetEmployeeAsync(1);
Employee employee2 = await GetEmployeeAsync(2);
Employee employee3 = await GetEmployeeAsync(3);

Console.WriteLine(employee1.Name);
Console.WriteLine(employee2.Name);
Console.WriteLine(employee3.Name);

The code above does use async / await, however, each call to GetEmployeeAsync is going to run after the previous one completes. This isn’t very efficient. This is what a timeline of our program might look like.

Because the three method calls don’t rely on one another, we can execute them all at the same time, or “concurrently”. In order to accomplish this, we update our code to what is shown below.

// Create a list to hold all our tasks:
var tasks = new List<Task<Employee>>();

// Execute the async methods without awaiting them and collect the tasks:
tasks.Add(GetEmployeeAsync(1));
tasks.Add(GetEmployeeAsync(2));
tasks.Add(GetEmployeeAsync(3));

// Don't continue processing until all calls to get an employee are completed:
Employee[] employees = await Task.WhenAll(tasks);

// Do something with each employee:
foreach(var employee in employees)
{
    Console.WriteLine(employee.Name);
}

In this example, we execute the GetEmployeeAsync method without immediately using the await keyword. This is what initially kicks off the method execution. Because we aren’t using the await keyword, the Task<Employee> returned from GetEmployeeAsync isn’t unwrapped automatically, and we add it to our task list to keep track of. Finally, we use the WhenAll method on the Task type to delay processing until all our tasks complete.

In this example, we’re using asynchronous programming to do more than one thing at a time, or in “parallel” which makes our code more efficient. If each request would take ~2 seconds to complete, our code would take ~6 seconds to finish in the first example. In the second example, we execute each method without immediately awaiting and our code should finish execution in ~2 seconds. A significant improvement as shown in the timeline below.

In this example, I would prefer to say, we’re doing “multiple things at once” or concurrently. While it may look like we’re programming in parallel, I would not confuse it with parallel programming in .NET or in general. Parallel programming is more focused on completing CPU intensive tasks where the work is broken into chunks that are processed by multiple processors.

What Exactly Does Task.Run Do?

You’ve seen it mentioned a few times now, so you might be wondering what does Task.Run do? In my experience, Task.Run is the cause of a lot of confusion. The Microsoft documentation has this to say about Task.Run:

Queues the specified work to run on the thread-pool and returns a Task or Task<TResult> handle for that work.

The primary purpose of Task.Run is to execute CPU-bound code in an asynchronous way. In UI applications, Task.Run is commonly used to run CPU-bound work on a thread other than the UI thread which keeps the UI responsive to the user. However, in many ASP.NET web applications, Task.Run is misused (and abused) to run CPU-bound tasks (and sometimes async tasks). Although the code below will work, the thread-pool thread used to perform the work will be blocked for the entire duration of the GetDataSynchronously method because it is synchronous.

public async Task<List<Data>> GetDatabaseRecords(int identifier)
{
    var data = await Task.Run(() => GetDataSynchronously(identifier));
    return data;
}

In this example, no scalability benefits are gained. You’re shifting the blocking code from the initial thread to another thread-pool thread. I’ve seen applications where almost every method is wrapped in Task.Run in a misguided attempt to make all code ‘asynchronous’ and more ‘efficient’.

In the article Async Programming: Introduction to Async / Await on ASP.NET, Stephen Cleary provides another reason why abusing Task.Run can be sub-optimal:

Async and await on ASP.NET are all about I/O. They really excel at reading and writing files, database records, and REST APIs. However, they’re not good for CPU-bound tasks. You can kick off some background work by awaiting Task.Run, but there’s no point in doing so. In fact, that will actually hurt your scalability by interfering with the ASP.NET thread pool heuristics. If you have CPU-bound work to do on ASP.NET, your best bet is to just execute it directly on the request thread. As a general rule, don’t queue work to the thread pool on ASP.NET.

Common Misconceptions When Using Async / Await in .NET

There are a variety of misconceptions developers may believe regarding asynchronous code in .NET. Some of the more common ones I’ve outlined below.

Misconception #1: Awaiting the Task is What Starts the Execution of Asynchronous Methods

In the example below, when does the GetEmployeeAsync method start executing?

Task<Employee> employeeTask = GetEmployeeAsync(1);

// Do some other work...

var employee = await employeeTask;

If you said when the employeeTask is awaited, that’s incorrect. The GetEmployeeAsync method starts executing immediately when we call it.

Misconception #2: Async / Await Makes Your Code Run “Faster”

Using async / await is not about making code “run faster”, it’s going to run slower than a similar synchronous method (and utilize more memory). Instead, efficiency (or in the case of UI apps, offloading) is what is gained by using async / await. I imagine this misconception occurs when running things in parallel becomes conflated with code running faster in general.

The article “Asynchronous Programming - Async Performance: Understanding the Costs of Async and Await” explains this further.

Asynchronous methods are a powerful productivity tool, enabling you to more easily write scalable and responsive libraries and applications. It’s important to keep in mind, though, that asynchronicity is not a performance optimization for an individual operation. Taking a synchronous operation and making it asynchronous will invariably degrade the performance of that one operation, as it still needs to accomplish everything that the synchronous operation did, but now with additional constraints and considerations. A reason you care about asynchronicity, then, is performance in the aggregate: how your overall system performs when you write everything asynchronously, such that you can overlap I/O and achieve better system utilization by consuming valuable resources only when they’re actually needed for execution.

Misconception #3: Async / Await Ensures Your Code Runs on a “Background Thread”

Using async / await does not guarantee your code will run on a new thread or on a “background thread.” Async methods start running on the calling thread and do not use any thread for the asynchronous operation. After the async operation completes, the work will resume on a thread-pool thread or one defined by the synchronization context. A common misconception I hear from developers is that each time you use the await keyword the method will be immediately executed by a thread other than the current thread which is incorrect.

Additionally, if the bolded portion in the statement above confuses you, Stephen Cleary’s blog post There Is No Thread is a great read.

Misconception #4: Async / Await Creates New Threads

Using async / await does not directly create threads (nor does Task.Run). This quote from Microsoft’s Task Asynchronous Programming Model articles explains it best.

The async and await keywords don’t cause additional threads to be created. Async methods don’t require multithreading because an async method doesn’t run on its own thread. The method runs on the current synchronization context and uses time on the thread only when the method is active. You can use Task.Run to move CPU-bound work to a background thread, but a background thread doesn’t help with a process that’s just waiting for results to become available.

Asynchronous programming doesn’t create threads, it just allows more efficient use of them.

Misconception #5: Executing Synchronous Code Using Task.Run Improves Performance

Executing CPU-bound synchronous code using Task.Run (or wrapping it in an async method) is not going to make the code more efficient, or run faster. In the context of a desktop application, executing CPU-bound code using Task.Run is a good practice to ensure that the UI thread of the application remains unblocked. In the context of an ASP.NET web application, running synchronous code on a background thread using Task.Run is not going to improve performance, it’s going to add a bit of latency due to context switching from the initial thread to the background thread. In most cases, it’s better to run this CPU-bound code without using Task.Run.

In an ASP.NET web application moving this CPU-bound code to a background thread using Task.Run might make sense if you need to complete other work at the same time. You could execute two CPU-bound operations concurrently on separate threads which will run quicker than both operations run sequentially. In the end, the performance of the synchronous code won’t be improved, but you will be able to do multiple things at once. In general, if you use Task.Run to execute synchronous code on a background-thread and immediately await it, you’re wasting resources.

Misconception #6: Executing Asynchronous (I/O-Bound) Code Using Task.Run Improves Performance

In the context of an ASP.NET web application, calling a method that is already asynchronous using Task.Run and awaiting the call does not increase performance or efficiency. In fact, it has the opposite effect. You should call the async method directly using the await keyword and refrain from using Task.Run to call the method.

In the example below, we’re using Task.Run to call an I/O-bound asynchronous method to download some data.

var data = await Task.Run(async () => await DownloadDataAsync());

First, the initial thread is going to execute Task.Run which returns a Task representing the work to be done. Task.Run shifts the work to another thread-pool thread and the DownloadDataAsync method begins execution. The initial thread that started execution of Task.Run is returned to the thread-pool because the Task returned from Task.Run is awaited. Similarly, because DownloadDataAsync is asynchronous, at some point during execution an I/O-bound operation will be awaited which will return the thread-pool thread back to the thread-pool. When the I/O-bound operation completes, a thread-pool thread will finish execution of the DownloadDataAsync method. Essentially what we’ve done is forced an unnecessary context switch from the initial thread to another thread from the thread-pool. The code above should be updated to remove Task.Run and avoid this unnecessary context switch.

var data = await DownloadDataAsync();

In this case, an initial thread begins execution of DownloadDataAsync. When an I/O-bound operation is awaited within DownloadDataAsync, the initial thread will be returned to the thread-pool until the asynchronous operation completes. When it does, a thread will be retrieved from the thread-pool to complete the work.

In the first example, the unnecessary context switch might not seem like much, but if our goal is to improve the scalability of our ASP.NET web application, we’re wasting resources for no benefit. At higher scales, you will see a negative performance impact from this unnecessary switch.

The Biggest Problem (Sync-Over-Async)

Another way of saying sync-over-async, is “executing asynchronous code synchronously instead of asynchronously”. It is the most common problem in asynchronous code written for ASP.NET web applications. At some point in your programming career you may have written code like this:

public string GetData()
{
    var response = client.GetAsync("www.api.com/data").Result;
}

The GetData method is a synchronous method and the signature cannot be changed. However, you really need to call the GetAsync method of the client, which is asynchronous. Because the GetData method is synchronous, you cannot use the await keyword when calling the GetAsync method. Additionally, assume the GetData method has been implemented as part of an interface, so you’re unable to change the signature easily. As a shortcut, you decide to use the Result property on the task returned from the GetAsync method to access the response. Microsoft’s documentation of the Result property has this to say, emphasis mine:

Gets the result value of this Task<TResult>. Accessing the [Result] property’s get accessor blocks the calling thread until the asynchronous operation is complete; it is equivalent to calling the Wait method. Once the result of an operation is available, it is stored and is returned immediately on subsequent calls to the Result property. Note that, if an exception occurred during the operation of the task, or if the task has been canceled, the Result property does not return a value. Instead, attempting to access the property value throws an AggregateException exception.

The Result property provides a way for a caller to access the value of the Task<T> returned by the asynchronous method without using the async / await keywords. Similarly, you might see code like this:

public string GetData()
{
    var data = client.GetAsync("www.api.com/data").GetAwaiter().GetResult();
}

Using the Task.GetAwaiter().GetResult() methods are preferred over Task.Result and Task.Wait because it propagates exceptions instead of wrapping them in an AggregateException like Result does. However, all three methods create the potential for deadlocks and thread-pool starvation-related issues. They should all be avoided in favor of the async / await keywords, if possible.

David Fowler of the .NET team at Microsoft explains why this is bad in his “Asynchronous Programming Guidance” document.

There are very few ways to use Task.Result and Task.Wait correctly so the general advice is to completely avoid using them in your code. Using Task.Result or Task.Wait to block wait on an asynchronous operation to complete is MUCH worse than calling a truly synchronous API to block. This phenomenon is dubbed “sync-over-async”. Here is what happens at a very high level:

  • An asynchronous operation is kicked off.
  • The calling thread is blocked waiting for that operation to complete.
  • When the asynchronous operation completes, it unblocks the code waiting on that operation. This takes place on another thread.

The result is that we need to use 2 threads instead of 1 to complete synchronous operations. This usually leads to thread-pool starvation and results in service outages.

David follows up this advice with a number of examples of bad async code using different combinations of the Task.Result property, Task.GetAwaiter().GetResult(), and Task.Wait() methods.

public string DoOperationBlocking()
{
    // Bad - Blocking the thread that enters.
    // DoAsyncOperation will be scheduled on the default task scheduler, and remove the risk of deadlocking.
    // In the case of an exception, this method will throw an AggregateException wrapping the original exception.
    return Task.Run(() => DoAsyncOperation()).Result;
}

public string DoOperationBlocking2()
{
    // Bad - Blocking the thread that enters.
    // DoAsyncOperation will be scheduled on the default task scheduler, and remove the risk of deadlocking.
    // In the case of an exception, this method will throw the exception without wrapping it in an AggregateException.
    return Task.Run(() => DoAsyncOperation()).GetAwaiter().GetResult();
}

public string DoOperationBlocking3()
{
    // Bad - Blocking the thread that enters, and blocking the thread-pool thread inside.
    // In the case of an exception, this method will throw an AggregateException containing another AggregateException, containing the original exception.
    return Task.Run(() => DoAsyncOperation().Result).Result;
}

public string DoOperationBlocking4()
{
    // Bad - Blocking the thread that enters, and blocking the thread-pool thread inside.
    return Task.Run(() => DoAsyncOperation().GetAwaiter().GetResult()).GetAwaiter().GetResult();
}

public string DoOperationBlocking5()
{
    // Bad - Blocking the thread that enters.
    // Bad - No effort has been made to prevent a present SynchronizationContext from becoming deadlocked.
    // In the case of an exception, this method will throw an AggregateException wrapping the original exception.
    return DoAsyncOperation().Result;
}

public string DoOperationBlocking6()
{
    // Bad - Blocking the thread that enters.
    // Bad - No effort has been made to prevent a present SynchronizationContext from becoming deadlocked.
    return DoAsyncOperation().GetAwaiter().GetResult();
}

public string DoOperationBlocking7()
{
    // Bad - Blocking the thread that enters.
    // Bad - No effort has been made to prevent a present SynchronizationContext from becoming deadlocked.
    var task = DoAsyncOperation();
    task.Wait();
    return task.GetAwaiter().GetResult();
}

In the context of an ASP.NET web application, writing code that resembles the examples above can lead to problems, most notably, thread-pool starvation. Thread-pool starvation is a tricky problem that can be hard to debug. Usually, you won’t know there’s an issue until it’s too late. In general, you should avoid using blocking APIs where possible e.g. Task.Result, GetAwaiter().GetResult(), Task.Wait(), and Thread.Sleep.

There are certain scenarios where using Task.Result (or better GetAwaiter().GetResult()) make sense, but you should avoid using either unless absolutely necessary and you understand why you’re doing it.

What is Thread-Pool Starvation?

To illustrate this problem, consider a web application with a method GetEmployee that is written as shown below.

public IActionResult GetEmployee()
{
    var employee = GetEmployeeAsync().Wait();
    return Ok(employee);
}

The method GetEmployeeAsync has a good asynchronous implementation, using async I/O under the covers such that no threading resources are consumed for the vast majority of its execution; only at the very end of execution does it need to do a small amount of processing to handle the results received from the asynchronous I/O, and it’ll do this processing internally by queuing the work to the thread-pool. This is a common phenomenon in asynchronous implementations. Let’s say that someone has set an upper limit on the number of threads in the thread-pool to 25 using ThreadPool.SetMaxThreads.

Now, you call GetEmployeeAsync synchronously using Wait() from a thread-pool thread. What will happen? GetEmployeeAsync is invoked, it kicks off the asynchronous operation, and then immediately blocks the thread-pool thread waiting for the operation to complete. When the async I/O eventually completes, it queues a work item to the thread-pool to complete the processing, and that work item will be handled by one of the 24 free threads (1 of the threads is currently blocked.) Two threads are used for an operation that could be done with just one: one waiting actively on the Wait() method call and another one performing the continuation.

Now, let’s say that instead of one call to GetEmployee, 25 calls are made instead. Each of the 25 threads in the thread-pool will pick up a task, and invoke GetEmployeeAsync. Each of those calls will start the asynchronous I/O and will block until the work completes. The async I/O for all of the 25 GetEmployeeAsync calls will be completed but will result in the final processing work items getting queued to the pool. The pool threads are now all blocked waiting for the calls to GetEmployee to complete, which won’t happen until the queued work items get processed, which won’t happen until threads become available, which won’t happen until the calls to GetEmployee complete. Our app is now deadlocked.

Thread-pool starvation is an application state where the thread-pool is emptied with no threads readily available to service the remainder of work items. This causes the application to stall or respond slowly. This stalled time shows up as a “longer” HTTP request or database query. Latency in your application increases and so does the thread-pool queue.

Thread-pool starvation is a particularly annoying problem, as it usually doesn’t rear its head until a certain level of traffic is reached. That level is also dependent on your application and will vary from case to case. This is why performance / load testing is so important.

In the next part of this article series, I’ll performance test many of the sync-over-async problems outlined in this introduction to demonstrate how badly they can affect the performance of your application.

Full Async Reading List

The following articles were pivotal in my own understanding of asynchronous concepts and I recommend reading them to fully understand certain concepts.

A Tour of Task by Stephen Cleary
Task.Run Etiquette and Proper Usage by Stephen Cleary
There Is No Thread by Stephen Cleary
Eliding Async and Await by Stephen Cleary
Async Programming: Introduction to Async / Await on ASP.NET by Stephen Cleary
Async and Await by Stephen Cleary
ASP.NET Core Diagnostic Scenarios by David Fowler
Prefer ValueTask to Task, always; and don’t await twice by Marc Gravell
Two Ways to Do Async / Await in ASP.NET Wrong (and How to Fix Them) by Matthew Jones
The Ultimate Guide to Async and Await in C# and ASP.NET by Matthew Jones
Asynchronous Programming by Microsoft
Async in depth by Microsoft
Diagnosing thread-pool exhaustion issues in .NET Core apps by Microsoft
Diagnosing .NET Core ThreadPool Starvation with PerfView by Microsoft
ASP.NET Core Performance Best Practices by Mike Rousos
The performance characteristics of async methods in C# by Sergey
Dissecting the async methods in C# by Sergey
Should I expose synchronous wrappers for asynchronous methods? by Stephen Toub
Should I expose asynchronous wrappers for synchronous methods? by Stephen Toub
Understanding the Whys, Whats, and Whens of ValueTask by Stephen Toub
ConfigureAwait FAQ by Stephen Toub
Asynchronous Programming - Async Performance by Stephen Toub
Tasks are (still) not threads and async is not parallel by Ben Williams


Written by William Applegate

© 2023