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 await
ing 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
await
ing 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 theSynchronizationContext
and make our continuation execute on another thread? The answer isConfigureAwait
.
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:
- Line 3: We enter the
Start
method on the main thread, first log message saysStart: 1
. - Line 5: We await the
Wait
method, withoutConfigureAwait
. - Line 12: We are inside the
Wait
method, still on the main thread; log message saysWait start: 1
. - Line 14: We are asynchronously waiting 100 milliseconds with
ConfigureAwait(false)
. - Line 16: After
ConfigureAwait(false)
we have moved to a worker thread; log message saysAfter first wait: 53
. - Line 18: Again waiting 100 milliseconds, this time without
ConfigureAwait(false)
. - Line 20: To understand what happens here, we must remember that
SynchronizationContext
exists only on the main thread, not in the application itself. Since thisawait
executes on worker thread, even thoughawait
keyword’s default behaviour is to post continuation toSynchronizationContext
, it cannot find one. So it ends up posting its continuation on the thread pool again; log message saysAfter second wait: 54
. - Line 7: After exiting the
Wait
method we are back on the main thread. It doesn’t matter thatWait
’s code was running on the thread pool, at this point only theStart
method matters. Since before theawait
we were on the main thread and there was noConfigureAwait(false)
, we continue the rest of the method on the main thread.
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
- The
await
keyword always tries to bring you back where you were. In Unity, this means that afterawait
you will always return to the main thread. - If you need the rest of the method after
await
to execute on the thread pool, useConfigureAwait(false)
. - Consider using
ConfigureAwait(false)
in the helper methods that do not call Unity’s API or use shared data. This way you will offload their work from the main thread, which may be beneficial for performance. - Don’t make
async
methods finish synchronously by usingTask.Wait()
orTask<T>.Result
. If you absolutely do need to do that, make sure that it will not cause deadlock.