To await or not - C# and asynchronous programming

Since the introduction of async/await in C# 5 (2012), new developers still struggle to understand how it works and when to use it. I have conducted numerous interviews in which even experienced developers have struggled to explain how async/await works. In this article, I will try to explain what is async/await, how it works, how to use it and what to avoid when using it.

In C#, the await keyword is used to asynchronously wait for a task to complete. When an asynchronous method is called, it returns a task that represents the ongoing work. The await keyword, when encountered, allows returning control to the calling method while the called method continues executing the task. Once, the awaited task is complete, the calling method continues to run the code following the awaited method call.

On the face of it, it seems as though a new thread to run the awaited method is created because how can control be returned to the caller if the same thread is being used? This misconception catches both new and experienced developers. One critical idea to understand is that async/await is aimed at I/O tasks. In synchronous programming, it means the calling thread is blocked and waits for the result of the I/O operation. Using await means that the calling thread does not have to be blocked and can be used for other tasks too while waiting for the I/O task result. Under the hood, a state machine is created that then allows execution to continue after the awaited task is complete. I will not go into details about the state machine but you can read a good article here.

Async/await provides a cleaner way of writing asynchronous code. Before async/await, C# developers had other ways of achieving asynchronous programming including background worker, threads and Task Parallel Library (TPL). The async keyword is used to mark a method as asynchronous. A method marked as async must always return a Task or Task<T>. A caller of this method can use the await keyword in front of the method call. Let's look at an example to make this concrete:

public async Task<string> GetDataAsync()
{
    using (HttpClient client = new HttpClient())
    {
        var response = await client.GetAsync("https://api.example.com/data");
        return await response.Content.ReadAsStringAsync();
    }
}

private async Task PrintData()
{
    var data = await GetDataAsync();
    Console.WriteLine(data);
}

In the above code snippet, when PrintData calls GetDataAsync, the control flow returns to the caller of PrintData while GetDataAsync is executing. The code after the call to GetDataAsync does not execute. However, once GetDataAsync completes executing, PrintData then continues to execute the rest of the code in the method. Notice that, the call to GetDataAsync is non-blocking but that does not mean the code after the call to GetDataAsync will execute before the task completes.

Using await has several benefits. First, it allows for more efficient use of resources. When a method is awaited, the calling code can continue executing, rather than being blocked until the task completes. This can lead to a significant boost in performance, especially when working with I/O-bound operations such as network or file access.

Another benefit of using await is that it makes asynchronous code easier to read and understand. Without await, asynchronous code can easily become complex and difficult to follow, with multiple levels of nested callbacks. By using await, the code can be written more linearly and predictably, making it easier to debug and maintain.

However, there are scenarios where it might not be appropriate to use await. For example, if an operation is expected to complete immediately, using await might add unnecessary overhead. In these cases, it might be better to simply use synchronous code.

Additionally, if an application requires a high degree of parallelism, it might be more appropriate to use the Task.Run method to run operations in parallel, rather than using await. Let's look at the example below. Notice that CalculateTotal awaits the Calculate method. There are several issues with this code. First, the operations in Calculate are non-complex and the method will likely return immediately.

public async Task<decimal> Calculate(int quantity, decimal price)
{
    decimal subtotal = quantity * price;
    decimal total = subtotal += subtotal * VAT_CONST;
}

private async Task CalculateTotal(int quantity, decimal price)
{
    decimal total = await Calculate(quantity, price);
    Console.WriteLine(total);
}

Using await in this scenario is most likely to create more overheads than using synchronous code. Remember that a call to await will result in a state machine that keeps track of the state of each awaited task.

The second issue with the above code is that Calculate is a CPU-bound task. The method is still consuming CPU cycles and await offers no benefit here. In many cases where code is written this way, the developer's intention is usually to run the task in the background. In such a scenario, Task.Run may be more suitable.

In general, whether to use await or not depends on the specific requirements of the task and the nature of the operations being performed. For I/O-bound operations, await is generally recommended to improve performance and readability. For CPU-bound operations, it may be more appropriate to use synchronous code or the Task.Run method.