Default SynchronizationContext vs Default TaskScheduler

.net, async-await, c#, multithreading, task-parallel-library

Solution

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 by `await tcs.Task`.

- At the end of the `StartNew` task, it synchronously executes the continuation set by `await 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 has `TaskScheduler.Current` set to a `SynchronizationContextTaskScheduler` whose `m_synchronizationContext` is the instance created by `new SynchronizationContext()`; however, that thread's `SynchronizationContext.Current` is `null`.

- `SetResult` attempts to execute the `await tcs.Task` continuation synchronously on the current task scheduler; however, it cannot because `SynchronizationContextTaskScheduler` sees that thread 10 has a `SynchronizationContext.Current` of `null` while it is requiring a `new 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 the `await 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.

Problem

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 via `await` or explicitly via `TaskScheduler.FromCurrentSynchronizationContext()`). They both schedule tasks to be executed on a random `ThreadPool` thread. In fact, `SynchronizationContext.Post` merely calls `ThreadPool.QueueUserWorkItem`. However, there is a subtle but important difference in how `TaskCompletionSource.SetResult` works, when used from a task queued on the default `SynchronizationContext`. 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`). There `tcs.SetResult` synchronously invokes the first continuation part (`await tcs.Task`), then the execution point returns to `tcs.SetResult` and continues synchronously ever after, including the second `await 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 does `await tcs.Task`, it should be captured and the continuation should occur asynchronously (i.e., on a different pool thread as queued by `SynchronizationContext.Post`). By analogy, if I ran the test #1 from within a WinForms app, it would have been continued asynchronously after `await tcs.Task`, on `WinFormsSynchronizationContext` 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)` to `ConfigureAwait(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, the `await tcs.Task` continuation triggered by `tcs.SetResult` does happen asynchronously, on another pool thread. I tried `ConfigureAwait(false)` too, that didn't change anything either. I also tried installing `SynchronizationContext` 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 of `ConfigureAwait(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 after `await`. That's why I expect the continuation after `tcs.SetResult` to take place on a different thread from the `ThreadPool` (queued there by `SynchronizationContext.Post`), while the main thread may still be blocked by `TcsTest(...).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 around `SynchronizationContext`. It's now installed instead of the `SynchronizationContext` 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 change `ConfigureAwait(true)` to `ConfigureAwait(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 the `await tcs.Task` ignore the synchronization context (the `TestSyncContext.Post` call is gone), so now it continues synchronously after `tcs.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 for `await` continuations) checks the runtime type information of the thread's synchronization context, and give some special treatment to `SynchronizationContext`? Now, I still can't explain the behavior of test #2 for when `ConfigureAwait(false)`. It's one less `TestSyncContext.Post` call, that's understood. However, `await tcs.Task` still gets continued on a different thread from `tcs.SetResult` (unlike in #1), that's not what I'd expect. I'm still seeking for a reason for this.

Original source