Why the default SynchronizationContext is not captured in a Console App?
Asked Answered
P

4

17

I'm trying to learn more about the SynchronizationContext, so I made this simple console application:

private static void Main()
{
    var sc = new SynchronizationContext();
    SynchronizationContext.SetSynchronizationContext(sc);
    DoSomething().Wait();
}

private static async Task DoSomething()
{
    Console.WriteLine(SynchronizationContext.Current != null); // true
    await Task.Delay(3000);
    Console.WriteLine(SynchronizationContext.Current != null); // false! why ?
}

If I understand correctly, the await operator captures the current SynchronizationContext then posts the rest of the async method to it.

However, in my application the SynchronizationContext.Current is null after the await. Why is that ?

EDIT:

Even when I use my own SynchronizationContext it is not captured, although its Post function is called. Here is my SC:

public class MySC : SynchronizationContext
{
    public override void Post(SendOrPostCallback d, object state)
    {
        base.Post(d, state);
        Console.WriteLine("Posted");
    }
}

And this is how I use it:

var sc = new MySC();
SynchronizationContext.SetSynchronizationContext(sc);

Thanks!

Pappus answered 7/10, 2018 at 8:43 Comment(5)
Link: First try getting the current synchronization context. If the current context is really just the base SynchronizationContext type, which is intended to be equivalent to not having a current SynchronizationContext at all, then ignore it. This helps with performance by avoiding unnecessary posts and queueing of work items, but more so it ensures that if code happens to publish the default context as current, it won't prevent usage of a current task scheduler if there is one.Epigastrium
P.S. Your Post implementation is incorrect. It must ensure that delegate is invoked in proper context. base.Post does not do it for you.Epigastrium
@PetSerAl "If the current context is really just the base SynchronizationContext type..." but MySC is not the base SC type; means syncCtx.GetType() != typeof(SynchronizationContext) is true.Pappus
@PetSerAl And yes my Post is incorrect .. it's just a fake implementation for learning purposes :)Pappus
As you say, your Post is called. That is where context capturing ends. It only responsible for calling Post on captured context, nothing else. Anything else is Post implementer responsibility.Epigastrium
W
25

The word "capture" is too opaque, it sounds too much like that is something that the framework is supposed to. Misleading, since it normally does in a program that uses one of the default SynchronizationContext implementations. Like the one you get in a Winforms app. But when you write your own then the framework no longer helps and it becomes your job to do it.

The async/await plumbing gives the context an opportunity to run the continuation (the code after the await) on a specific thread. That sounds like a trivial thing to do, since you've done it so often before, but it is in fact quite difficult. It is not possible to arbitrarily interrupt the code that this thread is executing, that would cause horrible re-entrancy bugs. The thread has to help, it needs to solve the standard producer-consumer problem. Takes a thread-safe queue and a loop that empties that queue, handling invoke requests. The job of the overridden Post and Send methods is to add requests to the queue, the job of the thread is to use a loop to empty it and execute the requests.

The main thread of a Winforms, WPF or UWP app has such a loop, it is executed by Application.Run(). With a corresponding SynchronizationContext that knows how to feed it with invoke requests, respectively WindowsFormsSynchronizationContext, DispatcherSynchronizationContext and WinRTSynchronizationContext. ASP.NET can do it too, uses AspNetSynchronizationContext. All provided by the framework and automagically installed by the class library plumbing. They capture the sync context in their constructor and use Begin/Invoke in their Post and Send methods.

When you write your own SynchronizationContext then you must now take care of these details. In your snippet you did not override Post and Send but inherited the base methods. They know nothing and can only execute the request on an arbitrary threadpool thread. So SynchronizationContext.Current is now null on that thread, a threadpool thread does not know where the request came from.

Creating your own isn't that difficult, ConcurrentQueue and delegates help a lot of cut down on the code. Lots of programmers have done so, this library is often quoted. But there is a severe price to pay, that dispatcher loop fundamentally alters the way a console mode app behaves. It blocks the thread until the loop ends. Just like Application.Run() does.

You need a very different programming style, the kind that you'd be familiar with from a GUI app. Code cannot take too long since it gums up the dispatcher loop, preventing invoke requests from getting dispatched. In a GUI app pretty noticeable by the UI becoming unresponsive, in your sample code you'll notice that your method is slow to complete since the continuation can't run for a while. You need a worker thread to spin-off slow code, there is no free lunch.

Worthwhile to note why this stuff exists. GUI apps have a severe problem, their class libraries are never thread-safe and can't be made safe by using lock either. The only way to use them correctly is to make all the calls from the same thread. InvalidOperationException when you don't. Their dispatcher loop help you do this, powering Begin/Invoke and async/await. A console does not have this problem, any thread can write something to the console and lock can help to prevent their output from getting intermingled. So a console app shouldn't need a custom SynchronizationContext. YMMV.

Wooer answered 7/10, 2018 at 11:17 Comment(0)
R
4

By default, all threads in console applications and Windows Services only have the default SynchronizationContext.

Kindly refer to the MSDN article Parallel Computing - It's All About the SynchronizationContext. This has detailed information regarding SynchronizationContexts in various types of applications.

Rescissory answered 7/10, 2018 at 8:58 Comment(0)
L
3

To elaborate on what was already pointed out.

The SynchronizationContext class that you use in the first code snippet is the default implementation, which doesn't do anything.

In the second code snippet, you create your own MySC context. But you are missing the bit that would actually make it work:

public override void Post(SendOrPostCallback d, object state)
{
    base.Post(state2 => {
        // here we make the continuation run on the original context
        SetSynchronizationContext(this); 
        d(state2);
    }, state);        
    Console.WriteLine("Posted");
}
Lifeline answered 7/10, 2018 at 10:33 Comment(1)
But the code after waiting is still continuing in a different thread, why is this happening?Navigate
N
1

Implementing your own SynchronizationContext is doable, but not trivial. It's much easier to use an existing implementation, like the AsyncContext class from the Nito.AsyncEx.Context package. You can use it like this:

using System;
using System.Threading;
using System.Threading.Tasks;
using Nito.AsyncEx;

public static class Program
{
    static void Main()
    {
        AsyncContext.Run(async () =>
        {
            await DoSomethingAsync();
        });
    }

    static async Task DoSomethingAsync()
    {
        Console.WriteLine(SynchronizationContext.Current != null); // True
        await Task.Delay(3000);
        Console.WriteLine(SynchronizationContext.Current != null); // True
    }
}

Try it on Fiddle.

The AsyncContext.Run is a blocking method. It will complete when the supplied asynchronous delegate Func<Task> action completes. All asynchronous continuations are going to run on the console application's main thread, provided that there is no Task.Run or ConfigureAwait(false) that would force your code to exit the context.

The consequences of using a single-threaded SynchronizationContext in a console application are that:

  1. You'll no longer have to worry about thread-safety, since all your code will be funneled to a single thread.
  2. Your code becomes susceptible to deadlocks. Any .Wait(), .Result, .GetAwaiter().GetResult() etc inside your code is very likely to cause your application to freeze, in which case you'll have to kill the process manually from the Windows Task Manager.
Nobell answered 30/7, 2021 at 8:58 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.