6

As a learning exercise, I'm trying to reproduce an async/await deadlock that occurs in a normal windows form, but using a console app. I was hoping the code below would cause this to happen, and indeed it does. But the deadlock also happens unexpectedly when using await.

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
static class Program
{
    static async Task Main(string[] args)
    {
        // no deadlocks when this line is commented out (as expected)
        SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext()); 
        Console.WriteLine("before");
        //DoAsync().Wait(); // deadlock expected...and occurs
        await DoAsync(); // deadlock not expected...but also occurs???
        Console.WriteLine("after");
    }
    static async Task DoAsync()
    {
        await Task.Delay(100);
    }
}

I'm mostly curious if anyone knows why this is happening?

Darragh
  • 2,526
  • 1
  • 23
  • 31
  • My standing advise is to never try to learn anything about Multtiasking in Console. You have to make sure the console programm stays alive and responsive. That are two things the Event Loop does for you already on Forms and WPF. | I also never use such complicated class for Synchronisation. A plain old lock statement and mutext is all I ever needed. – Christopher Nov 20 '19 at 17:23
  • 3
    WindowsFormsSynchronizationContext works off the assumption that a program is using Application.Run(). Which is a big deal to "make stuff happen", async continuations as well. Specifically that the continuation can happen on the same thread that called Run(), highly desirable since UI is not thread-safe. And extra especially so in UWP, the WinRT api it uses is highly asynchronous and the reason async/await was added to the C# language in 2012. Since there is no dispatcher loop the program is doomed to get stuck. – Hans Passant Nov 20 '19 at 17:37
  • The article at https://devblogs.microsoft.com/pfxteam/await-synchronizationcontext-and-console-apps/ gives code for a SynchronizationContext that works in console applications – NineBerry Nov 20 '19 at 17:42
  • You might be interested to check [my `WinformsApartment` code](https://stackoverflow.com/a/57626659/1768303). – noseratio Nov 25 '19 at 19:53

3 Answers3

3

WindowsFormsSynchronizationContext will post any delegates its given to a WinForms message loop, which is serviced by a UI thread. However you never set one of those up and there is no UI thread, so anything you post will simply disappear.

So your await is capturing a SynchronizationContext which will never run any completions.

What's happening is:

  1. Your Task is being returned from Task.Delay
  2. The main thread starts synchronously waiting for this Task to complete, using a spin lock (in Task.SpinThenBlockingWait)
  3. The spin lock times out, and the main thread creates an event to wait on, which is set by a continuation on the Task
  4. The Task completes (you can see that it has, because its Status is RanToCompletion)
  5. The Task tries to complete the continuation which will release the event the main thread is waiting on (Task.FinishContinuations). This ends up calling TaskContinuation.RunCallback (though I haven't traced that call path yet), which calls your WindowsFormSynchronizationContext.Post.
  6. However, Post does nothing, and deadlock occurs.

To get that information, I did the following things:

  1. Try to call new WindowsFormsSynchronizationContext.Post(d => ..., null), see that the delegate isn't called.
  2. Construct my own SynchronizationContext and install it, see when Post gets called.
  3. Break the debugger during the deadlock, look at Threads and look at the Call Stack of the main thread.
  4. Capture the task being awaited in a variable, look at it in a watch window, right-click -> Make Object ID, then put that Object ID in the watch window. Let it deadlock, break, and inspect the task in the watch window from its Object ID.
canton7
  • 37,633
  • 3
  • 64
  • 77
3

This happens because the WindowsFormsSynchronizationContext depends on the existence of a standard Windows message loop. A console application does not start such a loop, so the messages posted to the WindowsFormsSynchronizationContext are not processed, the task continuations are not invoked, and so the program hangs on the first await. You can confirm the non-existence of a message loop by querying the boolean property Application.MessageLoop.

Gets a value indicating whether a message loop exists on this thread.

To make the WindowsFormsSynchronizationContext functional you must start a message loop. It can be done like this:

static void Main(string[] args)
{
    EventHandler idleHandler = null;
    idleHandler = async (sender, e) =>
    {
        Application.Idle -= idleHandler;
        await MyMain(args);
        Application.ExitThread();
    };
    Application.Idle += idleHandler;
    Application.Run();
}

The MyMain method is your current Main method, renamed.


Update: Actually the Application.Run method installs automatically a WindowsFormsSynchronizationContext in the current thread, so you don't have to do it explicitly. If you want you can prevent this automatic installation, be configuring the property WindowsFormsSynchronizationContext.AutoInstall before calling Application.Run.

The AutoInstall property determines whether the WindowsFormsSynchronizationContext is installed when a control is created, or when a message loop is started.

marc_s
  • 732,580
  • 175
  • 1,330
  • 1,459
Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • I wouldn't call it a deadlock. If another thread is created to pump the MessageLoop, the application will continue. Deadlock means two threads waiting for each other to release a resource. – Aron Dec 16 '19 at 09:51
  • @Aron I guess your comment was intended for the main question, not for this answer. :-) – Theodor Zoulias Dec 16 '19 at 10:16
2

I believe it's because async Task Main is nothing more than syntax sugar. In reality it looks like:

static void Main(string[] args) => MainAsync(args).GetAwaiter().GetResult();

I.e. it's still blocking. Continuation of DoAsync is trying to execute on original thread because synchronization context isn't null. But the thread is stuck because it's waiting when task is completed. You can fix it like this:

static class Program
{
    static async Task Main(string[] args)
    {
        SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());
        Console.WriteLine("before");
        await DoAsync().ConfigureAwait(false); //skip sync.context
        Console.WriteLine("after");
    }
    static async Task DoAsync()
    {
        await Task.Delay(100).ConfigureAwait(false); //skip sync.context
    }
}
mtkachenko
  • 5,389
  • 9
  • 38
  • 68
  • 1
    This needs to be expanded a bit, for people that don't understand about sync contexts, or that console apps don't have one. Without `SynchronizationContext.SetSynchronizationContext` the program would run just fine. It's because of that call that after `await` execution tries to get back to the original sync context, ie the original thread, which is blocked by `GetResult()` – Panagiotis Kanavos Nov 20 '19 at 17:31
  • @PanagiotisKanavos Ok, I'll expand it. I wrote this answer for the question author who already knows the original problem with tasks and sync.context. – mtkachenko Nov 20 '19 at 17:33
  • I understand that but imagine someone else trying to understand why this answer is helpful. – Panagiotis Kanavos Nov 20 '19 at 17:36
  • "*Continuation of DoAsync is trying to execute on original thread because synchronization context isn't null.*" -- is that right? Surely it'll try and continue on the captured `WindowsFormsSynchronizationContext`? That also doesn't explain why the main thread is stuck in [`TaskAwaiter.HandleNonSuccessAndDebuggerNotification`](https://referencesource.microsoft.com/#mscorlib/system/runtime/compilerservices/TaskAwaiter.cs,aa2362a27deccda6) – canton7 Nov 20 '19 at 17:38
  • @NineBerry you should write `ConfigureAwait(false)`. See this question https://stackoverflow.com/questions/57054770/is-configureawaitfalse-required-on-all-levels-of-the-async-chain-when-no-real – mtkachenko Nov 20 '19 at 17:48