loading...

Async / Await: From Zero to Hero

zhiyuanamos profile image Zhi Yuan Updated on ・7 min read

Async / Await: From Zero to Hero

I had absolutely no idea what async / await was and learning it was hard as:

  1. There's 27 minutes worth of text to read in the first two introductory articles by MSDN here and here, with many more articles referenced in them.
  2. It wasn't clearly stated in the documentation what async / await solves.
  3. There's no one-stop guide concerning this topic.

I'm documenting my learnings to address the above pain points that I encountered, and the content is ordered as such:

  1. Must Know
  2. Should Know
  3. Good to Know

This post assumes prior understanding of threads.

Must Know

Main Point: async / await solves the problem of threads being blocked (waiting idly) while waiting for its task to complete.

Introduction

It's a weekend afternoon, and you decide to:

  1. Use a waffle machine to make a waffle.
  2. Reply a text message from your mum.

In this hypothetical scenario,

  1. Making a waffle is an asynchronous operation - you would leave the waffle mixture in the machine and let the machine make the waffle. This frees you to perform other tasks while waiting for the waffle to be completed.
  2. Replying mum is a synchronous operation.

These operations, if implemented in a fully synchronous manner in C# code, may look like this:

static void Main()
{
    Waffle waffle = MakeWaffle();
    ReplyMum();
}

static Waffle MakeWaffle() 
{
    var task = Task.Delay(2000); // start the waffle machine. Simulates time taken to make the waffle
    task.Wait(); // synchronously wait for it...
    return new Waffle(); // waffle is done!
}

static void ReplyMum() => Thread.Sleep(1000); // simulates time taken by you to reply mum

Problem

The thread calling task.Wait() is blocked till task completes.

This leads to inefficiency as you would ReplyMum() after MakeWaffle() has completed execution, rather than replying while MakeWaffle() is executing. Therefore, these tasks take roughly 2000ms + 1000ms = 3s to complete rather than the expected 2s.

Solution

Let's update MakeWaffle() to run asynchronously:

-static Waffle MakeWaffle()
+static async Task<Waffle> MakeWaffleAsync() // (2) & (3)
 {
     var task = Task.Delay(2000);
-    task.Wait();
+    await task; // (1)
     return new Waffle();
 }
  1. Replacing Wait() with await. await can be conceived of as the asynchronous version of Wait(). You would ReplyMum() immediately after starting the waffle machine, rather than waiting idly for the waffle machine to complete making the waffle.
  2. Addition of async modifier in the method signature. This modifier is required to use the await keyword in the method; the compiler will complain otherwise.
  3. Modifying the return type to Task<Waffle>. A Task object basically "represents the ongoing work". More on that below.

Let's update the caller method accordingly:

-static void Main()
+static async Task MainAsync()
 {
-    Waffle waffle = MakeWaffle();
+    Task<Waffle> waffleTask = MakeWaffleAsync();
     ReplyMum();
+    Waffle waffle = await waffleTask;
 }

The resulting code looks like this:

static async Task MainAsync()
{
    Task<Waffle> waffleTask = MakeWaffleAsync(); // (3)
    ReplyMum(); // (4)
    Waffle waffle = await waffleTask; // (5) & (7)
}

static async Task<Waffle> MakeWaffleAsync()
{
    var task = Task.Delay(2000); // (1)
    await task; // (2)
    return new Waffle(); // (6)
}

static void ReplyMum() => Thread.Sleep(1000);

Let's analyse the code:

  1. Start the waffle machine.
  2. Wait asynchronously for the waffle machine to complete making the waffle. Since the waffle is not yet done, control is returned to the caller.
  3. waffleTask now references the incomplete task.
  4. Start replying mum.
  5. Wait asynchronously (remaining ~1s) for the waffle machine to complete making the waffle. In our scenario, since the main method has no caller, there's no caller to return control to and no further work for the thread to process.
  6. Waffle machine is done making the waffle.
  7. Assign the result of waffleTask to waffle.

Key clarifications:

  1. Don't await a task too early; await it only at the point when you need its result. This allows the thread to execute the subsequent code until the await statement. This is illustrated in the above code sample:

    a. Notice the control flow in step 2. After executing await task, control is returned to MainAsync(); code after the await statement (step 6) is not executed until task completes.

    b. Similarly, if await waffleTask was executed before ReplyMum() (i.e. immediately after step 3), ReplyMum() won't execute until waffleTask completes.

  2. Suppose ReplyMum() takes longer than 2000ms to complete, then await waffleTask will return a value immediately since waffleTask has already completed.

And we're done! You can run my program to verify that the synchronous code takes 3s to execute, while the asynchronous code only takes 2s.

Additional Notes

  1. Sahan puts it well that "tasks are not an abstraction over threads"; async != multithreading. The illustration above is an example of a single-threaded (i.e. tasks are completed by one person), asynchronous work. Stephen Cleary explained how this works under the hood.

  2. Suffix Async "for methods that return awaitable types". For example, I've renamed MakeWaffle() to MakeWaffleAsync().

