Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Await vs. Wait Analogies and TaskCompletionSource

5.00/5 (12 votes)
13 Nov 2023CPOL9 min read 8.2K   77  
Explain by analogy the difference between a Synchronous Wait and an Asynchronous await
This article gives an analogy to explain the difference between a Synchronous Wait and an Asynchronous await, and discusses issues which can occur when using TaskCompletionSource.

Introduction

This article explains by analogy the difference between a Synchronous wait and an Asynchronous await. Then I’ll explain how things can go wrong when using a TaskCompletionSource with the wrong type of wait.

Synchronous Wait

Example

C#
Task t = Task.Run(() => Foo());
t.Wait();

Analogy

Image 1

I start from home and drive up to a stop barrier. I leave a message for the worker I’m waiting for, asking him to please wake me when he reads this message. Then I take a nap.

Image 2

Later, the worker I was waiting for gets to the point where he sees my note. He raises the gate, taps on my car window waking me up, and I resume driving down the path. The worker then continues with his work.

Code Example

C#
static void Main(string[] args)
{
    Task t = Task.Run(() => Foo());
    t.Wait();
    Console.WriteLine("Finished waiting");
}

static void Foo()
{
    Thread.Sleep(3000);
}

Asynchronous Wait – Continue on Any Thread

Example

C#
Task t = FooAsync();
await t.ConfigureAwait(false);

The ConfigureAwait(false) means the code after the await may be run on a background thread pool thread.

Analogy

Image 3

I drive up to a stop bar. I leave a message for the worker I’m waiting on asking him to finish my job for me. I then turn around and go home where I can work on other chores.

Image 4

Later, the worker I’m awaiting on comes along and sees my note. He's expecting this kind of note, and he handles it by passing the note to Fido the dog waiting in the pool. Fido finishes the job for me. The worker continues on his way.

Code Example

C#
static async Task Main(string[] args)
{
    Task t = FooAsync();
    await t.ConfigureAwait(false);
    Console.WriteLine("Finished awaiting");
}

private async static Task FooAsync()
{
    await Task.Delay(3000);
}

Asynchronous Wait – Continue on Original Thread

Example

Notice for this example we do not specify .ConfigureAwait(false). Typically, this means we’re on the GUI thread and we need to continue on the GUI thread.

C#
Task t = FooAsync();
await t;

Analogy

Image 5

I drive up to a stop bar. I leave a message for the worker I’m awaiting on, asking him to please notify me when I can continue my job. I then turn around and go home where I can work on other chores.

Image 6

Later, the worker I’m awaiting on arrives, sees my note, and delivers the note to my mailbox telling me to finish the job I started. The worker then continues on his way.

Image 7

I eventually check my mail and find the message directing me to finish the job I started. I jump back to where I was and continue down the path I was on, finishing my work.

Code Example

C#
static async Task Main(string[] args)
{
    Task t = FooAsync();
    await t;
    Console.WriteLine("Finished waiting");
}

How it Goes Wrong

Consider what happens if the worker I’m waiting on is expecting to handle a synchronous wait, but I instead call await on him.

Image 8

I drive up to a stop bar. I leave a message for the worker I’m awaiting on asking him to please finish my job for me. I then turn around and go home.

Image 9

Later a Synchronous worker comes along. The synchronous worker is expecting a note which says, “Tap on my window and wake me.” This is a quick action which won’t take but a moment of his time, and he’s happy to do it and be on his way. However, he instead receives a note which says, “Finish my job for me.” Suddenly, our poor synchronous worker is ordered to finish my job! He dutifully raises the gate and goes down the path to finish my job. Who knows how long of a task he just got sucked into? He may be gone a short time, he may be gone a long time, he might even never return! Who knows? It all depends on what the rest of my job is. He won’t get to return to finish his work until he either finishes my job or encounters another await allowing him to return. If the rest of my job is a task which never ends, he will never return. This can lead to a deadlock if someone else decides they need to wait for this worker who now may never return.

Awaiting on a Synchronous Task Completion Source

The above is what can happen if we create a Synchronous TaskCompletionSource and later await on it. The Synchronous TaskCompletionSource is expecting to have Wait() called on its task. Wait()is where I leave a note asking for a tap on my car window to wake me so I can proceed; await is where I leave a note telling the worker to finish the job for me. If I await on a Synchronous TaskCompletionSource and use ConfigureAwait(false), I am leaving a note telling the worker to complete the job for me, something he is not expecting which might take a long time.

Code Example

Below is a complete program we will analyze in depth (admittedly contrived, but it demonstrates the issue):

C#
static async Task Main(string[] args)
{
    TaskCompletionSource<int> tcs = new TaskCompletionSource<int>();
    Task.Run( () =>
    {
        DoWork();
        tcs.SetResult(5);
        MoreWork();
    });

    await tcs.Task;
    int result = tcs.Task.Result;
}

private static void DoWork()
{
    Thread.Sleep(3000);
}

private static void MoreWork()
{
    int j = 2000;
    for (int i = 0; i < j; ++i)
    {
        int x = i;
    }
    int y = 1;
}

Because Main() is an asynchronous task, the compiler creates some hidden code wrapping Main() which handles the async part of calling Main(). This wrapping code looks something like the following (this is how await works under the hood for a console program):

C#
Static void RealMain()
{
    private volatile object m_lock = new Object();

    Task t = Main();

    t.continuationObject = new Action(() =>
        lock (m_lock)
        {
            Monitor.PulseAll(m_lock));
        });

    lock (m_lock)
    {
        Monitor.Wait(m_lock);
    }

    if (!t.IsRanToCompletion) ThrowForNonSuccess(t);
    return;
}

