默认SynchronizationContext与默认TaskScheduler [英] Default SynchronizationContext vs Default TaskScheduler

查看:107
本文介绍了默认SynchronizationContext与默认TaskScheduler的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

这会有点长,所以请多多包涵.

我在想默认任务计划程序(ThreadPoolTaskScheduler)的行为与默认任务"ThreadPool"的行为非常相似.

输出:

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

这是一个控制台应用程序,其Main线程默认情况下没有任何同步上下文,因此在运行测试之前,我先明确地安装了默认上下文:SynchronizationContext.SetSynchronizationContext(new SynchronizationContext()).

最初,我以为我完全理解了测试#1(任务是用TaskScheduler.Default安排的)期间的执行工作流程.那里tcs.SetResult同步调用第一个连续部分(await tcs.Task),然后执行点返回到tcs.SetResult并从此以后同步继续,包括第二个await task.这对我来说确实很有意义,直到我意识到以下内容.由于我们现在已经在执行await tcs.Task的线程上安装了默认的同步上下文,因此应该捕获它,并且延续应该异步发生(即,在由SynchronizationContext.Post排队的另一个池线程上) .以此类推,如果我从WinForms应用程序中运行测试#1,则在以后的消息循环中,在await tcs.Task之后,在WinFormsSynchronizationContext上,它将异步继续进行.

但这不是测试#1内部发生的事情.出于好奇,我将ConfigureAwait(true) 更改为ConfigureAwait(false) ,但这对输出没有任何影响.我正在寻找对此的解释.

现在,在#2测试中(任务由TaskScheduler.FromCurrentSynchronizationContext()安排),与#1相比,确实有一个更多的线程切换.从输出中可以看出,由tcs.SetResult触发的await tcs.Task延续确实在另一个池线程上异步发生.我也尝试过ConfigureAwait(false),也没有任何改变.我还尝试在开始测试#2之前而不是在开始时立即安装SynchronizationContext.要么得到完全相同的输出.

我实际上更喜欢测试#2的行为,因为它留出了更少的副作用(可能还有死锁)的间隙,这些副作用可能是由tcs.SetResult触发的同步延续引起的,即使它出现在额外的线程开关的价格.但是,我不完全理解为什么为什么会与ConfigureAwait(false)无关地进行这种线程切换.

我熟悉以下有关该主题的出色资源,但我仍在寻找对测试1和测试2中所见行为的良好解释. 有人可以详细说明吗?

TaskCompletionSource的性质
并行编程:任务计划程序和同步上下文
并行编程:TaskScheduler.FromCurrentSynchronizationContext
这全都与SynchronizationContext有关


[UPDATE] 我的意思是,默认同步上下文对象已在主线程中击中测试#1中的第一个await tcs.Task之前显式安装在主线程上. IMO,它不是GUI同步上下文,这并不意味着它不应在await之后继续捕获.这就是为什么我希望tcs.SetResult之后的延续发生在与ThreadPool不同的线程上(由SynchronizationContext.Post排队),而主线程可能仍被TcsTest(...).Wait()阻塞的原因.这与此处所述的情况非常相似. >

所以我继续实现了一个哑同步上下文类 TestSyncContext,它只是SynchronizationContext的包装.现在已安装它,而不是SynchronizationContext本身:

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();
        }
    }
}

神奇的是,情况有了更好的改变!这是新的输出:

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

现在,测试1的行为符合预期(await tcs.Task异步排队到池线程中). #2看起来也不错.让我们将ConfigureAwait(true)更改为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

测试#1仍能按预期正确运行:ConfigureAwait(false)使await tcs.Task忽略同步上下文(TestSyncContext.Post调用消失了),因此现在它在tcs.SetResult之后继续同步.

为什么与使用默认SynchronizationContext的情况有什么不同?我还是很想知道.也许,默认的任务计划程序(负责await的延续)会检查线程的同步上下文的运行时类型信息,并对SynchronizationContext进行一些特殊处理?

现在,对于ConfigureAwait(false),我仍然无法解释测试#2的行为.可以理解,这是少了一个TestSyncContext.Post调用.但是,await tcs.Task仍然在与tcs.SetResult不同的线程上继续执行(与#1不同),这不是我期望的.我仍在为此寻找原因.

解决方案

当您开始深入研究实现细节时,区分已记录的/可靠的行为和未记录的行为很重要.另外,将SynchronizationContext.Current设置为new SynchronizationContext()并不是真正合适的方法. .NET中的某些类型将null作为默认调度程序,而其他类型将null new SynchronizationContext()视为默认调度程序.

当您await不完整的Task时,默认情况下TaskAwaiter会捕获当前的SynchronizationContext-除非它是null(或其GetType返回typeof(SynchronizationContext)),在这种情况下TaskAwaiter捕获当前的TaskScheduler.大多数记录此行为(GetType子句不是AFAIK).但是,请注意,这里描述的是TaskAwaiter的行为,而不是TaskScheduler.DefaultTaskFactory.StartNew的行为.

捕获上下文(如果有)后,然后await安排继续.如我的博客所述,使用ExecuteSynchronously (此行为是未记录的).但是,请注意, ExecuteSynchronously并不总是同步执行;尤其是,如果延续具有任务计划程序,则它将仅请求在当前线程上同步执行,并且任务计划程序可以选择拒绝同步执行(也未记录).

最后,请注意,可以要求TaskScheduler同步执行任务,但是SynchronizationContext不能.因此,如果await捕获了自定义SynchronizationContext,则它必须始终异步执行延续.

因此,在您最初的测试#1中:

  • StartNew使用默认任务计划程序(在线程10上)启动新任务.
  • SetResult同步执行await tcs.Task设置的延续.
  • StartNew任务结束时,它同步执行await task设置的延续.

在原始测试2中:

  • StartNew使用用于默认构造的同步上下文的任务调度程序包装器启动新任务(在线程10上).请注意,线程10上的任务已将TaskScheduler.Current设置为SynchronizationContextTaskScheduler,其m_synchronizationContextnew SynchronizationContext()创建的实例.但是,该线程的SynchronizationContext.Currentnull.
  • SetResult尝试在当前任务计划程序上同步执行await tcs.Task延续;但是,它不能,因为SynchronizationContextTaskScheduler看到线程10在需要new SynchronizationContext()时具有SynchronizationContext.Currentnull的情况.因此,它以异步方式(在线程11上)调度继续.
  • StartNew任务结束时发生类似情况;在这种情况下,我相信await task在同一线程上继续运行是偶然的.

最后,我必须强调,依靠未记录的实现细节是不明智的.如果要让async方法在线程池线程上继续,则将其包装在Task.Run中.这将使您的代码意图更加清晰,并使您的代码对将来的框架更新更具弹性.另外,也不要将SynchronizationContext.Current设置为new SynchronizationContext(),因为这种情况的处理方式不一致.

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.

解决方案

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.

这篇关于默认SynchronizationContext与默认TaskScheduler的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