Перекачка сообщений StaTaskScheduler и STA

TL; DR: тупик внутри задачи, выполняемой StaTaskScheduler. Длинная версия:

Я использую StaTaskScheduler от ParallelExtensionsExtras от Parallel Team, чтобы разместить несколько устаревших STA COM объекты, предоставленные третьей стороной. Описание деталей реализации StaTaskScheduler говорит следующее:

Хорошей новостью является то, что реализация TPL может работать на любом MTA или STA, и учитывает соответствующие различия в базовые API, такие как WaitHandle.WaitAll(который поддерживает только MTA потоки, когда для метода предусмотрены несколько дескрипторов ожидания).

Я думал, что это означало бы, что блокирующие части TPL будут использовать API ожидания, который накачивает сообщения, такие как CoWaitForMultipleHandles, чтобы избежать ситуаций взаимоблокировки при вызове STA нить.

В моей ситуации, я считаю, что происходит следующее: in-proc STA COM-объект A делает вызов для объекта без компромисса B, затем ожидает обратного вызова из B через часть исходящего вызова.

В упрощенной форме:

var result = await Task.Factory.StartNew(() =>
{
    // in-proc object A
    var a = new A(); 
    // out-of-proc object B
    var b = new B(); 
    // A calls B and B calls back A during the Method call
    return a.Method(b);     
}, CancellationToken.None, TaskCreationOptions.None, staTaskScheduler);

Проблема в том, что a.Method(b) никогда не возвращается. Насколько я могу судить, это происходит потому, что остановка блокировки где-то внутри BlockingCollection<Task> не передает сообщения, поэтому мое предположение о цитируемом утверждении, вероятно, неверно.

EDITED Тот же код работает, когда выполняется в потоке пользовательского интерфейса тестового приложения WinForms (то есть, < <27 > вместо StaTaskScheduler до Task.Factory.StartNew).

Каков правильный способ решить эту проблему? Должен ли я реализовать пользовательский контекст синхронизации, который будет явно передавать сообщения с помощью CoWaitForMultipleHandles и устанавливать его в каждом потоке STA, начатом с StaTaskScheduler?

Если да, будет ли базовая реализация BlockingCollection вызывать метод SynchronizationContext.Wait? Могу ли я использовать SynchronizationContext.WaitHelper для реализации SynchronizationContext.Wait?


EDITED с некоторым кодом, показывающим, что управляемый поток STA не накачивается при выполнении блокировки. Код - это готовое консольное приложение для копирования/вставки/запуска:
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleTestApp
{
    class Program
    {
        // start and run an STA thread
        static void RunStaThread(bool pump)
        {
            // test a blocking wait with BlockingCollection.Take
            var tasks = new BlockingCollection<Task>();

            var thread = new Thread(() => 
            {
                // Create a simple Win32 window 
                var hwndStatic = NativeMethods.CreateWindowEx(0, "Static", String.Empty, NativeMethods.WS_POPUP,
                    0, 0, 0, 0, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);

                // subclass it with a custom WndProc
                IntPtr prevWndProc = IntPtr.Zero;

                var newWndProc = new NativeMethods.WndProc((hwnd, msg, wParam, lParam) =>
                {
                    if (msg == NativeMethods.WM_TEST)
                        Console.WriteLine("WM_TEST processed");
                    return NativeMethods.CallWindowProc(prevWndProc, hwnd, msg, wParam, lParam);
                });

                prevWndProc = NativeMethods.SetWindowLong(hwndStatic, NativeMethods.GWL_WNDPROC, newWndProc);
                if (prevWndProc == IntPtr.Zero)
                    throw new ApplicationException();

                // post a test WM_TEST message to it
                NativeMethods.PostMessage(hwndStatic, NativeMethods.WM_TEST, IntPtr.Zero, IntPtr.Zero);

                // BlockingCollection blocks without pumping, NativeMethods.WM_TEST never arrives
                try { var task = tasks.Take(); }
                catch (Exception e) { Console.WriteLine(e.Message); }

                if (pump)
                {
                    // NativeMethods.WM_TEST will arrive, because Win32 MessageBox pumps
                    Console.WriteLine("Now start pumping...");
                    NativeMethods.MessageBox(IntPtr.Zero, "Pumping messages, press OK to stop...", String.Empty, 0);
                }
            });

            thread.SetApartmentState(ApartmentState.STA);
            thread.Start();

            Thread.Sleep(2000);

            // this causes the STA thread to end
            tasks.CompleteAdding(); 

            thread.Join();
        }

        static void Main(string[] args)
        {
            Console.WriteLine("Testing without pumping...");
            RunStaThread(false);

            Console.WriteLine("\nTest with pumping...");
            RunStaThread(true);

            Console.WriteLine("Press Enter to exit");
            Console.ReadLine();
        }
    }

    // Interop
    static class NativeMethods
    {
        [DllImport("user32")]
        public static extern IntPtr SetWindowLong(IntPtr hwnd, int nIndex, WndProc newProc);

        [DllImport("user32")]
        public static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hwnd, int msg, int wParam, int lParam);

        [DllImport("user32.dll")]
        public static extern IntPtr CreateWindowEx(int dwExStyle, string lpClassName, string lpWindowName, int dwStyle, int x, int y, int nWidth, int nHeight, IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);

        [DllImport("user32.dll")]
        public static extern bool PostMessage(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam);

        [DllImport("user32.dll")]
        public static extern int MessageBox(IntPtr hwnd, string text, String caption, int options);

        public delegate IntPtr WndProc(IntPtr hwnd, int msg, int wParam, int lParam);

        public const int GWL_WNDPROC = -4;
        public const int WS_POPUP = unchecked((int)0x80000000);
        public const int WM_USER = 0x0400;

        public const int WM_TEST = WM_USER + 1;
    }
}

