Подтвердить что ты не робот

Ожидание и синхронизацияContext в управляемом компоненте, размещенном неуправляемым приложением

[EDITED] Это, кажется, ошибка в реализации Framework Application.DoEvents, о котором я сообщил здесь. Восстановление неправильного контекста синхронизации в потоке пользовательского интерфейса может серьезно повлиять на разработчиков компонентов, таких как я. Целью награды является привлечение большего внимания к этой проблеме и вознаграждение @MattSmith, ответ которой помог отслеживать ее.

Я отвечаю за компонент .NET WinForms UserControl, открытый как ActiveX для устаревшего неуправляемого приложения, через COM-взаимодействие. Требование времени выполнения -.NET 4.0 + Microsoft.Bcl.Async.

Компонент получает экземпляр и используется в главном потоке пользовательского интерфейса STA. В его реализации используется async/await, поэтому он ожидает, что экземпляр сериализующего контекста синхронизации был установлен в текущем потоке (то есть, WindowsFormsSynchronizationContext).

Обычно WindowsFormsSynchronizationContext настраивается на Application.Run, где выполняется цикл сообщений управляемого приложения. Естественно, это не относится к неуправляемому хост-приложению, и я не контролирую это. Конечно, хост-приложение по-прежнему имеет свой собственный классический цикл сообщений Windows, поэтому не следует сериализовать обратные вызовы await продолжения.

Однако ни одно из решений, которые я придумал, не совершенен или даже работает правильно. Здесь искусственный пример, где метод Test вызывается приложением-хостом:

Task testTask;

public void Test()
{
    this.testTask = TestAsync();
}

async Task TestAsync()
{
    Debug.Print("thread before await: {0}", Thread.CurrentThread.ManagedThreadId);

    var ctx1 = SynchronizationContext.Current;
    Debug.Print("ctx1: {0}", ctx1 != null? ctx1.GetType().Name: null);

    if (!(ctx1 is WindowsFormsSynchronizationContext))
        SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());

    var ctx2 = SynchronizationContext.Current;
    Debug.Print("ctx2: {0}", ctx2.GetType().Name);

    await TaskEx.Delay(1000);

    Debug.WriteLine("thread after await: {0}", Thread.CurrentThread.ManagedThreadId);

    var ctx3 = SynchronizationContext.Current;
    Debug.Print("ctx3: {0}", ctx3 != null? ctx3.GetType().Name: null);

    Debug.Print("ctx3 == ctx1: {0}, ctx3 == ctx2: {1}", ctx3 == ctx1, ctx3 == ctx2);
}

Отладочный вывод:

thread before await: 1
ctx1: SynchronizationContext
ctx2: WindowsFormsSynchronizationContext
thread after await: 1
ctx3: SynchronizationContext
ctx3 == ctx1: True, ctx3 == ctx2: False

Хотя он продолжается в том же потоке, WindowsFormsSynchronizationContext контекст, который я устанавливаю в текущем потоке до await , получает reset по умолчанию SynchronizationContext после него, для некоторых причина.

Почему он получает reset? Я проверил, что мой компонент является единственным компонентом .NET, который используется этим приложением. Само приложение действительно правильно вызывает CoInitialize/OleInitialize.

Я также пробовал настройку WindowsFormsSynchronizationContext в конструкторе статического одноэлементного объекта, поэтому он устанавливается в потоке, когда моя управляемая сборка загружается. Это не помогло: когда Test позже вызывается в том же потоке, контекст уже был reset по умолчанию.

Я рассматриваю возможность использования пользовательского awaiter для планирования await обратных вызовов через control.BeginInvoke моего элемента управления, поэтому приведенное выше будет выглядеть как await TaskEx.Delay().WithContext(control). Это должно работать для моего собственного awaits, если хост-приложение продолжает накачивать сообщения, но не для awaits внутри любой из сторонних сборок, на которые может ссылаться моя сборка.

Я все еще разбираюсь в этом. Любые идеи о том, как сохранить правильное сходство потоков для await в этом сценарии, будут оценены.

