DEV Community

loading...
Cover image for Asynchronous C#: Making a simple Cup of Tea

Asynchronous C#: Making a simple Cup of Tea

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

Years ago, programs ran sequentially, line by line and function by function. Only one task processing at a given unit of time. If a function relied on another function, it had to wait till it got the result from that function before continuing to process, even if it meant that the application's main thread would get blocked for an amount of time, waiting for some network call to return a response or for a piece of work that requires a heavy calculation to yield a result.

For the past couple of years, a typical device running almost any type of operating systems have at least a two, eight, or even 16 cores chip, where each core can focus on a task (or multiple tasks at once in some cases), resulting in the ability to process more bits and bytes in a fewer number of precious microseconds, allowing what's called "Asynchrony", or Asynchronous Programming of a given group of tasks.

Asynchrony refers to the occurrence of events independent of the main program flow, they typically take place concurrently with program execution, without the program blocking to wait for results. Doing so provides a degree of parallelism, effectively using the available resources to achieve a target sooner than the synchronous version of the same piece of code.

Threads vs Processes

Whenever we talk about asynchrony, we have to mention either one of these two terms, so what are the differences between the two?

A process is an executing instance of a program, it has its own memory space, registers, etc. On the other hand, a thread is essentially one of the components of a given process, meaning that a process can have multiple threads running concurrently.
The conversation can't end without addressing "multi-processing" and "multi-threading", let me share a nice comparison between them from guru99.com.

Topic MultiProcessing MultiThreading
Definition Is a system that has more than two processors Is a program execution technique that allows a single process to have multiple code segments
What is it about Improves the reliability of the system Each thread runs parallel to each other
Resources The creation of a process is slow and resource-consuming The creation of a thread is economical in time and resource.

Be sure to check out the full comparison: Multithreading vs Multiprocessing at Guru99.

So long story short, the main difference between the two is whether they share memory or not. Processes do not share memory, thus operating independently while threads (of the same process) on the other hand, share the same memory allocated to their housing process.

A great real-life example would be how you're currently reading this article on Chrome, odds are you currently have more than one tab lying around in your browser, each in its own process, a tab would not care about its neighboring tab, and a tab could (God forbid) crash without the whole app crashing, that's "multiprocessing".
Now try to reload the page (and let's assume your connection isn't that fast), notice how you can move or resize the window normally while waiting for the page to load, that's "multithreading", your app is responsive even though it is waiting for a task (or a thread) to return a result.

Muliple processes in Google Chrome

Async and Await

Introduction

In C# 5, Microsoft introduced a simplified approach to asynchronous programming in .NET Framework, Where all the heavy lifting that was done by the developer is now done by the compiler.

The framework is wrapped in what's called: "Task asynchronous programming model" or TAP for short. The model is an abstraction layer over asynchronous code. You write your logic as a sequence of steps normally, and the compiler performs some transformations in the background because these statements return a Task<>.

Asynchronous methods mainly look something like this:

public async Task DoSomethingAsync()
{
  // In the Real World, we would actually do something...
  // For this example, we're just going to (asynchronously) wait 100ms.
  await Task.Delay(100);
}
Enter fullscreen mode Exit fullscreen mode

Synchronous Example

A perfect example that illustrates asynchronous work would be preparing a cup of tea, the job consists of four main tasks:

  1. Preparing the cups by putting tea and sugar
  2. Boiling the water
  3. Warming up some fresh milk
  4. Adding all the ingredients together in one nice cup of tea.

One person (or thread) can handle all these tasks. He can prepare the cups asynchronously by starting the next task before the first one completes. As soon as you start the kettle, you can begin warming up the milk. Once the water boils, you can pour it into the cup and then add some milk.

static string BoilWater()
{
    Console.WriteLine("Start the kettle");
    Console.WriteLine("Waiting for the kettle");
    Task.Delay(3000).Wait();

    Console.WriteLine("Kettle Finished Boiling");

    return "Hot water";
}
Enter fullscreen mode Exit fullscreen mode
static string PrepareCups(int numberOfCups)
{
    for (int i = 0; i < numberOfCups; i++)
    {
        Console.WriteLine($"Taking cup #{i + 1} out.");
        Console.WriteLine("Putting tea and sugar in the cup");
        Task.Delay(3000).Wait();
    }

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

    return "cups";
}
Enter fullscreen mode Exit fullscreen mode
static string WarmupMilk()
{
    Console.WriteLine("Pouring milk into a container");
    Console.WriteLine("Putting the container in microwave");
    Console.WriteLine("Warming up the milk");
    Task.Delay(5000).Wait();

    Console.WriteLine("Finished warming up the milk");

    return "Warm Milk";
}
Enter fullscreen mode Exit fullscreen mode
static void MakeTea()
{
    var water = BoilWater();
    var cups = PrepareCups(2);
    Console.WriteLine($"Pouring {water} into {cups}");

    cups = "cups with tea";

    var warmMilk = WarmupMilk();
    Console.WriteLine($"Adding {warmMilk} into {cups}");
}
Enter fullscreen mode Exit fullscreen mode
static void Main(string[] args)
{
    // App's main entry point.
    MakeTea();
    // Stopwatch logic is hidden for simplicity
}
Enter fullscreen mode Exit fullscreen mode

