5

I am executing the following code snippet to test how I can change the thread, on which my code after awaiting will be called. According to @Stephen Cleary in this answer, to be able to continue executing the async code after awaiting on the same thread (context), I need to set the SynchronizationContext, and I did that, however, my code keeps continuning in a different thread.

static void Main(string[] args)
{
    var mainSyncContex = new SynchronizationContext();
    SynchronizationContext.SetSynchronizationContext(mainSyncContex);

    Console.WriteLine($"Hello World! ThreadId: {Thread.CurrentThread.ManagedThreadId}"); // <-- In thread 1

    try
    {
        Task.Run(async () =>
        {
            SynchronizationContext.SetSynchronizationContext(mainSyncContex);

            Console.WriteLine($"Is there Sync Contex?: {SynchronizationContext.Current != null}");

            Console.WriteLine($"Before delay. ThreadId: {Thread.CurrentThread.ManagedThreadId}"); // <-- In thread 3
            await Task.Delay(1000).ConfigureAwait(true);
            Console.WriteLine($"After delay. ThreadId: {Thread.CurrentThread.ManagedThreadId}"); // <-- In thread 4
            throw new Exception();
        });
    }
    catch (Exception e)
    {
        Console.WriteLine($"Exception: {e.Message} Catch. ThreadId: {Thread.CurrentThread.ManagedThreadId}");
    }

    Console.WriteLine($"Ending ThreadId: {Thread.CurrentThread.ManagedThreadId}"); // <-- In thread 1
    Console.ReadKey();
}

Output:

Hello World! ThreadId: 1
Ending ThreadId: 1
Is there Sync Contex?: True
Before delay. ThreadId: 3
After delay. ThreadId: 4

Why is that happening?

