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
Task t = Task.Run(() => Foo());
t.Wait();
Analogy
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.
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
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
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
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.
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
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.
Task t = FooAsync();
await t;
Analogy
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.
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.
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
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.
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.
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):
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):
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.
-
The program begins by calling Main()
:
Task t = Main();
-
Main()
creates a synchronous TaskCompletionSource
and then calls Task.Run()
to create a background task. It then calls await tcs.Task
:
TaskCompletionSource tcs = new TaskCompletionSource();
Task.Run( () =>
{
DoWork();
tcs.SetResult(5);
MoreWork();
});
await tcs.Task;
-
tcs.Task
returns an incomplete task t
to the caller.
-
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.
t.continuationObject = new Action(() =>
lock (m_lock)
{
Monitor.PulseAll(m_lock));
});
lock (m_lock)
{
Monitor.Wait(m_lock);
-
Meanwhile, our background Task.Run()
task calls DoWork()
. When DoWork()
returns, the background thread then calls tcs.SetResult(5)
.
DoWork();
tcs.SetResult(5);
-
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
.
int result = tcs.Task.Result;
}
-
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)
.
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)
.
-
The background thread, now having completed the unexpected “Finish the job for me” chore, now gets back to doing its job, and calls MoreWork()
.
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
.
if (!t.IsRanToCompletion) ThrowForNonSuccess(t);
return;
-
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.
-
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.
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:
new TaskCompletionSource
A synchronous TaskCompletionSource
will typically have no parameters and look something like this:
var tcs = new TaskCompletionSource<int>();
An Asynchronous TaskCompletionSource
will have the parameter TaskCreationOptions.RunContinuationsAsynchronously
, like this:
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:
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:
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:
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