Default SynchronizationContext vs Default TaskScheduler
Solution 1
When you start diving this deep into the implementation details, it's important to differentiate between documented/reliable behavior and undocumented behavior. Also, it's not really considered proper to have SynchronizationContext.Current
set to new SynchronizationContext()
; some types in .NET treat null
as the default scheduler, and other types treat null
or new SynchronizationContext()
as the default scheduler.
When you await
an incomplete Task
, the TaskAwaiter
by default captures the current SynchronizationContext
- unless it is null
(or its GetType
returns typeof(SynchronizationContext)
), in which case the TaskAwaiter
captures the current TaskScheduler
. This behavior is mostly documented (the GetType
clause is not AFAIK). However, please note that this describes the behavior of TaskAwaiter
, not TaskScheduler.Default
or TaskFactory.StartNew
.
After the context (if any) is captured, then await
schedules a continuation. This continuation is scheduled using ExecuteSynchronously
, as described on my blog (this behavior is undocumented). However, do note that ExecuteSynchronously
does not always execute synchronously; in particular, if a continuation has a task scheduler, it will only request to execute synchronously on the current thread, and the task scheduler has the option to refuse to execute it synchronously (also undocumented).
Finally, note that a TaskScheduler
can be requested to execute a task synchronously, but a SynchronizationContext
cannot. So, if the await
captures a custom SynchronizationContext
, then it must always execute the continuation asynchronously.
So, in your original Test #1:
-
StartNew
starts a new task with the default task scheduler (on thread 10). -
SetResult
synchronously executes the continuation set byawait tcs.Task
. - At the end of the
StartNew
task, it synchronously executes the continuation set byawait task
.
In your original Test #2:
-
StartNew
starts a new task with a task scheduler wrapper for a default-constructed synchronization context (on thread 10). Note that the task on thread 10 hasTaskScheduler.Current
set to aSynchronizationContextTaskScheduler
whosem_synchronizationContext
is the instance created bynew SynchronizationContext()
; however, that thread'sSynchronizationContext.Current
isnull
. -
SetResult
attempts to execute theawait tcs.Task
continuation synchronously on the current task scheduler; however, it cannot becauseSynchronizationContextTaskScheduler
sees that thread 10 has aSynchronizationContext.Current
ofnull
while it is requiring anew SynchronizationContext()
. Thus, it schedules the continuation asynchronously (on thread 11). - A similar situation happens at the end of the
StartNew
task; in this case, I believe it's coincidental that theawait task
continues on the same thread.
In conclusion, I must emphasize that depending on undocumented implementation details is not wise. If you want to have your async
method continue on a thread pool thread, then wrap it in a Task.Run
. That will make the intent of your code much clearer, and also make your code more resilient to future framework updates. Also, don't set SynchronizationContext.Current
to new SynchronizationContext()
, since the handling of that scenario is inconsistent.
Solution 2
SynchronizationContext
always simply calls ThreadPool.QueueUserWorkItem
on the post--which explains why you always see a different thread in test #2.
In test #1 you're using a smarter TaskScheduler
. await
is supposed to continue on the same thread (or "stay on the current thread" ). In a Console app there's no way to "schedule" return to the main thread like there is in message-queue-based UI frameworks. An await
in a console application would have to block the main thread until the work is done (leaving the main thread with nothing to do) in order to continue on that same thread. If the scheduler knows this, then it might as well run the code synchronously on the same thread as it would have the same result without having to create another thread and risk a context switch.
More information can be found here: http://blogs.msdn.com/b/pfxteam/archive/2012/01/20/10259049.aspx
Update:
In terms of the ConfigureAwait
. Console applications have no way to "marshal" back to the main thread so, presumably, ConfigureAwait(false)
means nothing in a console app.
See also: http://msdn.microsoft.com/en-us/magazine/jj991977.aspx
Related videos on Youtube
noseratio
Dad, self-employed, problem solver at heart. Formerly a principal software engineer at Nuance Communications. Async all the way down with .NET, Node.js, Electron.js, WebView2, WebRTC, PDFium, Google Speech API and more. Nozillium.com, Twitter, LinkedIn, GitHub, Dev.to Video: My .NET Conf 2020 talk on Asynchronous coroutines with C# Tool: #DevComrade, for pasting unformatted text in Windows by default, systemwide Blog: A few handy JavaScript tricks Tool: wsudo, a unix-like sudo CLI utility for Windows, Powershell-based Blog: Why I no longer use ConfigureAwait(false) Blog: C# events as asynchronous streams with ReactiveX or Channels Howto: OpenSSH with MFA on OpenWrt 19.07.x using Google Authenticator Why doesn't await on Task.WhenAll throw an AggregateException? Async/await, custom awaiter and garbage collector StaTaskScheduler and STA thread message pumping How to Unit test ViewModel with async initialization in WPF Keep UI thread responsive when running long task in windows forms Converting between 2 different libraries using the same COM interface Asynchronous WebBrowser-based console web scrapper Thread affinity for async/await in ASP.NET Throttling asynchronous tasks Task sequencing and re-entracy A reusable pattern to convert event into task Task.Yield - real usages? Call async method on UI thread How to make make a .NET COM object apartment-threaded? ... and more!
Updated on June 06, 2022Comments
-
noseratio almost 2 years
This is going to be a bit long, so please bear with me.
I was thinking that the behavior of the default task scheduler (
ThreadPoolTaskScheduler
) is very similar to that of the default "ThreadPool
"SynchronizationContext
(the latter can be referenced implicitly viaawait
or explicitly viaTaskScheduler.FromCurrentSynchronizationContext()
). They both schedule tasks to be executed on a randomThreadPool
thread. In fact,SynchronizationContext.Post
merely callsThreadPool.QueueUserWorkItem
.However, there is a subtle but important difference in how
TaskCompletionSource.SetResult
works, when used from a task queued on the defaultSynchronizationContext
. Here's a simple console app illustrating it:using System; using System.Threading; using System.Threading.Tasks; namespace ConsoleTcs { class Program { static async Task TcsTest(TaskScheduler taskScheduler) { var tcs = new TaskCompletionSource<bool>(); var task = Task.Factory.StartNew(() => { Thread.Sleep(1000); Console.WriteLine("before tcs.SetResult, thread: " + Thread.CurrentThread.ManagedThreadId); tcs.SetResult(true); Console.WriteLine("after tcs.SetResult, thread: " + Thread.CurrentThread.ManagedThreadId); Thread.Sleep(2000); }, CancellationToken.None, TaskCreationOptions.None, taskScheduler); Console.WriteLine("before await tcs.Task, thread: " + Thread.CurrentThread.ManagedThreadId); await tcs.Task.ConfigureAwait(true); Console.WriteLine("after await tcs.Task, thread: " + Thread.CurrentThread.ManagedThreadId); await task.ConfigureAwait(true); Console.WriteLine("after await task, thread: " + Thread.CurrentThread.ManagedThreadId); } // Main static void Main(string[] args) { // SynchronizationContext.Current is null // install default SynchronizationContext on the thread SynchronizationContext.SetSynchronizationContext(new SynchronizationContext()); // use TaskScheduler.Default for Task.Factory.StartNew Console.WriteLine("Test #1, thread: " + Thread.CurrentThread.ManagedThreadId); TcsTest(TaskScheduler.Default).Wait(); // use TaskScheduler.FromCurrentSynchronizationContext() for Task.Factory.StartNew Console.WriteLine("\nTest #2, thread: " + Thread.CurrentThread.ManagedThreadId); TcsTest(TaskScheduler.FromCurrentSynchronizationContext()).Wait(); Console.WriteLine("\nPress enter to exit, thread: " + Thread.CurrentThread.ManagedThreadId); Console.ReadLine(); } } }
The output:
Test #1, thread: 9 before await tcs.Task, thread: 9 before tcs.SetResult, thread: 10 after await tcs.Task, thread: 10 after tcs.SetResult, thread: 10 after await task, thread: 10 Test #2, thread: 9 before await tcs.Task, thread: 9 before tcs.SetResult, thread: 10 after tcs.SetResult, thread: 10 after await tcs.Task, thread: 11 after await task, thread: 11 Press enter to exit, thread: 9
This is a console app, its
Main
thread doesn't have any synchronization context by default, so I explicitly install the default one at the beginning, before running tests:SynchronizationContext.SetSynchronizationContext(new SynchronizationContext())
.Initially, I thought I fully comprehended the execution workflow during the test #1 (where the task is scheduled with
TaskScheduler.Default
). Theretcs.SetResult
synchronously invokes the first continuation part (await tcs.Task
), then the execution point returns totcs.SetResult
and continues synchronously ever after, including the secondawait task
. That did make sense to me, until I realized the following. As we now have the default synchronization context installed on the thread that doesawait tcs.Task
, it should be captured and the continuation should occur asynchronously (i.e., on a different pool thread as queued bySynchronizationContext.Post
). By analogy, if I ran the test #1 from within a WinForms app, it would have been continued asynchronously afterawait tcs.Task
, onWinFormsSynchronizationContext
upon a future iteration of the message loop.But that's not what happens inside the test #1. Out of curiosity, I changed
ConfigureAwait(true)
toConfigureAwait(false)
and that did not have any effect on the output. I'm looking for an explanation of this.Now, during the test #2 (the task is scheduled with
TaskScheduler.FromCurrentSynchronizationContext()
) there's indeed one more thread switch, as compared to #1. As can been seen from the output, theawait tcs.Task
continuation triggered bytcs.SetResult
does happen asynchronously, on another pool thread. I triedConfigureAwait(false)
too, that didn't change anything either. I also tried installingSynchronizationContext
immediately before starting the test #2, rather than at the beginning. That resulted in exactly the same output, either.I actually like the behavior of the test #2 more, because it leaves less gap for side effects (and, potentially, deadlocks) which may be caused by the synchronous continuation triggered by
tcs.SetResult
, even though it comes at a price of an extra thread switch. However, I don't fully understand why such thread switch takes place regardless ofConfigureAwait(false)
.I'm familiar with the following excellent resources on the subject, but I'm still looking for a good explanation of the behaviors seen in test #1 and #2. Can someone please elaborate on this?
The Nature of TaskCompletionSource
Parallel Programming: Task Schedulers and Synchronization Context
Parallel Programming: TaskScheduler.FromCurrentSynchronizationContext
It's All About the SynchronizationContext
[UPDATE] My point is, the default synchronization context object has been explicitly installed on the main thread, before the thread hits the first
await tcs.Task
in test #1. IMO, the fact that it is not a GUI synchronization context doesn't mean it should not be captured for continuation afterawait
. That's why I expect the continuation aftertcs.SetResult
to take place on a different thread from theThreadPool
(queued there bySynchronizationContext.Post
), while the main thread may still be blocked byTcsTest(...).Wait()
. This is a very similar scenario to the one described here.So I went ahead and implemented a dumb synchronization context class
TestSyncContext
, which is just a wrapper aroundSynchronizationContext
. It's now installed instead of theSynchronizationContext
itself:using System; using System.Threading; using System.Threading.Tasks; namespace ConsoleTcs { public class TestSyncContext : SynchronizationContext { public override void Post(SendOrPostCallback d, object state) { Console.WriteLine("TestSyncContext.Post, thread: " + Thread.CurrentThread.ManagedThreadId); base.Post(d, state); } public override void Send(SendOrPostCallback d, object state) { Console.WriteLine("TestSyncContext.Send, thread: " + Thread.CurrentThread.ManagedThreadId); base.Send(d, state); } }; class Program { static async Task TcsTest(TaskScheduler taskScheduler) { var tcs = new TaskCompletionSource<bool>(); var task = Task.Factory.StartNew(() => { Thread.Sleep(1000); Console.WriteLine("before tcs.SetResult, thread: " + Thread.CurrentThread.ManagedThreadId); tcs.SetResult(true); Console.WriteLine("after tcs.SetResult, thread: " + Thread.CurrentThread.ManagedThreadId); Thread.Sleep(2000); }, CancellationToken.None, TaskCreationOptions.None, taskScheduler); Console.WriteLine("before await tcs.Task, thread: " + Thread.CurrentThread.ManagedThreadId); await tcs.Task.ConfigureAwait(true); Console.WriteLine("after await tcs.Task, thread: " + Thread.CurrentThread.ManagedThreadId); await task.ConfigureAwait(true); Console.WriteLine("after await task, thread: " + Thread.CurrentThread.ManagedThreadId); } // Main static void Main(string[] args) { // SynchronizationContext.Current is null // install default SynchronizationContext on the thread SynchronizationContext.SetSynchronizationContext(new TestSyncContext()); // use TaskScheduler.Default for Task.Factory.StartNew Console.WriteLine("Test #1, thread: " + Thread.CurrentThread.ManagedThreadId); TcsTest(TaskScheduler.Default).Wait(); // use TaskScheduler.FromCurrentSynchronizationContext() for Task.Factory.StartNew Console.WriteLine("\nTest #2, thread: " + Thread.CurrentThread.ManagedThreadId); TcsTest(TaskScheduler.FromCurrentSynchronizationContext()).Wait(); Console.WriteLine("\nPress enter to exit, thread: " + Thread.CurrentThread.ManagedThreadId); Console.ReadLine(); } } }
Magically, things have changed in a better way! Here's the new output:
Test #1, thread: 10 before await tcs.Task, thread: 10 before tcs.SetResult, thread: 6 TestSyncContext.Post, thread: 6 after tcs.SetResult, thread: 6 after await tcs.Task, thread: 11 after await task, thread: 6 Test #2, thread: 10 TestSyncContext.Post, thread: 10 before await tcs.Task, thread: 10 before tcs.SetResult, thread: 11 TestSyncContext.Post, thread: 11 after tcs.SetResult, thread: 11 after await tcs.Task, thread: 12 after await task, thread: 12 Press enter to exit, thread: 10
Now test #1 now behaves as expected (
await tcs.Task
is asynchronously queued to a pool thread). #2 appears to be OK, too. Let's changeConfigureAwait(true)
toConfigureAwait(false)
:Test #1, thread: 9 before await tcs.Task, thread: 9 before tcs.SetResult, thread: 10 after await tcs.Task, thread: 10 after tcs.SetResult, thread: 10 after await task, thread: 10 Test #2, thread: 9 TestSyncContext.Post, thread: 9 before await tcs.Task, thread: 9 before tcs.SetResult, thread: 11 after tcs.SetResult, thread: 11 after await tcs.Task, thread: 10 after await task, thread: 10 Press enter to exit, thread: 9
Test #1 still behaves correctly as expected:
ConfigureAwait(false)
makes theawait tcs.Task
ignore the synchronization context (theTestSyncContext.Post
call is gone), so now it continues synchronously aftertcs.SetResult
.Why is this different from the case when the default
SynchronizationContext
is used? I'm still curious to know. Perhaps, the default task scheduler (which is responsible forawait
continuations) checks the runtime type information of the thread's synchronization context, and give some special treatment toSynchronizationContext
?Now, I still can't explain the behavior of test #2 for when
ConfigureAwait(false)
. It's one lessTestSyncContext.Post
call, that's understood. However,await tcs.Task
still gets continued on a different thread fromtcs.SetResult
(unlike in #1), that's not what I'd expect. I'm still seeking for a reason for this. -
noseratio over 10 yearsHmm, in fact I'm not expecting the
await tcs.Task
to continue on the same thread in test #1. I would, if I did not install the sync. context. But once it's there when I do theawait
, I expect it to be captured (as it'sConfigureAwait(true)
) and used for continuation, so it would be posted to another pool thread. Why doesn't this happen? -
Peter Ritchie over 10 yearsThat's what
await
does--as a means of more easily writing asynchronous code in a GUI-based applications. This is because operations on GUI controls need to happen on the one and only GUI thread--thus everything afterawait
defaults to continuing on the same thread. In a console app, the main thread cannot be asynchronous (if it were, it could exit before async operations complete) and has no way forawait
to marshal back to it in the continuation. -
noseratio over 10 yearsI use
TcsTest(...).Wait()
to avoid premature exit in the main thread of my console app, so this is not an issue: the main thread is blocked while waiting. At the same time, I can putawait Task.Delay(1000)
or simplyawait Task.Yield()
at the beginning ofTcsTest
, and execution will continue every time on a new thread after suchawait
, while the main thread is still waiting, verified. -
noseratio over 10 yearsThis experiment shows that
ConfigureAwait
works in a console app, at least for a custom synchronization context. Perhaps, it is not for the standardSynchronizationContext
? -
Peter Ritchie over 10 yearsHow does that experiment show that ConfigureAwait "works"? You don't compare it to using the
false
parameter. -
noseratio over 10 yearsI do compare, actually: for
ConfigureAwait(false)
the context is not captured in the updated #1, the fact thatTestSyncContext.Post
is no longer called shows that, as opposed to theConfigureAwait(true)
case. -
noseratio over 10 yearsDoes the default
SynchronizationContext
ever get used directly anywhere in the Framework (besides for deriving from it)? I wonder why they did not make itabstract
. -
Stephen Cleary over 10 yearsI agree it's not the best design. There are a number of local uses like
localContext = SynchronizationContext.Current ?? new SynchronizationContext()
. But I haven't gone through the whole framework. :)