As you can see, preparing the cups would take 3 seconds for each cup (6 for both cups), boiling the water would take another 3 seconds, warming up some milk would take 5 seconds, resulting in a total of 14 seconds, here is the output of our program.

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

Synchronous waterfall graph

Of course, waiting for the cups to be prepared might risk boiled water to cool down, the exact same case can take place while waiting for the milk to get warm, the nice hot cup of tea might have to sit and wait on the counter till the milk is ready, this represents our first concern.
Another problem might arise the moment this console application is converted to a GUI application. Try to cancel, minimize, or even interact with the window and you'll instantly notice something is not right, our application will freeze the second we "wait" for a task to finish, warming up a simple cup of milk would not result in the best user experience.

What happened in our current version is analogous to a chef being focused on exactly one job, tapping his feet on the ground, looking at his watch every now and then, so eager, waiting for it to get done. He won't respond to his fellow chef asking for the current time, he won't even answer to his boss. That's not the best behavior to get out of our chef (or the thread, technically speaking).

Stuck Windows Form
As you can see, upon converting the console app into a windows forms application (GUI), clicking on the "Make Tea" button instantly stalls the window. The block is not released until the synchronous wait is completed.

Addressing Synchronous code Issues

Now, let's address each issue.

Issue #1: Frozen UI (generally, the main thread)

When we write client applications, we want the UI to be as responsive as possible, the last thing we want is our app freezing here and there while it's downloading data from the web. We need to modify our app to utilize asynchrony.

Don't Block, Await Instead

Let's start with the helping functions:

static async Task<string> BoilWaterAsync()
{
    Console.WriteLine("Start the kettle");
    Console.WriteLine("Waiting for the kettle");
    await Task.Delay(3000);

    Console.WriteLine("Kettle Finished Boiling");

    return "Hot water";
}
Enter fullscreen mode Exit fullscreen mode
static async Task<string> PrepareCupsAsync(int numberOfCups)
{
    for (int i = 0; i < numberOfCups; i++)
    {
        Console.WriteLine($"Taking cup #{i + 1} out.");
        Console.WriteLine("Putting tea and sugar in the cup");
        await Task.Delay(3000);
    }

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

    return "cups";
}
Enter fullscreen mode Exit fullscreen mode
static async Task<string> WarmupMilkAsync()
{
    Console.WriteLine("Pouring milk into a container");
    Console.WriteLine("Putting the container in microwave");
    Console.WriteLine("Warming up the milk");
    await Task.Delay(5000);

    Console.WriteLine("Finished warming up the milk");

    return "Warm Milk";
}
Enter fullscreen mode Exit fullscreen mode
static async Task MakeTeaAsync()
{

    var water = await BoilWaterAsync();
    var cups = await PrepareCupsAsync(2);
    Console.WriteLine($"Pouring {water} into {cups}");

    cups = "cups with tea";

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

Finally, we'll need to modify our entry point too

static async Task Main(string[] args)
{
    // App's main entry point.
    await MakeTea();
    // Stopwatch logic is hidden for simplicity
}
Enter fullscreen mode Exit fullscreen mode

And when we try running the app again, we get this output:

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

Something is not just right here. The elapsed time is roughly the same as our initial synchronous version!

Ok, here is the thing, this code doesn't block. While we're waiting for the water to boil, the chef would still start the kettle, stare at it while it heats up, but at least now, he'll respond to his partners, the UI thread is now responsive while waiting for a task to finish.

When you write server programs, you don't want threads blocked. Those threads could be serving other requests. Using synchronous code when asynchronous alternatives exist hurts your ability to scale out less expensively. You pay for those blocked threads.

For some use cases, this is all we need for our app to be acceptable for the user.

Remember, we are still addressing the first issue, the code still needs to take advantage of some features of TAP asynchronous model, but first, let's take a quick look under the hood.

Awaitable and Tasks

I highlighted exactly what was altered in order for the BoilWater() method to become BoilWaterAsync()

static async Task BoilWaterAsync()
{
    Console.WriteLine("Start the kettle");
    Console.WriteLine("Waiting for the kettle");
    await Task.Delay(3000); // We also removed the .Wait() call

    Console.WriteLine("Kettle Finished Boiling");

    return "Hot water";
}

Now let's take a couple of notes:

As we observed the results, the method executed just like any other method, synchronously, until it hits the "await keyword", this is where things can get asynchronous.

An await keyword expects an "awaitable" (in our case, the Task<> resulted from the Delay method), then it examines whether the awaitable has already completed or not, if await sees that the awaitable has not completed, then it acts asynchronously, waits for the awaitable to continue till it finally completes, only then, the method just continues running synchronously again, just like a regular method.
Hold on to this point in your head, I promise it's going to get clearer.

Now, let me modify the MakeTeaAsync() method once again, functionality stays the same:

static async Task MakeTeaAsync()
{

    //var water = await BoilWaterAsync();
    Task waterTask = BoilWaterAsync();
    string water = await waterTask;
    var cups = await PrepareCupsAsync(2);
    Console.WriteLine($"Pouring {water} into {cups}");

    cups = "cups with tea";

    var warmMilk = WarmupMilkAsync();
    Console.WriteLine($"Adding {warmMilk} into {cups}");
}

Recall that BoilWaterAsync() returns a Task<string>, not just a string. A task is an object that represents some work that should be done, some sort of a promise, there should be a result at some point in the future. A Task is an awaitable, and hence it can be "awaited" using the keyword (you guessed it), await.

Pop Quiz! 📃
Q: Why did we use Task<> as the return type?
A: Because we might need to await the task to finish at some point.

Q: Why do we need to await the Task?
A: Because we need a result extracted from the Task object.

One important concept to notice, the following line:

Task<string> waterTask = BoilWaterAsync();
Enter fullscreen mode Exit fullscreen mode

.. caused the creation of what's called a "hot" task 🔥, which is a task that starts running immediately the moment it gets created, so if we decide to add more logic to the function

static async Task MakeTeaAsync()
{
    •••
    
    Task waterTask = BoilWaterAsync();
    
    // Some long-running synchronous for loop
    // 
    // ... still looping aimlessly
    // 
    // ummm, okay, done 👍
    
    string water = await waterTask;
    
    •••
}

.. by the time we reach the await keyword, the Task might have already started, and finished. await will just have to extract the result from the Task, and we'd be good to go, synchronously of course (the rest of the function). This called Task Composition.

The previous code snippet showed us that we can use Task or Task<> objects to hold running tasks, then we await each task before using its result.

One final note to write down, context switching...
After the awaitable (waterTask) is completed, the remainder of the async function (MakeTeaAsync) will execute on a "context" that was captured before the await returned, but what exactly is a "context"?

  • If you’re on a UI thread, then it’s a UI context.
  • If you’re responding to an ASP.NET request, then it’s an ASP.NET request context.
  • Otherwise, it’s usually a thread pool context.

Here is a quick example (taken from Stephen Cleary's blog post):

// WinForms example
private async void DownloadFileButton_Click(object sender, EventArgs e)
{
    // Since we asynchronously wait, the UI thread is not blocked by the file download.
    await DownloadFileAsync(fileNameTextBox.Text);

    // Since we resume on the UI context, we can directly access UI elements.
    resultTextBox.Text = "File downloaded!";
}

// ASP.NET example
protected async void MyButton_Click(object sender, EventArgs e)
{
    // Since we asynchronously wait, the ASP.NET thread is not blocked by the file download.
    // This allows the thread to handle other requests while we're waiting.
    await DownloadFileAsync(...);

    // Since we resume on the ASP.NET context, we can access the current request.
    // We may actually be on another *thread*, but we have the same ASP.NET request context.
    Response.Write("File downloaded!");
}
Enter fullscreen mode Exit fullscreen mode

To sum up, I'd like to (again) quote Stephen Cleary:

"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)."

Next up, we'll have another attempt to refactor our tea maker in order for it to be more time-efficient and further utilize C#'s asynchrony.

See you soon. 👋

Discussion (1)

Collapse
broctholumeu profile image
Broctholumeu

Great articles! This is the info I have been looking for, thank you