Author avatar

Nate Cook

Understanding Control Flow with Async and Await in C#

Nate Cook

  • Feb 22, 2019
  • 13 Min read
  • 1,246 Views
  • Feb 22, 2019
  • 13 Min read
  • 1,246 Views
Languages Frameworks and Tools
C#

Async and Await in Practice

In the previous guide in this series we took a look at the basics of the async and await keywords in C#. Once you get the hang of their syntax and usage, it can actually be quite a joy to write asynchronous code. In fact, it feels so natural that one can forget that the code is asynchronous! Often this is an advantage; you can ignore the minutiae and focus on the application you're building. But, sooner or later, you will come across some confusing behavior that will remind you how tricky asynchronicity can be. It's at these moments that understanding what happens under the hood when using async and await becomes important. It turns out that there is a lot going on.

Blocking vs. Non-Blocking Code

You might recall from the previous guide that the async keyword is actually just a way to eliminate ambiguity for the compiler with regard to await. So, when we talk about the async/await approach, it's really the await keyword that does all the heavy lifting. But before we look at what await does, let's talk about what it does not do.

The await keyword does not block the current thread. What do we mean by that? Let's look at some examples of blocking code.

1
System.Threading.Thread.Sleep(1000);
csharp

The above code blocks execution of the current thread for one second. Other threads in the application may continue to execute, but the current thread does absolutely nothing until the sleep operation has completed. Another way to describe it is that the thread waits synchronously. Now, for another example, this time from the Task Parallel Library:

1
2
3
var httpClient = new HttpClient();
var myTask = client.GetStringAsync("https://...");
var myString = myTask.GetAwaiter().GetResult();
csharp

In the above code snippet .NET's HttpClient class is returning a Task instance, but we're calling GetAwaiter().GetResult() on the task, which is a blocking call. Again, this is synchronous; no execution will take place on the current thread until GetResult returns with the data returned by the operation (the requested string data in this example). Similarly, a task's Result property also blocks synchronously until data is returned.

Last but not least, there's also a Wait method that is blocking, e.g.:

1
myTask.Wait();
csharp

Even if the underlying task is asynchronous, if you call a blocking method or blocking property on the task, execution will wait for the task to complete - but will do so synchronously, such that the current thread is completely occupied during the wait. So, if you use one of the above properties/methods, be sure that's actually what you meant to do.

The await keyword, by contrast, is non-blocking, which means the current thread is free to do other things during the wait. But what else would the current thread be doing?

Await from the User's Perspective

To better answer the question about what a thread would do during a non-blocking wait, it might be helpful to take a step back and think about asynchronicity in general from the perspective of an application user. As an example: In previous guides, we've looked at the code for an application that downloads and blurs images. Let's say this time that the application has a graphical user interface with two buttons and a progress bar. The first button downloads and blurs images while displaying a progress bar animation and the second button counts the number of images you've blurred and tells you the number in some sort of dialog.

Now, imagine you are the user and the developer has used code that is blocking or synchronous. You click the first button to download and blur the images and, when you do, the application appears to freeze. The progress bar either doesn't show up at all or it appears with a frozen animation. Perhaps your mouse cursor changes to an hourglass or a pinwheel. You can't even move the application window, much less click the second button. Short of killing the application, you have no choice but to wait for the download/blur operation to finish.

Let's contrast that with the user experience if the same application were written to use await. You click the first button and the progress bar appears with an animation. While waiting, you decide to move the application window, which repaints itself smoothly as you drag the window. Still waiting, you decide to click the second button and get an image count in the meantime. Before long, the image count appears in a dialog. Finally, the download/blur operation finishes and the progress bar hides itself.

As you can see, the user provided plenty for the original thread to do while awaiting the long-running operation. The fact that await frees the thread up to do other things means that it can remain responsive to additional user actions and input. But, even if there is no graphical user interface, we can see the advantage of freeing up a thread. As was demonstrated in previous guides in this series, a console application can also display progress independent of execution in the form of a text-based dashboard; you could extend that idea to periodically check for keyboard input. In the case of ASP.NET, freeing up a thread potentially means greater scalability, allowing a single server to handle more requests concurrently than it otherwise could. It can, therefore, be very advantageous to write non-blocking code with await.

Await from the Perspective of the Calling Method

We know now that await doesn't block - it frees up the calling thread. But how does this non-blocking behavior manifest itself to the calling method? Consider the following code.

Assume there is a method somewhere called ShowDialog that shows a message alert of some sort to the user.

1
2
3
4
5
6
7
8
9
10
11
12
void OnButtonClick()
{
  DownloadAndBlur("https://...jpg");
  ShowDialog("Success!");
}

async Task DownloadAndBlur(string url)
{
  await DownloadImage(...);  
  await BlurImage(...);
  await SaveImage(...);
}
csharp

