Josh Fischer - .NET and C# Consultant

.NET, C#, Azure, WinUI, Wpf, Uno, Sql, Visual Studio, Webassembly

When async/await Is Not Asyncronous

- Posted in .NET by

Numerous times in my career I have come across code that appears to be asynchronous, or multi-threaded, but the tasks are actually executed one at a time. This usually happens when existing code is retrofitted to use async/await or when there are layered function calls and complicated logic.

The Problem

To demonstrate the problem, I will use the simple example of baking a cake. Afterward, I will list some ways you can spot this problem even when the code is complex or the function calls are extremely layered.

Note: proper error handling is omitted from the code for simplicity

class Program
{
    public static void Main(string[] args)
    {
        var stopwatch = new System.Diagnostics.Stopwatch();
        stopwatch.Start();
        string cakeName = new Baker().BakeACake().Name;
        stopwatch.Stop();
        // FOOBAR: 440
        Console.WriteLine($"{cakeName}: {stopwatch.ElapsedMilliseconds}");
        Console.ReadLine();
    }
}

public class Cake
{
    public string Name { get; private set; }
    public Cake(string ingredients, string recipe) => Name = ingredients + recipe;
}

public class Baker
{
    public Cake BakeACake()
    {
        string ingredients = GetIngredients();
        string recipe = GetRecipe();
        Cake cake = PutInOven(ingredients, recipe);
        UpdateMenu(cake.Name);
        return cake;
    }

    private string GetIngredients()
    {
        Thread.Sleep(50); // HTTP GET 50 ms
        return "FOO";
    }

    private string GetRecipe()
    {
        Thread.Sleep(50); // HTTP GET 50 ms
        return "BAR";
    }

    private Cake PutInOven(string ingredients, string recipe)
    {
        Thread.Sleep(100); // Complex calculation 100 ms
        return new Cake(ingredients, recipe);
    }

    /* HTTP POST 200 ms */
    private void UpdateMenu(string cakeName) => Thread.Sleep(200);
}

As you can see, our Baker is going to bake a cake using just strings (yum!) and then update the menu. We will assume each of the steps involves a non-trivial database or network call. I listed some hypothetical call times that we can use as an example. The code is completely synchronous and each function must complete before the next one starts. This gives us a time of 400 ms (plus execution time) to bake the cake.

Now, I'm sure you know what happens next. The customers call and complain that baking a cake is too slow so your boss asks someone to speed it up. Knowing that .NET has this cool async/await feature, someone modifies the code to make it "asynchronous".

class Program2
{
    public static void Main(string[] args)
    {
        var stopwatch = new System.Diagnostics.Stopwatch();
        stopwatch.Start();
        string cakeName = "???";
        Task.Run(async () => cakeName = (await new Baker().BakeACake()).Name);
        stopwatch.Stop();
        Console.WriteLine($"{cakeName}: {stopwatch.ElapsedMilliseconds}");
        Console.ReadLine();
    }
}

public class Cake
{
    public string Name { get; private set; }
    public Cake(string ingredients, string recipe) => Name = ingredients + recipe;
}

public class Baker
{
    public async Task<Cake> BakeACake()
    {
        string ingredients = await GetIngredients();
        string recipe = await GetRecipe();
        Cake cake = await PutInOven(ingredients, recipe);
        await UpdateMenu(cake.Name);
        return cake;
    }

    private async Task<string> GetIngredients()
    {
        await Task.Delay(50); // HTTP GET 50 ms
        return "FOO";
    }

    private async Task<string> GetRecipe()
    {
        await Task.Delay(50); // HTTP GET 50 ms
        return "BAR";
    }

    private async Task<Cake> PutInOven(string ingredients, string recipe)
    {
        await Task.Delay(100); // Complex calculation 100 ms
        return new Cake(ingredients, recipe);
    }

    /* HTTP POST 200 ms */
    private async Task UpdateMenu(string cakeName) => await Task.Delay(200);
}

Wow, not only is this code asynchronous, the function returns in a matter of milliseconds! Of course, I'm being sarcastic. While a bunch of ceremony was added to the code, nothing with the flow changed for the better. In fact, the code is now worse because it gives the impression that the cake was baked when it has not been. This kind of code results in database errors, 404s, etc that "magically" fix themselves when the user refreshes the screen or page. What's really happening is that the original Task completes in between refreshes making the "not found" error go away.

Putting aside the fire-and-forget nature of the initial call, the main problem is that the BakeACake function is still synchronous. We've added the option of making it asynchronous, but the functions are still serially executed because each one is awaited individually and in the original order. It's at this point that you need to move past syntactic sugar and truly evaluate the logic, the goals of the function/class, and possibly make changes to the overall workflow.

Here are some observations we can make:

  • The UpdateMenu function takes the longest, but it doesn't actually have anything to do with the baking of the cake.
  • The ingredients and recipe have nothing to do with one another; they can be loaded at the same time.
  • The PutInOven function requires the ingredients and recipe so it must wait for those tasks to complete before executing.
  • We can see that the caller is only interested in the name of the cake. We might not need to return the Cake object.