Это приводит к выходу:

Testing without pumping...
The collection argument is empty and has been marked as complete with regards to additions.

Test with pumping...
The collection argument is empty and has been marked as complete with regards to additions.
Now start pumping...
WM_TEST processed
Press Enter to exit

Ответ 1

Мое понимание вашей проблемы: вы используете StaTaskScheduler только для организации классической квартиры COM STA для ваших устаревших COM-объектов. Вы не, запустив цикл сообщений WinForms или WPF в потоке STA в StaTaskScheduler. То есть вы не используете что-либо вроде Application.Run, Application.DoEvents или Dispatcher.PushFrame внутри этого потока. Исправьте меня, если это неверное предположение.

Сам по себе StaTaskScheduler не устанавливает любой контекст синхронизации в потоках STA, которые он создает. Таким образом, вы полагаетесь на CLR для накачки сообщений для вас. Я нашел неявное подтверждение того, что CLR-насосы на потоках STA, в Квартиры и насосы в CLR от Криса Брумме:

Я продолжаю говорить, что управляемая блокировка будет выполнять "некоторую накачку", когда вызвал поток STA. Было бы здорово точно знать, что будут накачаны? К сожалению, накачка - это черное искусство, которое за пределами смертного понимания. На Win2000 и выше мы просто делегируем OLE32s Служба CoWaitForMultipleHandles.

Это означает, что CLR использует CoWaitForMultipleHandles внутренне для потоков STA. Кроме того, в документе MSDN docs для COWAIT_DISPATCH_WINDOW_MESSAGES флаг укажите это:

... в STA - это всего лишь небольшой набор отправленных сообщений с особыми обложками.

Я сделал некоторые исследования по этому вопросу, но не смог перекачать WM_TEST из вашего образца кода с помощью CoWaitForMultipleHandles, мы обсудили это в комментариях к ваш вопрос. Насколько я понимаю, вышеупомянутый небольшой набор сообщений с особыми номерами действительно ограничен для некоторых сообщений, связанных с марш-сервером COM, и не содержит никаких обычных сообщений общего назначения, таких как ваш WM_TEST.

Итак, чтобы ответить на ваш вопрос:

... Должен ли я реализовать пользовательский контекст синхронизации, который явно передавать сообщения с помощью CoWaitForMultipleHandles и устанавливать его на каждом потоке STA, запущенном StaTaskScheduler?

Да, я считаю, что создание настраиваемого контекста синхронизации и переопределение SynchronizationContext.Wait действительно является правильным решением.