Should Know

Introduction

Suppose you want to do something more complex instead:

  1. Use a waffle machine to make a waffle.
  2. Use a coffee maker to make a cup of coffee.
  3. Download a camera app from Play Store.
  4. After steps 1 & 3 are completed, snap a photo of the waffle.
  5. After steps 2 & 3 are completed, snap a photo of the coffee.

If we only use the syntax we've learned above, the code looks like this:

static async Task MainAsync()
{
    Task<Waffle> waffleTask = MakeWaffleAsync();
    Task<Coffee> coffeeTask = MakeCoffeeAsync();
    Task<App> downloadCameraAppTask = DownloadCameraAppAsync();

    var waffle = await waffleTask;
    var coffee = await coffeeTask;
    var app = await downloadCameraAppTask;

    app.Snap(waffle);
    app.Snap(coffee);
}

Problem

Suppose the timing taken for each task to complete is random. In the event waffleTask and downloadCameraAppTask completes first, you would want to app.Snap(waffle) while waiting for coffeeTask to complete.

However, you will not do so as you are still await-ing the completion of coffeeTask; app.Snap(waffle) comes after the awaiting of coffeeTask. That's inefficient.

Solution

Let's use task continuation and task composition to resolve the above problem:

static async Task MainAsync()
{
    Task<Waffle> waffleTask = MakeWaffleAsync();
    Task<Coffee> coffeeTask = MakeCoffeeAsync();
    Task<App> downloadCameraAppTask = DownloadCameraAppAsync();

    Task snapWaffleTask = Task.WhenAll(waffleTask, downloadCameraAppTask) // (1)
        .ContinueWith(_ => downloadCameraAppTask.Result.Snap(waffleTask.Result)); // (2)
    Task snapCoffeeTask = Task.WhenAll(coffeeTask, downloadCameraAppTask)
        .ContinueWith(_ => downloadCameraAppTask.Result.Snap(coffeeTask.Result));

    await Task.WhenAll(snapWaffleTask, snapCoffeeTask);
}
  1. WhenAll creates a task that completes when both waffleTask and downloadCameraAppTask completes.
  2. ContinueWith creates a task that executes asynchronously after the above task completes.

Now, you would continue with snapping a photo of the waffle after waffleTask and downloadCameraAppTask completes; coffeeTask is no longer a factor in determining when downloadCameraAppTask.Result.Snap(waffleTask.Result) is executed.

Additional Notes:

  1. Result "blocks the calling thread until the asynchronous operation is complete". However, it doesn't cause performance degradation in our scenario as we have await-ed for the tasks to complete. Therefore, waffleTask.Result, coffeeTask.Result and downloadCameraAppTask.Result will return a value immediately.

  2. Related to the above, use Result and Wait() judiciously so that the thread does not get blocked.

  3. Use WhenAny if you want the task to complete when any of the supplied tasks have completed.

  4. Favor asynchronous API (WhenAny, WhenAll) over synchronous API (WaitAny, WaitAll).

Good to Know

  1. An asynchronous method can return void instead of Task, but it is not advisable to do so.

  2. await Task.WhenAll(snapWaffleTask, snapCoffeeTask) can be replaced with await snapWaffleTask; await snapCoffeeTask;. However, there are benefits of not doing so.

  3. The following method

    static Task<Waffle> MakeWaffleAsync() => 
        return Task.Delay(2000).ContinueWith(_ => new Waffle());
    

    Can also be written as an asynchronous method:

    static async Task<Waffle> MakeWaffleAsync() => 
        return await Task.Delay(2000).ContinueWith(_ => new Waffle());
    

    Both options have their pros & cons depending on the scenario.

    Edit: Do take a look at the discussion Josef had with me in the comments' section for more understanding into this matter.

  4. The performance of .NET and UI applications can be improved by using ConfigureAwait(false). There's much complexities involved however, so do take a look at the links here and here before doing so.

  5. Tangential to our topic: Don't create fake asynchronous methods by using Task.Run incorrectly.

Conclusion

There are other advanced topics that I didn't cover so as to keep this article short, such as:

  1. Task Cancellation
  2. Exception Handling

However, you should be able to do a whole lot of asynchronous programming with the above knowledge.

Lastly, if you liked this article, please give it a ❤️ or a 🦄, and do let me know your thoughts in the section below :)

Posted on by:

zhiyuanamos profile

Zhi Yuan

@zhiyuanamos

Gets the job (well) done.

Discussion

markdown guide
 

Nice post!

I wanted to add for those who are not that familiar with tasks, that using ConfigureAwait(false) is not only about performance issues but also about avoiding deadlocks in UI and Asp.Net applications.

In .NET you always should use "ConfigureAwait(false)" if you publish a library.

Second addition:
Using .Result within the UI Mainthread or within Asp.Net Request threads produces deadlocks. This also can be avoided using ConfigureAwait(false). But care attention if you use dependency injection. The scope may not flow with the code if you use ConfigureAwait(false).

And:
async/await introduces more complexity at CIL level and thus costs memory and CPU time. This should be taken into consideration when writing async code.