The Solution

// Showing changes only.
class Program3
{
    public static void Main(string[] args)
    {
        var stopwatch = new System.Diagnostics.Stopwatch();
        stopwatch.Start();
        new Baker().BakeACake()
            .ContinueWith(async cakeTask =>
            {
                string? cakeName = await cakeTask;
                stopwatch.Stop();
                // FOOBAR: 272
                Console.WriteLine($"{cakeName}: {stopwatch.ElapsedMilliseconds}");
            });
        Console.ReadLine();
    }
}

public class Cake
{
    public string Name { get; private set; }
    public Cake(string ingredients, string recipe) => Name = GenerateName(ingredients, recipe);

    public static string GenerateName(string ingredients, string recipe) => ingredients + recipe;
}

public class Baker
{
    public async Task<string?> BakeACake()
    {
        string? cakeName = null;
        string? tempCakeName = null;
        try
        {
            var ingredientsTask = GetIngredients();
            var recipeTask = GetRecipe();
            await Task.WhenAll(ingredientsTask, recipeTask);
            // I avoid using .Result whenever possible.
            string ingredients = await ingredientsTask;
            string recipe = await recipeTask;

            tempCakeName = Cake.GenerateName(ingredients, recipe);
            var menuTask = UpdateMenu(tempCakeName);
            var ovenTask = PutInOven(ingredients, recipe);
            await Task.WhenAll(menuTask, ovenTask);
            cakeName = (await ovenTask).Name;
        }
        catch // ... error handling and logging logic omitted
        {
            if (tempCakeName != null)
            {
                _ = UndoMenuUpdate(tempCakeName);
            }
        }
        return cakeName;
    }

    // GetIngredients, GetRecipe, PutInOven, and UpdateMenu unchanged.

    /* HTTP DELETE 50 ms */
    private async Task UndoMenuUpdate(string cakeName) => await Task.Delay(50);
}

The changes above cut the total execution time by 170ms (~40%) and allowed us to execute functions in a truly asynchronous manner. The important changes are:

  • The ingredients and recipe were loaded at the same time so we only need to wait on the slowest operation, not both, before baking.
  • The cake name generation was exposed as a public static function to enable the menu to be updated sooner. There is a tradeoff here because now the name of the cake exists in two places. However, if we are diligent in managing the copies and the generation function, the savings in time is worth the duplication.
  • We changed the workflow to execute the slowest operation as soon as possible. Namely, we are going to update the menu "first" and assume the cake will be baked successfully. If either operation fails, we will rollback the menu change. In this example, this is an acceptable risk because the chances of failure are low and potentially showing an unavailable cake for a few milliseconds is not the end of the world. A UndoMenuUpdate function was added which, for simplicity, we will assume works whenever an error occurs.
  • The logic in the calling function, Main, was changed to continue only after the baking Task was complete. No more fire-and-forget pattern with false reporting to the user/caller.

Real World Application

Hopefully it was easy to see how the example above could be optimized. I'm sure there are more or different ways to optimize it further. In the real world, however, problems like this are not always easy to recognize or diagnose. Visual Studio, and other tools, provide numerous ways to observe the threads and tasks in your application as well as diagnose efficiency. Those tools are beyond the scope of this article, but there are ways you can detect misapplied asynchronous logic.

  • Fire-and-forget Tasks. When a Task is started with nothing monitoring it or waiting for its completion, it is a red flag. It is not necessarily wrong, but should be done with caution and intention.
  • Many "await" keywords grouped together. If you see many calls being awaited close together, it's an indication that the function might be doing too much or there is room to change the logic to be more efficient.
  • There are distinct sets of asynchronous functions (like in our example above) but no outputs from the operations are used by the subsequent groups of code.
  • Understanding the difference between when something must absolutely happen versus when it can be assumed to happen 99.999% of the time. Both the examples below use the word "must", but they are in very different contexts and have very different real-world outcomes. In the second statement, there is probably room for the business rule to be change to allow better efficiency.
  • Before the code in a medical device zaps a patient with radiation, it MUST check the current radiation level of the device.
  • Before the phone number can be written to the database, the email MUST be written first.

Here are some questions that can help you find problems in your code.

  • When a given asynchronous function is executed, what is the caller doing while it runs? Do we even know; do we care?
  • When the current Task is paused on an await statement, is there anything that could be done while it waits? If so, start the task, do the side work, then await the Tasks's result.
  • What are the actual inputs needed for a given function? Do I need to wait for the entire object/record to be retrieved, or could the function execute with a smaller set of inputs?
  • If this asynchronous operation, or Task, fails, what are the consequences? How does it affect the downstream workflow?
  • When a Task is started, who is responsible for handling the result and any errors that might occur inside of it?
  • Can my workflow handle asynchronous operations that take less than one millisecond to complete? What if the time jumps to sixty seconds?
  • Can my function be broken into smaller pieces allowing clients to piece together the results as they see fit and on their own timeframe?