4b9b3361

Ответ 1

Это будет немного длиннее. Прежде всего, спасибо Мэтту Смиту и Hans Passant за ваши идеи, они были очень полезны.

Проблема была вызвана добрым старым другом Application.DoEvents, хотя и в новинке. Ганс отличный пост о том, почему DoEvents - зло. К сожалению, я не могу избежать использования DoEvents в этом элементе управления из-за синхронных ограничений API, создаваемых устаревшим неуправляемым хост-приложением (подробнее об этом в конце). Я хорошо знаю о существующих последствиях DoEvents, но здесь я считаю, что у нас есть новый:

В потоке без явного конвейера WinForms (т.е. любой поток, который не ввел Application.Run или Form.ShowDialog), вызов Application.DoEvents заменит текущий контекст синхронизации на значение по умолчанию SynchronizationContext, если WindowsFormsSynchronizationContext.AutoInstall is true (по умолчанию это так).

Если это не ошибка, то это очень неприятное недокументированное поведение, которое может серьезно повлиять на разработчиков некоторых компонентов.

Вот простое консольное STA-приложение, воспроизводящее проблему. Обратите внимание, что WindowsFormsSynchronizationContext получает (неправильно) заменяется на SynchronizationContext в первом проходе Test и не переходит во второй проход.

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace ConsoleApplication
{
    class Program
    {
        [STAThreadAttribute]
        static void Main(string[] args)
        {
            Debug.Print("ApartmentState: {0}", Thread.CurrentThread.ApartmentState.ToString());
            Debug.Print("*** Test 1 ***");
            Test();
            SynchronizationContext.SetSynchronizationContext(null);
            WindowsFormsSynchronizationContext.AutoInstall = false;
            Debug.Print("*** Test 2 ***");
            Test();
        }

        static void DumpSyncContext(string id, string message, object ctx)
        {
            Debug.Print("{0}: {1} ({2})", id, ctx != null ? ctx.GetType().Name : "null", message);
        }

        static void Test()
        {
            Debug.Print("WindowsFormsSynchronizationContext.AutoInstall: {0}", WindowsFormsSynchronizationContext.AutoInstall);
            var ctx1 = SynchronizationContext.Current;
            DumpSyncContext("ctx1", "before setting up the context", ctx1);

            if (!(ctx1 is WindowsFormsSynchronizationContext))
                SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());

            var ctx2 = SynchronizationContext.Current;
            DumpSyncContext("ctx2", "before Application.DoEvents", ctx2);

            Application.DoEvents();

            var ctx3 = SynchronizationContext.Current;
            DumpSyncContext("ctx3", "after Application.DoEvents", ctx3);

            Debug.Print("ctx3 == ctx1: {0}, ctx3 == ctx2: {1}", ctx3 == ctx1, ctx3 == ctx2);
        }
    }
}

Отладочный вывод:

ApartmentState: STA
*** Test 1 ***
WindowsFormsSynchronizationContext.AutoInstall: True
ctx1: null (before setting up the context)
ctx2: WindowsFormsSynchronizationContext (before Application.DoEvents)
ctx3: SynchronizationContext (after Application.DoEvents)
ctx3 == ctx1: False, ctx3 == ctx2: False
*** Test 2 ***
WindowsFormsSynchronizationContext.AutoInstall: False
ctx1: null (before setting up the context)
ctx2: WindowsFormsSynchronizationContext (before Application.DoEvents)
ctx3: WindowsFormsSynchronizationContext (after Application.DoEvents)
ctx3 == ctx1: False, ctx3 == ctx2: True

Было выполнено некоторое исследование реализации Framework Application.ThreadContext.RunMessageLoopInner и WindowsFormsSynchronizationContext.InstalIifNeeded/Uninstall, чтобы понять, почему именно это происходит. Условие состоит в том, что поток в настоящее время не выполняет цикл сообщений Application, как упоминалось выше. Соответствующий фрагмент из RunMessageLoopInner:

