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:
-
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?
-
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
2.2
Is quite simple to explain,1.2
not as easy.The reason
2.2
printsnull
is due to when youawait
using the default (new SynchronizationContext
) ornull
SynchronizationContext, thePost
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 currentSynchronizationContext
beingnull
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 thePost
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 thePost
method to callSynchronizationContext.SetSynchronizationContext(this)
with the posted delegate (usingDelegate.Combine(...)
). Also, the internals treatSynchronizationContext
instances the same asnull
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 fromAsyncMethodBuilder
), but it would be called synchronously while maintaining itsSynchronizationContext
.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 callingExecutionContext
and henceSynchronizationContext
. Code for this can been seen here (thanks @VMAtm).