How to explain await/async Synchronization Context switching behavior
Asked Answered
O

1

6

There are a couple of things (but 1 main thing) that I don't understand about the behavior of the following code.

Can someone help explain this?

It's actually pretty simple code - just one regular method calling an async method. And in the async method I use a using block to try to temporarily change the SynchronizationContext.

At different points in the code, I probe for the current SynchronizationContext.

Here are my questions:

  1. When execution reaches position "2.1" the context has changed to Context #2. Okay. Then, because we hit an `await`, a Task is returned and execution jumps back to position "1.2". Why then, at position 1.2, does the context not "stick" at Context #2?

    Maybe there's some magic going on here with the using statement and async methods?
  2. At position 2.2, why is the context not Context #2? Shouldn't the context be carried over into the "continuation" (the statements after `await`)?

Code:

    public class Test
    {
        public void StartHere()
        {
            SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());

            this.logCurrentSyncContext("1.1"); // Context #1
            Task t = f();
            this.logCurrentSyncContext("1.2"); // Context #1, why not Context #2?
            t.Wait();
            this.logCurrentSyncContext("1.3"); // Context #1
        }


        private async Task f()
        {
            using (new ThreadPoolSynchronizationContextBlock())
            {
                this.logCurrentSyncContext("2.1");  // Context #2
                await Task.Delay(7000);
                this.logCurrentSyncContext("2.2");  // Context is NULL, why not Context #2?
            }

            this.logCurrentSyncContext("2.3");  // Context #1
        }


        // Just show the current Sync Context. Pass in some kind of marker so we know where, in the code, the logging is happening

        private void logCurrentSyncContext(object marker)
        {
            var sc = System.Threading.SynchronizationContext.Current;
            System.Diagnostics.Debug.WriteLine(marker + " Thread: " + Thread.CurrentThread.ManagedThreadId + " SyncContext: " + (sc == null? "null" : sc.GetHashCode().ToString()));
        }

        public class ThreadPoolSynchronizationContextBlock : IDisposable
        {
            private static readonly SynchronizationContext threadpoolSC = new SynchronizationContext();

            private readonly SynchronizationContext original;

            public ThreadPoolSynchronizationContextBlock()
            {
                this.original = SynchronizationContext.Current;
                SynchronizationContext.SetSynchronizationContext(threadpoolSC);
            }

            public void Dispose()
            {
                SynchronizationContext.SetSynchronizationContext(this.original);
            }
        }
    }

Results:

1.1 Thread: 9 SyncContext: 37121646 // I call this "Context #1"
2.1 Thread: 9 SyncContext: 2637164 // I call this "Context #2"
1.2 Thread: 9 SyncContext: 37121646
2.2 Thread: 11 SyncContext: null
2.3 Thread: 11 SyncContext: 37121646
1.3 Thread: 9 SyncContext: 37121646
Outbrave answered 20/4, 2017 at 1:11 Comment(4)
2.2: referencesource.microsoft.com/#mscorlib/system/threading/Tasks/…Illuviation
1.2: referencesource.microsoft.com/#mscorlib/system/runtime/…Illuviation
It seems like your context block gets under GC. Did you check the Dispose call time?Veloz
I'm not sure what you mean by "under GC". The Dispose method gets called only one time - by Thread #11 at the end of the using block.Outbrave
V
5

2.2 Is quite simple to explain, 1.2 not as easy.

The reason 2.2 prints null is due to when you await using the default (new SynchronizationContext) or null SynchronizationContext, the Post method will get called passing in the continuation delegate, this is scheduled on the ThreadPool. It makes no effort to restore the current instance, it relies on the current SynchronizationContext being null for these continuations when they run on the ThreadPool (which it is). To be clear, because you are not using .ConfigureAwait(false) your continuation will get posted to the captured context as you are expecting, but the Post method in this implementation doesn't preserve/flow the same instance.

To fix this (i.e. make your context "sticky"), you could inherit from SynchronizationContext, and overload the Post method to call SynchronizationContext.SetSynchronizationContext(this) with the posted delegate (using Delegate.Combine(...)). Also, the internals treat SynchronizationContext instances the same as null in most places, so if you want to play with this stuff, always create an inheriting implementation.

For 1.2, this actually surprised me also, as my understanding was that this would call the underlying state machine (along with all the internals from AsyncMethodBuilder), but it would be called synchronously while maintaining its SynchronizationContext.

I think what we are seeing here is explained in this post, and it's to do with ExecutionContext being captured and restored inside of the AsyncMethodBuilder / async state machine, this is protecting and preserving the calling ExecutionContext and hence SynchronizationContext. Code for this can been seen here (thanks @VMAtm).

Vanadium answered 23/4, 2017 at 13:16 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.