When do you decide to use async code?

 

Hey there, appreciate your helpful insights concerning ConfigureAwait(false). :)

async/await introduces more complexity at CIL level and thus costs memory and CPU time. This should be taken into consideration when writing async code.

You are spot-on concerning this. For the benefit of those reading this comment, this SO post gives 2 examples concerning the added complexity at CIL level when using async / await.

When do you decide to use async code?

I'm not sure if I got your question right, but I suppose you are asking this in relation to the added complexity at CIL level. If so, Stephen Cleary's post provides a thorough explanation on this matter, and I'll summarise some of the key points here:

It’s more efficient to elide async and await

Which you have mentioned rightly. However,

it’s important to point out that each of these gains are absolutely minimal... In almost every scenario, eliding async and await doesn’t make any difference to the running time of your application.

I suggest following these guidelines:

  1. Do not elide by default. Use the async and await for natural, easy-to-read code.
  2. Do consider eliding when the method is just a passthrough or overload.

Stephen Cleary also provides examples of pitfalls, one concerning using the using statement, and the other concerning exceptions. He gives an amazing and concise explanation there, so I'll not copy the quotes here.

Thanks for your question! You helped me to dig deeper into this topic, which resulted in me finding Stephen's blog post.

 

Thanks for linking that blog posts. I will read through it. Sounds very interesting!

 

But what does ConfigureAwait(false) do exactly?

 

There isn't an easy answer to this question that doesn't require more explanation. There is an FAQ of sorts here: devblogs.microsoft.com/dotnet/conf...

The very short and condensed version is that it signals to the async system that you want your asynchronous code to not be marshaled back to the original calling context. I highly recommend careful study of the FAQ post for more details on the consequences of that.

I don't use c# outside on my rare unity doodles. In case anyone is familisr with f#, is there anything similar in f# (for the async computational expressiom)?

 

Nice post! I also had troubles approaching async programming, so I wrote an article to help other devs understand the basics of this topic: code4it.dev/blog/asynchronous-prog...

 

Hey Davide, thanks for reading! I read through your article and found out about the existence of ValueTask, thanks for writing!

A quick comment on the header How to make a sync method asynchronous: Correct me if I'm wrong, I think such a scenario is only helpful when developing Desktop applications, whereby heavy synchronous work should not be processed by the UI thread, but deferred to a background thread using Task.Run.

Web applications, however, do not have a UI thread. Consider the code samples below (the first is copied from your article)

// Suppose Thread A is processing this incoming request
var taskResult = Task.Run( () => DoSomethingSynchronous() ); // Thread B is assigned to process DoSomethingSynchronous()
int value = await taskResult; // Thread A is freed up to process other incoming requests

VS

DoSomethingSynchronous(); // processed by Thread A

In both scenarios, a thread is required to perform DoSomethingSynchronous(); there's no benefit in doing it in an asynchronous manner. Rather, the asynchronous code would be slightly less performant as it has to deal with the overhead of async/await and allocating Thread B to execute DoSomethingSynchronous() and freeing Thread A.

Edit: I think it's beneficial to run synchronous methods on separate threads using Task.Run when executing multiple expensive synchronous methods. For example:

SomeExpensiveSynchronousMethodOne();
SomeExpensiveSynchronousMethodTwo();

VS

var taskOne = Task.Run(() => SomeExpensiveSynchronousMethodOne());
var taskTwo = Task.Run(() => SomeExpensiveSynchronousMethodTwo());
await Task.WhenAll(taskOne, tasktwo);

In the first option, the methods run sequentially, while the second option has both methods running in parallel, thus the second option should complete earlier than the first option.

 

"The performance of .NET and UI applications can be improved by using ConfigureAwait(false)."

Uuhhh. That's a way more complicated topic than you make it out to be. In a post that spends no time whatsoever talking about synchronization contexts giving such an advice is very illadvised.

 

Hey there, thanks for pointing out how my statement may be misread! Yes, I'm aware of the complexities involved so I cited Stephen Cleary's answer from SO.

What I really wanted to say is that: You can, but that doesn't mean therefore that you should do it.

Meanwhile, I've removed this statement. Does this convey the message clearer?

The performance of .NET and UI applications can be improved by using ConfigureAwait(false). Things aren't as straightforward, however. Do take a look at Stephen Cleary's answer before doing so.

Edit: I'll include Cellivar's link as well.

 

Great read and well put together. Thanks for doing this, I would have personaly made the mistake with the make waffle and coffee, then snap a picture, example you gave.

 

Thank you for your kind encouragement, I'm glad that this post helped you :)

 

Nice and clear explanation Zhi Yuan.

 

Thank you for reading through this article and for your kind words! :)

 

Excellent post. Really well explained.

 

Thanks Eamon for reading through this article and for your kind words! :)

 
 

Thank you for your kind encouragement! :)

 

Thanks for this Zhi, it was a great read, cleared things up quite a bit. I will look into it more for further knowledge.

 

Hey John, I'm glad that you found this short post helpful! :)