Mohammed Noureldin
  • 14,913
  • 17
  • 70
  • 99
  • Why do you care? this might imply a problematic design... – AK_ Jul 10 '20 at 11:41
  • 1
    @AK_ so basically I am just trying to understand async/await more, and it is not very pleasant when a function does not work as expected (according to my understanding). – Mohammed Noureldin Jul 10 '20 at 11:43
  • unless you're working with WinForms, and in some rare cases ASP.NET I'm pretty certain you would be better ignoring this whole part all together. Whenever writing asynchronous code it's better to avoid external state as much as possible, and stick to pure functions... – AK_ Jul 10 '20 at 12:01
  • 1
    @AK_ so to be more exact, in my work we have a 3rd party library which may "crash" or raise some exceptions, and some people in our team would like to have this whole 3rd party library to run in a totally separate thread but without creating a `Thread` explicity. Therefore I am trying to figure out and understand how we can control the thread that runs a specific task(s). And of course some additional .Net understanding does not hurt ;) – Mohammed Noureldin Jul 10 '20 at 12:08
  • 1
    Wrong *kind* of SynchronizationContext. Backgrounder [is here](https://stackoverflow.com/a/52687947/17034). – Hans Passant Jul 10 '20 at 14:14
  • 1
    [Does this answer your question?](https://devblogs.microsoft.com/pfxteam/await-synchronizationcontext-and-console-apps/) :) – aepot Jul 10 '20 at 20:03
  • Thank you Hans and aepot, I will check both links and let you know. – Mohammed Noureldin Jul 10 '20 at 21:17
  • 1
    @aepot thank you a lot, your link helped so much, I added my own implementation as an answer accordingly. – Mohammed Noureldin Jul 13 '20 at 07:58
  • @MohammedNoureldin If you're using classic.net then what you want is a seperate appdomain – AK_ Jul 27 '20 at 15:19

2 Answers2

4

I would like to show some code according to my understanding, hopefully it can help somebody.

As aepot, dymanoid and Hans Passant (thanks to them) said, using the default SynchronizationContext will do nothing more than Posting the rest of the code after awaiting to the SynchronizationContext.

I created a very very basic and NOT optimal SynchronizationContext to demonstrate how the basic implementation should look like. My implementation will create a new Thread and run some Tasks in a specific context inside the same newly created Thread.

Better implementation (but much complex) may be found here in Stephen Cleary's GitHub repository.

My implementation looks basically like following (from my GitHub repository, the code in the repository may look different in the future):

/// <summary>
/// This <see cref="SynchronizationContext"/> will call all posted callbacks in a single new thread.
/// </summary>
public class SingleNewThreadSynchronizationContext : SynchronizationContext
{
    readonly Thread _workerThread;
    readonly BlockingCollection<KeyValuePair<SendOrPostCallback, object>> _actionStatePairs = new BlockingCollection<KeyValuePair<SendOrPostCallback, object>>();

    /// <summary>
    /// Returns the Id of the worker <see cref="Thread"/> created by this <see cref="SynchronizationContext"/>.
    /// </summary>
    public int ManagedThreadId => _workerThread.ManagedThreadId;

    public SingleNewThreadSynchronizationContext()
    {
        // Creates a new thread to run the posted calls.
        _workerThread = new Thread(() =>
        {
            try
            {
                while (true)
                {
                    var actionStatePair = _actionStatePairs.Take();
                    SetSynchronizationContext(this);
                    actionStatePair.Key?.Invoke(actionStatePair.Value);
                }
            }
            catch (ThreadAbortException)
            {
                Console.WriteLine($"The thread {_workerThread.ManagedThreadId} of {nameof(SingleNewThreadSynchronizationContext)} was aborted.");
            }
        });

        _workerThread.IsBackground = true;
        _workerThread.Start();
    }

    public override void Post(SendOrPostCallback d, object state)
    {
        // Queues the posted callbacks to be called in this SynchronizationContext.
        _actionStatePairs.Add(new KeyValuePair<SendOrPostCallback, object>(d, state));
    }

    public override void Send(SendOrPostCallback d, object state)
    {
        throw new NotSupportedException();
    }

    public override void OperationCompleted()
    {
        _actionStatePairs.Add(new KeyValuePair<SendOrPostCallback, object>(new SendOrPostCallback(_ => _workerThread.Abort()), null));
        _actionStatePairs.CompleteAdding();
    }
}

and here is a Demo to use it:

static void SingleNewThreadSynchronizationContextDemo()
{
    var synchronizationContext = new SingleNewThreadSynchronizationContext();

    // Creates some tasks to test that the whole calls in the tasks (before and after awaiting) will be called in the same thread.
    for (int i = 0; i < 20; i++)
        Task.Run(async () =>
        {
            SynchronizationContext.SetSynchronizationContext(synchronizationContext);
            // Before yielding, the task will be started in some thread-pool thread.
            var threadIdBeforeYield = Thread.CurrentThread.ManagedThreadId;
            // We yield to post the rest of the task after await to the SynchronizationContext.
            // Other possiblity here is maybe to start the whole Task using a different TaskScheduler.
            await Task.Yield();

            var threadIdBeforeAwait1 = Thread.CurrentThread.ManagedThreadId;
            await Task.Delay(100);
            var threadIdBeforeAwait2 = Thread.CurrentThread.ManagedThreadId;
            await Task.Delay(100);

            Console.WriteLine($"SynchronizationContext: thread Id '{synchronizationContext.ManagedThreadId}' | type '{SynchronizationContext.Current?.GetType()}.'");
            Console.WriteLine($"Thread Ids: Before yield '{threadIdBeforeYield}' | Before await1 '{threadIdBeforeAwait1}' | Before await2 '{threadIdBeforeAwait2}' | After last await '{Thread.CurrentThread.ManagedThreadId}'.{Environment.NewLine}");
        });
}

static void Main(string[] args)
{
    Console.WriteLine($"Entry thread {Thread.CurrentThread.ManagedThreadId}");
    SingleNewThreadSynchronizationContextDemo();
    Console.WriteLine($"Exit thread {Thread.CurrentThread.ManagedThreadId}");

    Console.ReadLine();
}

Output:

Entry thread 1   
Exit thread 1  
SynchronizationContext: thread Id '5' | type 'SynchronizationContexts.SingleNewThreadSynchronizationContext.'  
Thread Ids: Before yield '11' | Before await1 '5' | Before await2 '5' | After last await '5'.

SynchronizationContext: thread Id '5' | type 'SynchronizationContexts.SingleNewThreadSynchronizationContext.'  
Thread Ids: Before yield '4' | Before await1 '5' | Before await2 '5' | After last await '5'.

SynchronizationContext: thread Id '5' | type 'SynchronizationContexts.SingleNewThreadSynchronizationContext.'  
Thread Ids: Before yield '12' | Before await1 '5' | Before await2 '5' | After last await '5'.

SynchronizationContext: thread Id '5' | type 'SynchronizationContexts.SingleNewThreadSynchronizationContext.'  
Thread Ids: Before yield '6' | Before await1 '5' | Before await2 '5' | After last await '5'.

SynchronizationContext: thread Id '5' | type 'SynchronizationContexts.SingleNewThreadSynchronizationContext.'  
Thread Ids: Before yield '10' | Before await1 '5' | Before await2 '5' | After last await '5'.

SynchronizationContext: thread Id '5' | type 'SynchronizationContexts.SingleNewThreadSynchronizationContext.'  
Thread Ids: Before yield '7' | Before await1 '5' | Before await2 '5' | After last await '5'.  
aepot
  • 4,558
  • 2
  • 12
  • 24
Mohammed Noureldin
  • 14,913
  • 17
  • 70
  • 99
2

You are using a "wrong" synchronization context. The default SynchronizationContext implementation does not "restore" the original thread but just queues the continuation either on a different thread pool thread or on the current thread:

(see Reference Sources)

public virtual void Send(SendOrPostCallback d, Object state)
{
    d(state);
}

public virtual void Post(SendOrPostCallback d, Object state)
{
    ThreadPool.QueueUserWorkItem(new WaitCallback(d), state);
}

You should use such synchronization context (like WindowsFormsSynchronizationContext) that can post and send callbacks on a particular thread associated with that context.

For a console application, consider using Stephen Cleary's AsyncContext.

dymanoid
  • 14,771
  • 4
  • 36
  • 64
  • 4
    No, there is no mistake. Stephen Cleary writes that the awaiter *captures* the context and *resumes* the continuation using that context. It depends on the context implementation on which thread the continuation will in fact run. If you want to continue on the same thread in a console app, you can use Stephen Cleary's [AsyncEx](https://github.com/StephenCleary/AsyncEx) contexts. – dymanoid Jul 10 '20 at 11:27