Зачем нужен уникальный контекст синхронизации для каждого обратного вызова Dispatcher.BeginInvoke?

Я только что заметил, что в .NET 4.5 каждый обратный вызов Dispatcher.BeginInvoke/InvokeAsync выполняется в своем собственном уникальном контексте синхронизации (экземпляр DispatcherSynchronizationContext). В чем причина этого изменения?

Это иллюстрирует следующее тривиальное приложение WPF:

using System;
using System.Diagnostics;
using System.Threading;
using System.Windows;
using System.Windows.Threading;

namespace WpfApplication
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            Action test = null;
            var i = 0;

            test = () =>
            {
                var sc = SynchronizationContext.Current;

                Dispatcher.CurrentDispatcher.InvokeAsync(() => 
                {
                    Debug.Print("same context #" + i + ": " +
                        (sc == SynchronizationContext.Current));
                    if ( i < 10 ) 
                    {
                        i++;
                        test();
                    }
                });
            };

            this.Loaded += (s, e) => test();
        }
    }
}

Вывод:

same context #0: False
same context #1: False
same context #2: False
...

Установка BaseCompatibilityPreferences.ReuseDispatcherSynchronizationContextInstance на true восстанавливает поведение .NET 4.0:

public partial class App : Application
{
    static App()
    {
        BaseCompatibilityPreferences.ReuseDispatcherSynchronizationContextInstance = true;
    }
}
same context #0: True
same context #1: True
same context #2: True
...

Изучение исходников .NET для DispatcherOperation показывает следующее:

[SecurityCritical]
private void InvokeImpl() 
{
    SynchronizationContext oldSynchronizationContext = SynchronizationContext.Current;

    try 
    {
        // We are executing under the "foreign" execution context, but the 
        // SynchronizationContext must be for the correct dispatcher. 
        SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(_dispatcher));

        // Invoke the delegate that does the work for this operation.
        _result = _dispatcher.WrappedInvoke(_method, _args, _isSingleParameter);
    }
    finally 
    {
        SynchronizationContext.SetSynchronizationContext(oldSynchronizationContext); 
    } 
}

Я не понимаю, зачем это может понадобиться, обратные вызовы, поставленные в очередь с Dispatcher.BeginInvoke/InvokeAsync, в любом случае выполняются в правильном потоке, на котором уже установлен экземпляр DispatcherSynchronizationContext.

Одним интересным побочным эффектом этого изменения является то, что продолжение await TaskCompletionSource.Task (запускаемое TaskCompletionSource.SetResult) почти всегда асинхронно в .NET 4.5 WPF, в отличие от WinForms или v4.0 WPF (подробнее).


person noseratio    schedule 06.03.2014    source источник


Ответы (2)


Это объясняется очень длинным комментарием в исходном коде. Цитата из справочного источника 4.5.1 в wpf\src\Base\System\Windows\BaseCompatibilityPreferences.cs:

    ///     WPF 4.0 had a performance optimization where it would
    ///     frequently reuse the same instance of the
    ///     DispatcherSynchronizationContext when preparing the
    ///     ExecutionContext for invoking a DispatcherOperation.  This
    ///     had observable impacts on behavior.
    ///
    ///     1) Some task-parallel implementations check the reference
    ///         equality of the SynchronizationContext to determine if the
    ///         completion can be inlined - a significant performance win.
    ///
    ///     2) But, the ExecutionContext would flow the
    ///         SynchronizationContext which could result in the same
    ///         instance of the DispatcherSynchronizationContext being the
    ///         current SynchronizationContext on two different threads.
    ///         The continuations would then be inlined, resulting in code
    ///         running on the wrong thread.
    ///
    ///     In 4.5 we changed this behavior to use a new instance of the
    ///     DispatcherSynchronizationContext for every operation, and
    ///     whenever SynchronizationContext.CreateCopy is called - such
    ///     as when the ExecutionContext is being flowed to another thread.
    ///     This has its own observable impacts:
    ///
    ///     1) Some task-parallel implementations check the reference
    ///         equality of the SynchronizationContext to determine if the
    ///         completion can be inlined - since the instances are
    ///         different, this causes them to resort to the slower
    ///         path for potentially cross-thread completions.
    ///
    ///     2) Some task-parallel implementations implement potentially
    ///         cross-thread completions by callling
    ///         SynchronizationContext.Post and Wait() and an event to be
    ///         signaled.  If this was not a true cross-thread completion,
    ///         but rather just two seperate instances of
    ///         DispatcherSynchronizationContext for the same thread, this
    ///         would result in a deadlock.

Или, другими словами, они исправили ошибку в вашем коде :)

person Hans Passant    schedule 06.03.2014

Я считаю, что основная причина в том, что DispatcherSynchronizationContext 4.5 также захватывает DispatcherPriority операции, поэтому его нельзя использовать повторно (это поведение также настраивается с помощью BaseCompatibilityPreferences.FlowDispatcherSynchronizationContextPriority).

Что касается await - в SynchronizationContextAwaitTaskContinuation есть ссылочное равенство контекста синхронизации, захваченного асинхронным методом, с текущим (возвращаемым SynchronizationContext.CurrentNoFlow), что, конечно, не выполняется, если контекст не не используется повторно. Таким образом, операция ставится в очередь в диспетчере, а не выполняется встроенно.

Это также влияет на SynchronizationContextTaskScheduler, который также выполняет ссылочную проверку на равенство.

Возможно, это было недосмотром, поскольку WPF и TPL разрабатываются разными командами. Похоже, это было сделано намеренно. Тем не менее, это немного озадачивает, что в некоторых случаях они активно решили сделать асинхронные продолжения медленнее. Не могли бы они изменить поведение, чтобы разрешить сравнение контекста синхронизации на равенство (например, переопределив Equals и проверив, что он принадлежит одному и тому же Dispatcher)? Может быть, стоит открыть тему Коннекта.

person Eli Arbel    schedule 06.03.2014
comment
Очень интересное наблюдение, спасибо. Я хотел бы отметить оба ответа. - person noseratio; 07.03.2014
comment
Фактически, этот пункт из их комментария кажется неверным: ... Но ExecutionContext будет передавать SynchronizationContext, что может привести к тому, что один и тот же экземпляр DispatcherSynchronizationContext будет текущим SynchronizationContext в двух разных потоках. Это происходит только в том случае, если ExecutionContext явно перетекает с ExecutionContext.Run. В противном случае контекст синхронизации не передается при переключении потока (в отличие, скажем, от CallContext). Подробнее здесь. - person noseratio; 07.03.2014
comment
А даже если бы и было, то сравнивают с CurrentNoFlow... Странно. - person Eli Arbel; 07.03.2014
comment
Это действительно раздражающий выбор дизайна! У меня есть диспетчер вызова задач, который может выбирать разные контексты приоритета Dispather, но у него нет возможности проверить, можно ли использовать текущий активный DispatcherSynchrnonisationContext, потому что DispatcherPriority является частным полем. Гррррр!! Использование отражения для проверки этого приведет к потере всех преимуществ производительности, связанных с отсутствием переключения контекста задачи при каждом асинхронном вызове. Очень загадочно. - person Tom Deloford; 21.10.2016