Is there life after await?

When I first started using Tasks in C#, they caused me a lot of confusion. The main thing I was concerned with is how they work with threads. If I write code in async Task method, will it execute on the main thread or worker thread? On what thread will I be after await? And what the heck is this ContinueAwait thing?

I’m sure many of these questions also bother other software developers, so I’ll try to answer them in this post. I will explain how these concepts work in general in .NET and then also explore how it applies to the Unity Engine.

How awaiting a Task influences thread affinity

Let’s start off with example code in Unity:

public class ThreadIds : MonoBehaviour
{
  // Start method in Unity can be async Task.
  private async Task Start()
  {
    Log("Start");
    
    await Task.Delay(100);

    Log("Finish");
  }
  
  // Log a message along with the current thread id to the console.
  private static void Log(string text) => Debug.Log($"{text}: {Thread.Current.ManagedThreadId}");
}

What do you think will be the thread IDs in the two log messages. Well, as Stephen Toub wonderfully put it in his article on the topic: await keyword “tries to bring you back where you were”.

Unity will invoke the Start method on the main thread (with id of 1), so the first log message will say Start: 1. Then it will asynchronously wait 100 milliseconds, after which it will “bring you back where you were”, that is, on the main thread. So the second log message will look as following: Finish: 1. I’ll add comments to the above code to show thread ids and also remove boilerplate:

Log("Start"); // 1

await Task.Delay(100);

Log("Finish"); // 1

At this point we can already draw a conclusion, which covers majority of cases for usage of Tasks in Unity: after awaiting a task you will almost always get back onto the main thread. So it’s safe to use Unity’s API from async methods as it does not violate their only call from the main thread restriction.

However… Obviously if I said almost there are some exceptions to this rule. And before we explore them, let’s get familiar with a bit of theory.

Synchronization contexts

Imagine for a moment, that the code that we looked at is not from a Unity app, but from a Console application - the one from the standard .NET template. Assuming that we are inside the Main method and our Log method prints to the standard output, what will it print? The answer will be something like this:

Log("Start"); // 1

await Task.Delay(100);

Log("Finish"); // 53 

Notice that the second message is not from the main thread anymore. The actual ID of the thread is not that important: it’s value may change from run to run, but the important thing is that it’s not equal to 1. So what, await failed to bring us back to where we were? Well, it kind of did.

If we look under the hood on what the await keyword does, the code above will look roughly as the following:

Log("Start");

var task = Task.Delay(100);
var context = SynchronizationContext.Current;

task.ContinueWith(() => {
  if (context == null)
    Log("Finish");
  else
    context.Post(() => Log("Finish"), null);
}, TaskScheduler.Current);

Here we can see, that when the task finishes, the CLR will get the current Synchronization context, and will execute the remainder of the async method inside it. But if there is no context, it will run the continuation on the current TaskScheduler which is most ofter is the Thread Pool.

And this is the distinction between Unity app and a Console app. In Unity there is always a SynchronizationContext which will make sure that the reminder of async methods executes on the main thread. But in Console applications SynchronizationContext is not set by default, so after await our code continues execution on a worker thread.

Synchronization context is set per thread, not for per application. This means after awaiting from a thread other then main, you will not return to the main thread even in Unity
So back to Unity, can we somehow escape the SynchronizationContext and make our continuation execute on another thread? The answer is ConfigureAwait.

ConfigureAwait

ConfigureAwait is the tool that allows to run the code after await on a thread from the thread pool, instead of the main thread. Let’s consider our code snippet inside Unity:

Log("Start"); // 1

await Task.Delay(100).ConfigureAwait(false);

Log("Finish"); // 53

Notice the boolean parameter. It’s name is continueOnCapturedContext and setting it to false basically means that the continuation will not be sent to SynchronizationContext, but will be run on the thread pool. ConfigureAwait(true) sends the continuation to SynchronizationContext, which is the default behaviour of Tasks, so using it doesn’t have an effect.

One thing to note here is that ConfigureAwait(false) makes the rest of the method after await run on the worker thread. Consider an example:

private Task Start()
{
  Log("Start"); // 1

  await Wait();

  Log("Finish"); // 1 - we're back to the main thread after Wait has finished.
}

private Task Wait()
{
  Log("Wait start"); // 1
  
  await Task.Delay(100).ConfigureAwait(false);
  
  Log("After first wait"); // 53 - moved to thread pool.
  
  await Task.Delay(100); 
  
  Log("After second wait") // 54 - still on the thread pool, even through the last await wasn't configured.
}

Let’s explore the code line by line:

Bonus

I’d like to review an interesting case of async code, which can cause a deadlock of the whole application, and how ConfigureAwait can help solve this.

Imagine that in our Unity app we’ve made a helper method that copies a file to another location. The code is as following:

public static class FileUtils
{
  public static async Task CopyAsync(string sourcePath, string destinationPath)
  {
    using var source = File.Open(sourcePath, FileMode.Open, FileAccess.Read);
    using var destination = File.Open(destinationPath, FileMode.OpenOrCreate, FileAccess.Write);
    
    var buffer = new byte[100];
    
    var bytesRead = await source.ReadAsync(buffer, 0, 100);
    
    await destination.WriteAsync(buffer, 0, bytesRead);
  }
}

Ideally, the way to use this method is as following:

var sourcePath = /* ... */;
var destinationPath = /* ... */;

await FileUtils.CopyAsync(sourcePath, destinationPath);

But, sometimes somebody might need to have this operation finish synchronously and write code like this:

var sourcePath = /* ... */;
var destinationPath = /* ... */;

FileUtils.CopyAsync(sourcePath, destinationPath).Wait();

Interestingly enough, the above code will cause a deadlock, and the whole application will become unresponsive. Why? Let’s analyze. First I’ll rewrite the code a little bit, so that we can see what’s going on more clearly:

var sourcePath = /* ... */;
var destinationPath = /* ... */;

var task = FileUtils.CopyAsync(sourcePath, destinationPath);
task.Wait();

On line 4 we call the async method FileUtils.CopyAsync. Since we don’t await it, the following happens: we enter the method and execute it synchronously until the first await keyword, which is the following line:

var bytesRead = await source.ReadAsync(buffer, 0, 100);

After hitting the await we exit the method and return a Task object, which represents a promise to finish reading operation somewhere in the future. After that, on line 5, we call Wait() on the task, blocking the main thread until the task finishes.

While the main thread is blocked, eventually the read operation completes and the task scheduler has to decide how to run the continuation of the async method, which is the write operation. Since there is a SynchronizationContext on the main thread, it posts it there, which means that it will be executed on the main thread. But the main thread is waiting until the task is completed and it cannot execute anything else!

This is a deadlock. However, it’s pretty easy to resolve it. To do that, we must ensure that continuation of the CopyAsync method doesn’t run on the main thread. And the way to do that is to use ConfigureAwait:

public static class FileUtils
{
  public static async Task CopyAsync(string sourcePath, string destinationPath)
  {
    using var source = File.Open(sourcePath, FileMode.Open, FileAccess.Read);
    using var destination = File.Open(destinationPath, FileMode.OpenOrCreate, FileAccess.Write);
    
    var buffer = new byte[100];
    
    // The following line has changed.
    var bytesRead = await source.ReadAsync(buffer, 0, 100).ConfigureAwait(false);
    
    await destination.WriteAsync(buffer, 0, bytesRead);
  }
}

Rewriting code this way makes sure that the method completes successfully even if the main thread is blocked by waiting on it. Also, it’s enough to only put ConfigureAwait(false) on the first await, since the rest will execute on the thread pool anyways.

Conclusion