【问题标题】:Default SynchronizationContext vs Default TaskScheduler默认 SynchronizationContext 与默认 TaskScheduler
【发布时间】:2014-01-06 02:59:35
【问题描述】:

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

我在想默认任务调度程序(ThreadPoolTaskScheduler)的行为与默认的“ThreadPool”SynchronizationContext 的行为非常相似(后者可以通过await 隐式引用或通过@ 显式引用987654331@)。他们都安排任务在随机的ThreadPool 线程上执行。事实上,SynchronizationContext.Post 只是调用了ThreadPool.QueueUserWorkItem。

但是,当在默认SynchronizationContext 上排队的任务中使用时,TaskCompletionSource.SetResult 的工作方式存在细微但重要的区别。这是一个简单的控制台应用程序来说明它:

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

输出:

测试 #1,线程:9 在等待 tcs.Task 之前,线程:9 在 tcs.SetResult 之前,线程:10 在等待 tcs.Task 之后,线程:10 在 tcs.SetResult 之后,线程:10 在等待任务之后,线程:10 测试#2,线程:9 在等待 tcs.Task 之前,线程:9 在 tcs.SetResult 之前,线程:10 在 tcs.SetResult 之后,线程:10 在等待 tcs.Task 之后,线程:11 在等待任务之后,线程:11 按回车退出,线程: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 中所见行为的良好解释。 有人可以详细说明一下吗?

The Nature of TaskCompletionSource
Parallel Programming: Task Schedulers and Synchronization Context
Parallel Programming: TaskScheduler.FromCurrentSynchronizationContext
It's All About the SynchronizationContext


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

所以我继续实现了一个哑同步上下文类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();
        }
    }
}

神奇的是,事情发生了更好的变化!这是新的输出:

测试#1,线程:10 在等待 tcs.Task 之前,线程:10 在 tcs.SetResult 之前,线程:6 TestSyncContext.Post,线程:6 在 tcs.SetResult 之后,线程:6 在等待 tcs.Task 之后,线程:11 在等待任务之后,线程:6 测试#2,线程:10 TestSyncContext.Post,线程:10 在等待 tcs.Task 之前,线程:10 在 tcs.SetResult 之前,线程:11 TestSyncContext.Post,线程:11 在 tcs.SetResult 之后,线程:11 在等待 tcs.Task 之后,线程:12 在等待任务之后,线程:12 按回车退出,线程数:10

现在测试 #1 的行为符合预期(await tcs.Task 异步排队到池线程)。 #2似乎也可以。让我们把ConfigureAwait(true)改成ConfigureAwait(false):

测试 #1,线程:9 在等待 tcs.Task 之前,线程:9 在 tcs.SetResult 之前,线程:10 在等待 tcs.Task 之后,线程:10 在 tcs.SetResult 之后,线程:10 在等待任务之后,线程:10 测试#2,线程:9 TestSyncContext.Post,线程:9 在等待 tcs.Task 之前,线程:9 在 tcs.SetResult 之前,线程:11 在 tcs.SetResult 之后,线程:11 在等待 tcs.Task 之后,线程:10 在等待任务之后,线程:10 按回车退出,线程:9

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

为什么这与使用默认SynchronizationContext 时的情况不同?我仍然很想知道。或许,默认的任务调度器(负责await的延续)会检查线程同步上下文的运行时类型信息,并对SynchronizationContext做一些特殊处理?

现在,我仍然无法解释测试 #2 在 ConfigureAwait(false) 时的行为。这是少了一个TestSyncContext.Post 电话,这是可以理解的。然而,await tcs.Task 仍然在与tcs.SetResult 不同的线程上继续运行(与#1 不同),这不是我所期望的。我仍在寻找这样做的原因。

【问题讨论】:

    标签: c# .net multithreading task-parallel-library async-await


    【解决方案1】:

    当您开始深入研究实施细节时,区分记录/可靠行为和未记录行为非常重要。此外,将SynchronizationContext.Current 设置为new SynchronizationContext() 也不是很合适; .NET 中的某些类型将null 视为默认调度程序,而其他类型将null 视为默认调度程序或 new SynchronizationContext()。

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

    在捕获上下文(如果有)之后,await 会安排继续。如我的博客所述(此行为未记录),此延续计划为 using ExecuteSynchronously。但是,请注意ExecuteSynchronously does not always execute synchronously;特别是,如果一个 continuation 有一个任务调度器,它只会请求在当前线程上同步执行,而任务调度器可以选择拒绝同步执行它(也未记录)。 p>

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

    所以,在你原来的测试 #1 中:

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

    在您原来的测试 #2 中:

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

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

    【讨论】:

    • 默认的SynchronizationContext 是否曾经在框架中的任何地方直接使用(除了从它派生)?我想知道他们为什么没有成功abstract。
    • 我同意这不是最好的设计。有许多本地用途,例如localContext = SynchronizationContext.Current ?? new SynchronizationContext()。但我还没有完成整个框架。 :)
    【解决方案2】:

    SynchronizationContext 在帖子中总是简单地调用ThreadPool.QueueUserWorkItem——这就解释了为什么你总是在测试#2 中看到不同的线程。

    在测试#1 中,您使用的是更智能的TaskScheduler。 await 应该在同一个线程上继续(或 "stay on the current thread" )。在控制台应用程序中,无法像在基于消息队列的 UI 框架中那样“安排”返回到主线程。控制台应用程序中的await 必须阻塞主线程,直到工作完成(让主线程无事可做)才能在同一个线程上继续。如果调度程序知道这一点,那么它可能会在同一个线程上同步运行代码,因为它会得到相同的结果,而不必创建另一个线程并冒着上下文切换的风险。

    更多信息可以在这里找到:http://blogs.msdn.com/b/pfxteam/archive/2012/01/20/10259049.aspx

    更新: 就ConfigureAwait而言。控制台应用程序无法“编组”回主线程,因此,ConfigureAwait(false) 可能在控制台应用程序中没有任何意义。

    另见:http://msdn.microsoft.com/en-us/magazine/jj991977.aspx

    【讨论】:

    • 嗯,事实上我不希望 await tcs.Task 在测试#1 中继续在同一个线程上。 如果我没有安装同步,我会的。 但是,当我执行await 时,我希望它会被捕获(因为它是ConfigureAwait(true))并用于继续,因此它将被发布到另一个池线程。为什么不会发生这种情况?
    • 这就是await 所做的——作为一种在基于GUI 的应用程序中更容易编写异步代码的方法。这是因为 GUI 控件上的操作需要在唯一的 GUI 线程上进行——因此 after await 之后的所有操作都默认在同一个线程上继续。在控制台应用程序中,主线程不能是异步的(如果是,它可以在异步操作完成之前退出),并且await 无法在后续过程中将其封送回它。
    • 我使用TcsTest(...).Wait() 来避免我的控制台应用程序的主线程过早退出,所以这不是问题:主线程在等待时被阻塞。同时,我可以把await Task.Delay(1000)或者简单的await Task.Yield()放在TcsTest的开头,这样await之后每次都会在一个新线程上继续执行,而主线程还在等待,验证。
    • This experiment 表明ConfigureAwait 在控制台应用程序中工作,至少对于自定义同步上下文。也许,对于标准的SynchronizationContext,它不是?
    • 我确实比较过,实际上:对于ConfigureAwait(false),更新的#1 中没有捕获上下文,TestSyncContext.Post 不再被调用的事实表明,与ConfigureAwait(true) 的情况相反.
    猜你喜欢
    • 2011-10-11
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2013-01-17
    • 2018-10-14
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多