if (this.messageLoopCount == 1)
{
    WindowsFormsSynchronizationContext.InstallIfNeeded();
}

Тогда код внутри WindowsFormsSynchronizationContext.InstallIfNeeded/Uninstall пары методов не сохраняет/восстанавливает существующий контекст синхронизации. На данный момент я не уверен, что это ошибка или конструктивная особенность.

Решение состоит в том, чтобы отключить WindowsFormsSynchronizationContext.AutoInstall, как это просто:

struct SyncContextSetup
{
    public SyncContextSetup(bool autoInstall)
    {
        WindowsFormsSynchronizationContext.AutoInstall = autoInstall;
        SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());
    }
}

static readonly SyncContextSetup _syncContextSetup =
    new SyncContextSetup(autoInstall: false);

Несколько слов о том, почему я использую Application.DoEvents в первую очередь здесь. Это типичный асинхронный синхронный мостовой код, работающий в потоке пользовательского интерфейса, с использованием вложенного цикла сообщений. Это плохая практика, но устаревшее хост-приложение ожидает, что все интерфейсы API будут выполняться синхронно. Оригинальная проблема описана здесь . В какой-то более поздний момент я заменил CoWaitForMultipleHandles комбинацией Application.DoEvents/MsgWaitForMultipleObjects, которая теперь выглядит так:

[EDITED] Самая последняя версия WaitWithDoEvents здесь . [/отредактированы]

Идея заключалась в том, чтобы отправлять сообщения с использованием стандартного механизма .NET, а не полагаться на CoWaitForMultipleHandles для этого. Это, когда я неявно ввел проблему с контекстом синхронизации из-за описанного поведения DoEvents.

Унаследованное приложение в настоящее время переписывается с использованием современных технологий, а также контроль. Текущая реализация предназначена для существующих клиентов с Windows XP, которые не могут обновляться по причинам, не зависящим от нашего контроля.

Наконец, вот реализация пользовательского awaiter, о котором я упомянул в моем вопросе, как о возможности смягчения проблемы. Это был интересный опыт, и он работает, но он не может считаться правильным решением.

/// <summary>
/// AwaitHelpers - custom awaiters
/// WithContext continues on the control thread after await
/// E.g.: await TaskEx.Delay(1000).WithContext(this)
/// </summary>
public static class AwaitHelpers
{
    public static ContextAwaiter<T> WithContext<T>(this Task<T> task, Control control, bool alwaysAsync = false)
    {
        return new ContextAwaiter<T>(task, control, alwaysAsync);
    }

    // ContextAwaiter<T>
    public class ContextAwaiter<T> : INotifyCompletion
    {
        readonly Control _control;
        readonly TaskAwaiter<T> _awaiter;
        readonly bool _alwaysAsync;

        public ContextAwaiter(Task<T> task, Control control, bool alwaysAsync)
        {
            _awaiter = task.GetAwaiter();
            _control = control;
            _alwaysAsync = alwaysAsync;
        }

        public ContextAwaiter<T> GetAwaiter() { return this; }

        public bool IsCompleted { get { return !_alwaysAsync && _awaiter.IsCompleted; } }

        public void OnCompleted(Action continuation)
        {
            if (_alwaysAsync || _control.InvokeRequired)
            {
                Action<Action> callback = (c) => _awaiter.OnCompleted(c);
                _control.BeginInvoke(callback, continuation);
            }
            else
                _awaiter.OnCompleted(continuation);
        }

        public T GetResult()
        {
            return _awaiter.GetResult();
        }
    }
}

Ответ 2

WindowsFormsSynchronizationContext устанавливается автоматически при создании элемента управления, если вы не отключили его. Итак, если кто-то еще не топчет его после того, как ваш контроль создан, нет ничего особенного.

Вы можете установить контрольную точку в WindowsFormsSynchronizationContext.InstalIifNeeded, чтобы узнать, когда это произойдет.

См:

(Я понимаю, что это не отвечает на ваш вопрос, но не хотел помещать это в комментарий)