Strange deadlock while sync with TaskCompletionSource and AutoResetEvent

99 views Asked by At

While running the following C# program, I randomly got two different results. Result 1 (occurred frequently) is actually deadlocked. Please explain to me why this happened. I am not expecting the deadlock while using WaitOne on AutoResetEvent.

   class Program
    {
        static async Task Main()
        {
            TaskCompletionSource<bool> tcs = new();
            AutoResetEvent evtFinished = new AutoResetEvent(false);
            Func<Task> func = async Task () => 
                { await Task.Delay(10); tcs.SetResult(true); evtFinished.Set(); };
            var t = func.Invoke();
            //await func.Invoke();
            await tcs.Task;
            if (evtFinished.WaitOne(1000))
                Console.WriteLine("First Wait OK");
            else
            {
                Console.WriteLine("First Wait Timeout");
                for (int i = 0; i < 10 && !evtFinished.WaitOne(100); i++)
                    Console.WriteLine($" {i+1} Wait Timeout"); 
                await t;
                evtFinished.WaitOne();
                Console.WriteLine("Wait OK");
            }
        }
    }

<<<<<Result 1: >>>>>>
# First Wait Timeout
#  1 Wait Timeout
#  2 Wait Timeout
#  3 Wait Timeout
#  4 Wait Timeout
#  5 Wait Timeout
#  6 Wait Timeout
#  7 Wait Timeout
#  8 Wait Timeout
#  9 Wait Timeout
#  10 Wait Timeout
# Wait OK

<<<<<Result2 >>>>>
# First Wait OK
2

There are 2 answers

2
Yarik On

By default, TaskCompletionSource.SetResult runs its Task continuations synchronously (in most cases).

This means that in your case, everything after await tcs.Task and up to the next await will run before the evtFinished.Set() is even reached.

There are multiple way to solve this, but the easiest one will be to pass the TaskContinuationOptions.RunContinuationsAsynchronously flag to the TaskCompletionSource constructor.

7
Stephen Cleary On

There's a few combining reasons why this is happening. First, the basic behavior of await (as described on my blog) is that it captures a "context" and - by default - resumes in that "context". In this case, there is no context, so async methods resume on a thread pool thread. Second, when an awaitable completes, if the thread completing the awaitable is compatible with the context captured by the await, it will execute that continuation synchronously. More specifically, await uses TaskContinuationOptions.ExecuteSynchronously.

This scenario is explored in more depth in my blog post don't block in asynchronous code.

So, walking through it:

  1. Main starts on the main thread and calls func.Invoke.
  2. func.Invoke hits its await and returns an incomplete task, stored in t.
  3. Main continues executing (still on the main thread) and does the await tcs.Task.
  4. Assuming the tcs is not yet completed (this is the race condition), then Main returns an incomplete task.
  5. When the Task.Delay completes, a thread pool thread is used to resume executing func.
  6. This thread pool thread calls tcs.SetResult, which now has a continuation attached (the remainder of Main).
  7. Since the thread pool thread is compatible with the captured context (it's a thread pool thread), it just executes the continuation (the remainder of Main) synchronously.
  8. This thread then synchronously blocks waiting for evtFinished to be set, but it won't be. Note that your call stack is inverted at this point: func has actually called the Main continuation. You can verify this by making the delay much larger and running in the debugger.
  9. Eventually that same thread pool thread will hit await t, where it finally yields back to its caller, allowing func to finish running, setting evtFinished.