If you were to run this code you would notice a problem: The success dialog displays before the download/blur operation completes! This demonstrates an important point: When a method using await is not itself awaited, execution of the calling method continues before the called method has completed. Let's add some logging to see that in detail:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void OnButtonClick()
{
  Console.WriteLine("button clicked");
  DownloadAndBlur("https://...jpg");
  Console.WriteLine("about to show dialog");
  ShowDialog("Success!");
  Console.WriteLine("dialog shown");
}

async Task DownloadAndBlur(string url)
{
  Console.WriteLine("about to download");
  await DownloadImage(...);  
  Console.WriteLine("finished downloading, about to blur");
  await BlurImage(...);
  Console.WriteLine("finished blurring, about to save");
  await SaveImage(...);
  Console.WriteLine("finished saving");
}
csharp

The output is as follows:

1
2
3
4
5
6
7
button clicked
about to download
about to show dialog
dialog shown
finished downloading, about to blur
finished blurring, about to save
finished saving

Notice that, at first, execution of DownloadAndBlur is performed synchronously, until the first encounter with await. Control at that time returns to the calling method as if DownloadAndBlur had already finished.

Note that control may not return to the calling method immediately, but will do so at the first opportunity. For example, if the user clicks another button at the right moment the application's main thread might already be busy with something else. Whenever the thread is next idle, control will resume in the calling method as described.

We can, of course, fix the above example by using await (and don't forget async) in OnButtonClick as follows:

1
2
3
4
5
6
7
8
async void OnButtonClick()
{
  Console.WriteLine("button clicked");
  await DownloadAndBlur("https://...jpg");
  Console.WriteLine("about to show dialog");
  ShowDialog("Success!");
  Console.WriteLine("dialog shown");
}
csharp

But a larger question remains. In all cases, at some point after an await a method needs to "wake up", as it were, and continue with the rest of its code. How exactly does execution resume a piece of a method?

Resuming a Method After an Await Call

To answer that question, let's try logging the call stack in DownloadAndBlur. One way to do that is:

1
Console.WriteLine(new System.Diagnostics.StackTrace());
csharp

You'll see something like the following:

1
2
3
4
5
6
7
8
9
10
at OnButtonClick()
  at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
  at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext()
  at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(IAsyncStateMachineBox box, Boolean allowInlining)
  at System.Threading.Tasks.Task.RunContinuations(Object continuationObject)
  at System.Threading.Tasks.Task`1.TrySetResult(TResult result)
  at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetExistingTaskResult(TResult result)
  at System.Runtime.CompilerServices.AsyncTaskMethodBuilder.SetResult()
at DownloadAndBlur()
  at System.Threading.ExecutionContext.RunInternal...

Notice that there are a lot of methods being called here that we did not define in our code, including AsyncStateMachineBox.MoveNext() and AsyncTaskMethodBuilder.SetResult. It's apparent that the compiler is generating a bunch of code on our behalf to keep track of the execution state.

The details of the generated code are outside the scope of this guide (and vary depending on the C# compiler and version), but suffice it to say that there is a state machine produced that uses goto statements in combination with the Task Parallel Library methods, along with some exception and context (i.e. thread) tracking. If you're interested in going deeper, try inspecting a .NET assembly that includes await in a .NET decompiler capable of decompiling to C#. In this manner, you can see every detail!

Exception Handling Control Flow with Await

Although control flow when it comes to exception handling is exactly as one would expect when using await, it's worth repeating that the opposite is not the case. Consider the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
async void OnButtonClick
{
  string imageUrl = null;
  try
  {
    DownloadAndBlur(imageUrl);
  }
  catch (Exception ex)
  {
    Console.WriteLine($"Exception: {ex}");
  }  
  Console.WriteLine("Done!");
}

async Task DownloadAndBlur(string url)
{
  if (url == null)
  {
    throw new ArgumentNullException(nameof(url));
  }  
  ...
}
csharp

One might expect the output to include Exception: ArgumentNullException. However, not only is that not the case, unless you have a debugger attached that pauses on all exceptions you won't know that an exception took place at all! That problem does not arise when using await however. So unless you have a good reason not to do so, it is best to await all of your asynchronous methods.

But, you might ask, what about what's often called "fire and forget"? This refers to the situation where you do not want to await an asynchronous method and you're not particularly concerned about when it finishes. In that case, consider, at the very least, adding a ContinueWith with TaskContinuationOptions.OnlyOnFaulted where you can log any exceptions that may arise. Even better, go ahead and await the method, but make the method call the very last thing you do in your outermost method. That way, none of your other code will have its execution postponed while still taking advantage of the exception treatment that comes with using await.

Conclusion and Next Steps

Although the control flow of an application is always a bit tricky when it has asynchronous aspects, knowing a bit about how await works under the hood helps a great deal. The most important thing about the await keyword though is to use it. As you observe your application's behavior and troubleshoot edge cases, the control flow of await introduced to you in this guide will be reinforced in your mind and your confidence with regard to this powerful tool will grow.

So far in this series, we have talked mainly about using await with I/O operations, but computationally expensive operations require something extra. The next guide in this series will explore this topic.

4