[See Internals of How the await Keyword Works. In that article, the wrapping code is called Main() which calls SubMain(). Here, we have wrapping code called RealMain() which calls Main().]

We shall now trace the execution path when the above program is run. The diagram below initially looks a bit complicated, but don't worry, we'll examine each of the 10 steps below one at a time.

Code Flow Diagram

  1. The program begins by calling Main():

    C#
    Task t = Main();
  2. Main() creates a synchronous TaskCompletionSource and then calls Task.Run() to create a background task. It then calls await tcs.Task:

    C#
    TaskCompletionSource tcs = new TaskCompletionSource();
    Task.Run( () =>
    {
        DoWork();
        tcs.SetResult(5);
        MoreWork();
    });
    
    await tcs.Task;
  3. tcs.Task returns an incomplete task t to the caller.

  4. The caller sets a continuation Action() to be called when task t completes. The caller then calls Monitor.Wait(m_lock) and the main thread waits for someone else to call Monitor.PulseAll(m_lock) to release it.

    C#
    t.continuationObject = new Action(() =>
        lock (m_lock)
        {
            Monitor.PulseAll(m_lock));
        });
    
    lock (m_lock)
    {
        Monitor.Wait(m_lock);
  5. Meanwhile, our background Task.Run() task calls DoWork(). When DoWork() returns, the background thread then calls tcs.SetResult(5).

    C#
    DoWork();
    tcs.SetResult(5);
  6. tcs.SetResult(5) places the number 5 in private field tcs.m_result. The tcs.Task status is set to complete, and any continuation actions are called. We do have a continuation action which was set up by the await tcs.Task at the end of step 2. This is where the synchronous TaskCompletionSource tcs is expecting the continuation action to be, “Please knock on the car window to awaken the driver so the driver can continue on his way.” Instead, tcs finds a note saying, “Finish the job for me.” The background task jumps to the line of code following the await tcs.Task and runs int result = tcs.Task.Result which copies the number 5 from tcs.m_result to local variable result.

    C#
        int result = tcs.Task.Result;
    }
  7. After running int result = tcs.Task.Result, the background thread comes to the end of Task Main(). Task Main() is now complete. Part of setting the task to complete is calling any continuation Action() that has been set up for task t. There is a continuation Action() that was set up in step 4, which is to call Monitor.PulseAll(m_lock).

    C#
    lock (m_lock)
    {
        Monitor.PulseAll(m_lock);
    });

    Calling Monitor.PulseAll(m_lock) releases the thread which called Main() and was waiting at Monitor.Wait(m_lock).

  8. The background thread, now having completed the unexpected “Finish the job for me” chore, now gets back to doing its job, and calls MoreWork().

    C#
    MoreWork();

    Meanwhile, the original thread we started with resumes running. It checks to see if the task completed successfully, and if not, it calls ThrowForNonSuccess(t). If the task did complete successfully, it calls return.

    C#
    if (!t.IsRanToCompletion) ThrowForNonSuccess(t);
    return;
  9. We now have an interesting race condition: We have the background thread finally returning to work on its own task MoreWork(), and at the same time, we have the main thread waking up and calling return. Returning from RealMain() means the program has completed running. The operating system then proceeds to dispose of the process itself including the background thread.

  10. The background thread starts working on MoreWork(), and a few moments later the Grim Reaper comes along and ends its life because program RealMain() has finished.

    C#
    MoreWork(); → ☠

In the program above, one can try placing a breakpoint at the line y = 1 and vary the number for int j = 2000. Too high a number for j and the breakpoint will never be hit because the process is terminated before the loop completes. (It's even possible the breakpoint will never be hit even when using a value of 0 for j. It all depends on how fast the other thread terminates the process.)

How to Avoid This Scenario

Search your source code for:

C#
new TaskCompletionSource

A synchronous TaskCompletionSource will typically have no parameters and look something like this:

C#
var tcs = new TaskCompletionSource<int>();

An Asynchronous TaskCompletionSource will have the parameter TaskCreationOptions.RunContinuationsAsynchronously, like this:

C#
var tcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);

Now find out where the tcs.Task is being used and determine if it’s using Wait() or await. Make sure the type of wait matches up with the type of TaskCompletionSource that was created.

We don’t want to see a Synchronous TaskCompletionSource being used with:

C#
await tcs.Task;

This is the example we anaylzed above.

Add 'sync' or 'async' to the Variable Names

Another way to make it clear is to rename variables so the names include whether this is a synchronous or an asynchronous TaskCompletionSource. For example:

C#
var syncTcs = new TaskCompletionSource();
Task syncTask = syncTcs.Task;
...
await syncTask;

Oops, we're awaiting a Synchronous Task. Better fix that.

The same problem can occur in the other direction:

C#
var asyncTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Task asyncTask = asyncTcs.Task;
...
asyncTask.Wait();

Oops, we're waiting on an Asynchronous Task. Better fix that.

The hidden problem with calling Wait() on an Asynchronous Task is we are tying up a thread waiting, and we need another thread to finish the job for us while we wait. It’s possible to end up in a scenario where all available threads end up waiting on asynchronous tasks, and there are no more threads available to complete any of the tasks. (See Threadpool Starvation for more information.)

The One Case Where It Works Even Though It's Wrong

The one case where one can get away with awaiting a synchronous TaskCompletionSource is when the background task being awaited on has absolutely nothing else to do afterwards. The worker was about to go home, and the very last thing he does is check the note I left behind. He was expecting to just tap on my car window to awake me and then be on his way home; instead he gets sucked into completing my job for me. It doesn’t matter because he has nothing else left to do afterwards. (There’s still the possibility that someone else is waiting for this worker to complete his job. It's still wrong and should be fixed.)

References

History

  • 11th November, 2023: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)