DEV Community

loading...
Cover image for Asynchronous C#: Making a simple Cup of Tea (Part 2)

Asynchronous C#: Making a simple Cup of Tea (Part 2)

Paula Fahmy
I code stuff, my console's font color is not green tho 👨‍💻
Updated on ・6 min read

Last time, we had an introductory walkthrough to C#'s way of "asynchronizing" code. We talked about the difference between "multithreading" and "multiprocessing", and we gave an example to illustrate the subject where we made a simple cup of tea.

Ok, let's open the hood once more and get our hands dirty. The code now supports asynchronous operation and our UI does not freeze when we wait for a task. Next up, let's introduce some minor changes to allow our program to finish preparing our tea faster.

Some notes to remember from the previous part of the article before we move on:

  • Calling an async function (generally: a function returning a Task or a Task<>) results in the creation of a "hot task", a task that runs immediately once created.
  • At some point, we'll have to await the task to get its result.

Addressing Synchronous code Issues

Issue #2: Slow Execution

static async Task MakeTeaAsync()
{
    var water = await BoilWaterAsync(); // takes 3 seconds
    var cups = await PrepareCupsAsync(2); // 3 seconds for each cup (6 seconds in total)
    Console.WriteLine($"Pouring {water} into {cups}");

    cups = "cups with tea";

    var warmMilk = await WarmupMilkAsync(); // this line takes 5 seconds
    // ~14 seconds in total..
    Console.WriteLine($"Adding {warmMilk} into {cups}");
}
Enter fullscreen mode Exit fullscreen mode

Since we can split "awaiting" the task from creating it:

static async Task MakeTeaAsync()
{
    Task<string> waterBoilingTask = BoilWaterAsync();
    string water = await waterBoilingTask;

    Task<string> preparingCupsTask = PrepareCupsAsync(2);
    string cups = await preparingCupsTask;

    Console.WriteLine($"Pouring {water} into {cups}");

    cups = "cups with tea";

    Task<string> warmingMilkTask = WarmupMilkAsync();
    string warmMilk = await warmingMilkTask;

    Console.WriteLine($"Adding {warmMilk} into {cups}");
}
Enter fullscreen mode Exit fullscreen mode

It is important to note down the following:

The composition of an asynchronous operation followed by synchronous work is an asynchronous operation. Stated another way, if any portion of an operation is asynchronous, the entire operation is asynchronous.

Now let's reorder the logic.
Since the tasks are independent of each other, we can launch them all together at once. We know for a fact that we can't pour the milk unless we have the boiled water poured on top of the tea first.

static async Task MakeTeaAsync()
{
    var waterBoilingTask = BoilWaterAsync();
    var preparingCupsTask = PrepareCupsAsync(2);
    var warmingMilkTask = WarmupMilkAsync();

    var cups = await preparingCupsTask;
    var water = await waterBoilingTask;

    Console.WriteLine($"Pouring {water} into {cups}");
    cups = "cups with tea";

    var warmMilk = await warmingMilkTask;
    Console.WriteLine($"Adding {warmMilk} into {cups}");
}
Enter fullscreen mode Exit fullscreen mode
Start the kettle
Waiting for the kettle
Taking cup #1 out.
Putting tea and sugar in the cup
Pouring milk into a container
Putting the container in microwave
Warming up the milk
Taking cup #2 out.
Putting tea and sugar in the cup
Kettle Finished Boiling
Finished warming up the milk
Finished preparing the cups
Pouring Hot water into cups
Adding Warm Milk into cups with tea
-------------------------------
Time Elapsed: 6.1258995999999994 seconds
Enter fullscreen mode Exit fullscreen mode

Now that's a considerable boost in performance, about a 230% speed increase to be specific.
All we did was start the kettle, start preparing the cups, and start warming the milk all in parallel. Preparing cup #1 would take 3 seconds, and instead of waiting, we started warming the milk to buy some time.

Synchronous Operation Asynchronous Operation
Alt Text Parallel Waterfall Illustration

I think we can enhance a bit more, how about we refactor PrepareCupsAsync()? We can prepare multiple cups in parallel!

static async Task<string> PrepareCupsAsync(int numberOfCups)
{
    Task[] eachCupTask = Enumerable.Range(1, numberOfCups).Select(index => 
    {
        Console.WriteLine($"Taking cup #{index} out.");
        Console.WriteLine("Putting tea and sugar in the cup");
        return Task.Delay(3000);
    }).ToArray();

    await Task.WhenAll(eachCupTask);

    Console.WriteLine("Finished preparing the cups");

    return "cups";
}
Enter fullscreen mode Exit fullscreen mode