Однако вам не следует использовать CoWaitForMultipleHandles, а использовать MsgWaitForMultipleObjectsEx вместо. Если MsgWaitForMultipleObjectsEx указывает на ожидающее сообщение в очереди, вы должны вручную направить его с помощью PeekMessage(PM_REMOVE) и DispatchMessage. Затем вы должны продолжать ждать ручек, все внутри одного и того же вызова SynchronizationContext.Wait.

Заметьте там тонкое, но важное различие между MsgWaitForMultipleObjectsEx и MsgWaitForMultipleObjects. Последнее не возвращается и продолжает блокировать, если в очереди уже есть сообщение (например, с PeekMessage(PM_NOREMOVE) или GetQueueStatus), но не удалено. Это не подходит для перекачки, потому что ваши объекты COM могут использовать что-то вроде PeekMessage для проверки очереди сообщений. Это может привести к тому, что блок MsgWaitForMultipleObjects блокируется, если не ожидается.

OTOH, MsgWaitForMultipleObjectsEx с флагом MWMO_INPUTAVAILABLE не имеет такого недостатка и вернется в этом случае.

Некоторое время назад я создал пользовательскую версию StaTaskScheduler (доступную здесь как ThreadAffinityTaskScheduler) в попытке решить a различные проблемы: поддержание пула потоков с привязкой потоков для последующих await продолжений. Аффинность потока жизненно важна, если вы используете объекты STA COM через несколько awaits. Оригинальное StaTaskScheduler проявляет это поведение только тогда, когда его пул ограничен 1 потоком.

Итак, я пошел дальше и сделал еще несколько экспериментов с вашим тегом WM_TEST. Первоначально я установил экземпляр стандартного класса SynchronizationContext в потоке STA. Сообщение WM_TEST не перекачивалось, что ожидалось.

Затем я переопределил SynchronizationContext.Wait, чтобы просто переслать его на SynchronizationContext.WaitHelper. Его вызвали, но он все еще не накачивался.

Наконец, я внедрил полнофункциональный контур насоса сообщений, вот его основная часть:

// the core loop
var msg = new NativeMethods.MSG();
while (true)
{
    // MsgWaitForMultipleObjectsEx with MWMO_INPUTAVAILABLE returns,
    // even if there a message already seen but not removed in the message queue
    nativeResult = NativeMethods.MsgWaitForMultipleObjectsEx(
        count, waitHandles,
        (uint)remainingTimeout,
        QS_MASK,
        NativeMethods.MWMO_INPUTAVAILABLE);

    if (IsNativeWaitSuccessful(count, nativeResult, out managedResult) || WaitHandle.WaitTimeout == managedResult)
        return managedResult;

    // there is a message, pump and dispatch it
    if (NativeMethods.PeekMessage(out msg, IntPtr.Zero, 0, 0, NativeMethods.PM_REMOVE))
    {
        NativeMethods.TranslateMessage(ref msg);
        NativeMethods.DispatchMessage(ref msg);
    }
    if (hasTimedOut())
        return WaitHandle.WaitTimeout;
}

Это работает, WM_TEST накачивается. Ниже приведена адаптированная версия вашего теста:

public static async Task RunAsync()
{
    using (var staThread = new Noseratio.ThreadAffinity.ThreadWithAffinityContext(staThread: true, pumpMessages: true))
    {
        Console.WriteLine("Initial thread #" + Thread.CurrentThread.ManagedThreadId);
        await staThread.Run(async () =>
        {
            Console.WriteLine("On STA thread #" + Thread.CurrentThread.ManagedThreadId);
            // create a simple Win32 window
            IntPtr hwnd = CreateTestWindow();

            // Post some WM_TEST messages
            Console.WriteLine("Post some WM_TEST messages...");
            NativeMethods.PostMessage(hwnd, NativeMethods.WM_TEST, new IntPtr(1), IntPtr.Zero);
            NativeMethods.PostMessage(hwnd, NativeMethods.WM_TEST, new IntPtr(2), IntPtr.Zero);
            NativeMethods.PostMessage(hwnd, NativeMethods.WM_TEST, new IntPtr(3), IntPtr.Zero);
            Console.WriteLine("Press Enter to continue...");
            await ReadLineAsync();

            Console.WriteLine("After await, thread #" + Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("Pending messages in the queue: " + (NativeMethods.GetQueueStatus(0x1FF) >> 16 != 0));

            Console.WriteLine("Exiting STA thread #" + Thread.CurrentThread.ManagedThreadId);
        }, CancellationToken.None);
    }
    Console.WriteLine("Current thread #" + Thread.CurrentThread.ManagedThreadId);
}

Выход:

Initial thread #9
On STA thread #10
Post some WM_TEST messages...
Press Enter to continue...
WM_TEST processed: 1
WM_TEST processed: 2
WM_TEST processed: 3

After await, thread #10
Pending messages in the queue: False
Exiting STA thread #10
Current thread #12
Press any key to exit

Примечание. Эта реализация поддерживает как сродство потока (оно остается в потоке # 10 после await), так и при пересылке сообщения. Полный исходный код содержит повторно используемые части (ThreadAffinityTaskScheduler и ThreadWithAffinityContext) и доступен здесь как автономное консольное приложение. Он не был тщательно протестирован, поэтому используйте его на свой страх и риск.

Ответ 2

Объект перекачки резьбы STA является большим, у которого очень мало программистов, имеющих приятное время, решающее тупик. Основная статья об этом была написана Крисом Брумме, главным умным парнем, который работал над .NET. Вы найдете его в этом сообщении в блоге. К сожалению, он довольно короткий по специфике, он не выходит за рамки того, что CLR выполняет немного перекачки, но без каких-либо подробностей относительно точных правил.

Код, о котором он говорит, добавленный в .NET 2.0, присутствует во внутренней функции CLR с именем MsgWaitHelper(). Исходный код для .NET 2.0 доступен через дистрибутив SSCLI20. Очень полно, но источник для MsgWaitHelper() не включен. Довольно необычно. Декомпиляция это скорее потерянная причина, она очень большая.

Единственное, что нужно убрать с его сообщения в блоге, это опасность re-entrancy. Накачка в потоке STA опасна для возможности отправки сообщений Windows и получения произвольного кода, когда ваша программа не находится в правильном состоянии, чтобы разрешить выполнение этого кода. Что-то, что знает любой программист VB6, когда он использовал DoEvents(), чтобы получить модальный цикл в своем коде, чтобы остановить замораживание пользовательского интерфейса. Я написал сообщение о его наиболее типичных опасностях. MsgWaitHelper() делает этот точный вид самой накачки, однако он очень избирательно относится к тому, какой код он позволяет запускать.

Вы можете получить представление о том, что он делает в вашей тестовой программе, запустив программу без отладчика, а затем подключив неуправляемый отладчик. Вы увидите, что он блокирует NtWaitForMultipleObjects(). Я сделал еще один шаг и установил точку останова на PeekMessageW(), чтобы получить эту трассировку стека:

user32.dll!PeekMessageW()   Unknown
combase.dll!CCliModalLoop::MyPeekMessage(tagMSG * pMsg, HWND__ * hwnd, unsigned int min, unsigned int max, unsigned short wFlag) Line 2305  C++
combase.dll!CCliModalLoop::PeekRPCAndDDEMessage() Line 2008 C++
combase.dll!CCliModalLoop::FindMessage(unsigned long dwStatus) Line 2087    C++
combase.dll!CCliModalLoop::HandleWakeForMsg() Line 1707 C++
combase.dll!CCliModalLoop::BlockFn(void * * ahEvent, unsigned long cEvents, unsigned long * lpdwSignaled) Line 1645 C++
combase.dll!ClassicSTAThreadWaitForHandles(unsigned long dwFlags, unsigned long dwTimeout, unsigned long cHandles, void * * pHandles, unsigned long * pdwIndex) Line 46 C++
combase.dll!CoWaitForMultipleHandles(unsigned long dwFlags, unsigned long dwTimeout, unsigned long cHandles, void * * pHandles, unsigned long * lpdwindex) Line 120 C++
clr.dll!MsgWaitHelper(int,void * *,int,unsigned long,int)   Unknown
clr.dll!Thread::DoAppropriateWaitWorker(int,void * *,int,unsigned long,enum WaitMode)   Unknown
clr.dll!Thread::DoAppropriateWait(int,void * *,int,unsigned long,enum WaitMode,struct PendingSync *)    Unknown
clr.dll!CLREventBase::WaitEx(unsigned long,enum WaitMode,struct PendingSync *)  Unknown
clr.dll!CLREventBase::Wait(unsigned long,int,struct PendingSync *)  Unknown
clr.dll!Thread::Block(int,struct PendingSync *) Unknown
clr.dll!SyncBlock::Wait(int,int)    Unknown
clr.dll!ObjectNative::WaitTimeout(bool,int,class Object *)  Unknown

Помните, что я записал эту трассировку стека в Windows 8.1, она будет выглядеть совсем по-другому в старых версиях Windows. COM модальный цикл был сильно переделан в Windows 8, это также очень важно для программ WinRT. Не знаю, что об этом много, но, похоже, у него есть еще одна модель резьбы STA под названием ASTA, которая делает более ограничительный тип накачки, закрепленный в добавленном CoWaitForMultipleObjects()

ObjectNative:: WaitTimeout() - это то, где метод SemaphoreSlim.Wait() внутри метода BlockingCollection.Take() запускает код CLR. Вы видите, что он вспахивает уровни внутреннего кода CLR, чтобы добраться до мифической функции MsgWaitHelper(), а затем переключиться на печально известный COM-диспетчерский цикл.

Значок сигнала летучей мыши, сделав "неправильный" тип накачки в вашей программе, - это вызов метода CliModalLoop:: PeekRPCAndDDEMessage(). Другими словами, он рассматривает только сообщения о взаимодействии, которые отправляются в определенное внутреннее окно, которое отправляет вызовы COM, пересекающие границу квартиры. Он не будет передавать сообщения, которые находятся в очереди сообщений для вашего собственного окна.

Это понятное поведение, Windows может быть абсолютно уверена, что повторная попытка не приведет к урону вашей программы, когда вы увидите, что ваш поток пользовательского интерфейса простоя. Он простаивает, когда он накачивает сам контур сообщения, вызов PeekMessage() или GetMessage() указывает это состояние. Проблема в том, что вы не закачиваете себя. Вы нарушили основной контракт по потоку STA, он должен накапливать контур сообщения. Надеясь, что COM-модальная петля сделает перекачку для вас, вы, таким образом, надеетесь.

Вы можете исправить это, хотя я не рекомендую вам это делать. CLR оставит его самому приложению для выполнения ожидания соответствующим образом сконструированным объектом SynchronizationContext.Current. Вы можете создать его, выведя свой собственный класс и переопределив метод Wait(). Вызовите метод SetWaitNotificationRequired(), чтобы убедить CLR, что он должен оставить его вам. Неполная версия, демонстрирующая подход:

class MySynchronizationProvider : System.Threading.SynchronizationContext {
    public MySynchronizationProvider() {
        base.SetWaitNotificationRequired();
    }
    public override int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) {
        for (; ; ) {
            int result = MsgWaitForMultipleObjects(waitHandles.Length, waitHandles, waitAll, millisecondsTimeout, 8);
            if (result == waitHandles.Length) System.Windows.Forms.Application.DoEvents();
            else return result;
        }
    }
    [DllImport("user32.dll")]
    private static extern int MsgWaitForMultipleObjects(int cnt, IntPtr[] waitHandles, bool waitAll,
        int millisecondTimeout, int mask);        
}

И установите его в начале потока:

    System.ComponentModel.AsyncOperationManager.SynchronizationContext =
        new MySynchronizationProvider();

Теперь вы увидите сообщение WM_TEST, которое будет отправлено. Это вызов Application.DoEvents(), который отправил его. Я мог бы скрыть это с помощью PeekMessage + DispatchMessage, но это могло бы запутать опасность этого кода, лучше всего не придерживаться DoEvents() под таблицей. Здесь вы играете очень опасную игру. Не используйте этот код.

Короче говоря, единственная надежда на правильное использование StaThreadScheduler - это когда он используется в коде, который уже реализовал контракт STA, и насосы, подобные потоку STA, должны делать. Это было на самом деле означало, что вам нужна сводная помощь для старого кода, где вам не нужно роскошь контролировать состояние потока. Как и любой код, который начал работу в программе VB6 или надстройке Office. Немного экспериментируя с этим, я не думаю, что он действительно может работать. Примечательно также, что необходимость в нем должна быть полностью устранена с возможностью доступа к asych/wait.