How to Explain Wait / Async Synchronization Context Switching Behavior
There are a few things (but the main one is 1) that I don't understand about the behavior of the following code.
Can someone explain this?
It's actually pretty simple code - just one regular method calling the async method. And in the async method, I am using a using block to try and temporarily change the SynchronizationContext.
At various points in the code, I check the current SynchronizationContext.
Here are my questions:
-
When execution reaches position "2.1", the context has changed to Context # 2. Good. Then, since we hit await, the Task returns and execution returns to position "1.2". Why then position 1.2, doesn't the context mean "stick to" in the context # 2?
Maybe there is some magic here using the using statement and asynchronous methods? -
In position 2.2, why is the context not Context # 2? Shouldn't the context be transferred to the "continuation" (statements after the "wait")?
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
source to share
2.2
Simple enough to explain, 1.2
not so simple.
The reason it 2.2
prints is null
because when you await
are using default ( new SynchronizationContext
) or null
SynchronizationContext, then the Post
method will be called passing into the continuation of the delegate that's scheduled on the ThreadPool . It does not make any effort to restore the current instance, it relies on the current SynchronizationContext
one being null
for these continuations when they are started in the ThreadPool (which it is). To be clear, since you are not using .ConfigureAwait(false)
, your continuation will be posted to the captured context as you expect, but the method Post
in that implementation does not save / carry over the same instance.
To fix this (ie make your context "sticky"), you can inherit from SynchronizationContext
and overload the method Post
to call SynchronizationContext.SetSynchronizationContext(this)
with the hosted delegate (using Delegate.Combine(...)
). Plus, internals handle instances the SynchronizationContext
same way they do null
most places, so if you want to play with this stuff, always create an inheriting implementation.
For 1.2
this it really surprised me as I figured it would call the underlying state machine (along with all internals from AsyncMethodBuilder
), but it would be called synchronously, keeping it alive SynchronizationContext
.
I think this is where we are explaining in this post and this is because the ExecutionContext is captured and restored inside AsyncMethodBuilder
/ async, it protects and saves the call ExecutionContext
and hence SynchronizationContext
. The code for this can be seen here (thanks @VMAtm).
source to share