Using LINQ, we created a new IEnumerable of Tasks. All the created tasks ran immediately the moment we enumerated the IEnumerable using the .ToArray() call. Finally, we waited for the tasks altogether using WhenAll().
In other use cases, you may find it useful to use the result of only the first task to finish disregarding the other tasks. In such cases, you'd want to use .WhenAny().

IMPORTANT NOTE:

Although it's less code, use caution when mixing LINQ with asynchronous code. Because LINQ uses deferred (lazy) execution, async calls won't happen immediately as they do in a foreach loop unless you force the generated sequence to iterate with a call to .ToList() or .ToArray().

Now let's check the output:

Start the kettle
Waiting for the kettle
Taking cup #1 out.
Putting tea and sugar in the cup
Taking cup #2 out.
Putting tea and sugar in the cup
Pouring milk into a container
Putting the container in microwave
Warming up the milk
Kettle Finished Boiling
Finished preparing the cups
Pouring Hot water into cups
Finished warming up the milk
Adding Warm Milk into cups with tea
-------------------------------
Time Elapsed: 5.1878251 seconds
Enter fullscreen mode Exit fullscreen mode

We went down from 6 seconds to 5 seconds, and that's the best we could get out of the flow because warming milk takes 5 seconds itself.

Pop Quiz! 📃
Q: Let's say your friends are coming over for a movie night 🍿, and you are making 10 cups of tea instead of just 2 PrepareCupsAsync(10), how much time do you think it'd take?

Start the kettle
Waiting for the kettle
Taking cup #1 out.
Putting tea and sugar in the cup
•••
Taking cup #10 out.
Putting tea and sugar in the cup
Pouring milk into a container
Putting the container in microwave
Warming up the milk
Kettle Finished Boiling
Finished preparing the cups
Pouring Hot water into cups
Finished warming up the milk
Adding Warm Milk into cups with tea
------------------------------------
Time Elapsed: 5.1923364 seconds
Enter fullscreen mode Exit fullscreen mode

Yep, you guessed it right, it'd still take no more than 5 seconds. That is how scalable our code has been refactored to be.

Is Async/Await multithreaded?

Unlike other languages, Javascript for example, which uses async single threads, .NET framework is multithreaded, and in this instance, it can be both single and multithreaded, or either. Unless you're really trying to share data between multiple threads, you usually do not need to worry about it, but for mere pleasure, let's investigate which thread is actually working on each piece of code.

I added this simple helper method to state the ID of the current thread when writing to console..

public static void WriteLineWithCurrentThreadId(string textToPrint) 
            => Console.WriteLine($"Thread #{Thread.CurrentThread.ManagedThreadId} | {textToPrint}");
Enter fullscreen mode Exit fullscreen mode

.. and then I replaced each Console.WriteLine() with WriteLineWithCurrentThreadId().
Here are the results:

Thread #1 | Start the kettle
Thread #1 | Waiting for the kettle
Thread #1 | Taking cup #1 out.
Thread #1 | Putting tea and sugar in the cup
Thread #1 | Taking cup #2 out.
Thread #1 | Putting tea and sugar in the cup
Thread #1 | Pouring milk into a container
Thread #1 | Putting the container in microwave
Thread #1 | Warming up the milk
Thread #4 | Kettle Finished Boiling
Thread #5 | Finished preparing the cups
Thread #5 | Pouring Hot water into cups
Thread #4 | Finished warming up the milk
Thread #4 | Adding Warm Milk into cups with tea
------------------------------------
Time Elapsed: 5.2266067000000005 seconds
Enter fullscreen mode Exit fullscreen mode

So what is happening in here?
We have a bunch of threads lying around in a pool, a thread pool, all the threads are pretty much the same, they're all available and in the same context, some of them are currently working on something, others are idle, waiting for a task to be assigned to.
Our console application will just grab whichever one might be available. And occasionally they switch sometimes they don't, and after an await it's going to pick whatever thread that's available from the same context from the thread pool, and then continue on that.
In the case of a WPF program, there's only one thread that can write to the UI. So in that instance, the continuation runs on exactly the same thread to make sure that it can update the windows correctly.

Phew! That was a lot to take in, right?
Be sure to read the article a couple of times, and try to take the source code for a test drive to see the effects yourself, also check the last part of the series, I wrote down some tips and tricks that could be applied in almost any coding situation.

Best of luck 🙌.

Discussion (0)