StaTaskScheduler e STA mensagem de bombeamento de thread

TL; DR: Um deadlock dentro de uma tarefa executada pelo StaTaskScheduler . Versão longa:

Eu estou usando StaTaskScheduler de ParallelExtensionsExtras pela equipe paralela, para hospedar alguns objects STA COM herdados fornecidos por terceiros. A descrição dos detalhes da implementação do StaTaskScheduler diz o seguinte:

A boa notícia é que a implementação da TPL pode ser executada em encadeamentos MTA ou STA e leva em consideração diferenças relevantes em torno de APIs subjacentes, como WaitHandle.WaitAll (que suporta apenas encadeamentos MTA quando o método é fornecido com várias alças de espera).

Eu pensei que isso significaria que as partes de bloqueio do TPL usariam uma API de espera que bombeia mensagens, como CoWaitForMultipleHandles , para evitar situações de deadlock quando chamadas em um thread STA.

Na minha situação, acredito que está ocorrendo o seguinte: no object STA COM do proc faz uma chamada para fora do object B, em seguida, espera um retorno de chamada de B via como parte da chamada de saída.

De uma forma simplificada:

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

O problema é que a.Method(b) nunca retorna. Tanto quanto eu posso dizer, isso acontece porque uma espera de bloqueio em algum lugar dentro BlockingCollection não bombeia mensagens, então minha suposição sobre a instrução citada é provavelmente errada.

EDITADO O mesmo código funciona quando é executado no thread de interface do usuário do aplicativo WinForms de teste (ou seja, fornecendo TaskScheduler.FromCurrentSynchronizationContext() vez de staTaskScheduler para Task.Factory.StartNew ).

Qual é o caminho certo para resolver isso? Devo ter implementado um contexto de synchronization personalizado, que seria explicitamente bombear mensagens com CoWaitForMultipleHandles e instalá-lo em cada thread STA iniciado por StaTaskScheduler ?

Em caso afirmativo, a implementação subjacente do BlockingCollection estará chamando meu método SynchronizationContext.Wait ? Posso usar o SynchronizationContext.WaitHelper para implementar o SynchronizationContext.Wait ?


EDITADO com algum código mostrando que um encadeamento STA gerenciado não é acionado ao executar uma espera de bloqueio. O código é um aplicativo de console completo pronto para copiar / colar / executar:

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

Isso produz a saída:

 Testando sem bombear ...
 O argumento da coleção está vazio e foi marcado como completo com relação às adições.

 Teste com bombeamento ...
 O argumento da coleção está vazio e foi marcado como completo com relação às adições.
 Agora comece a bombear ...
 WM_TEST processado
 Pressione Enter para sair

    Meu entendimento do seu problema: você está usando o StaTaskScheduler apenas para organizar o apartamento COM STA clássico para seus objects COM legados. Você não está executando um loop de mensagem do WinForms ou WPF no thread STA do StaTaskScheduler . Ou seja, você não está usando nada como Application.Run , Application.DoEvents ou Dispatcher.PushFrame dentro desse segmento. Corrija-me se esta é uma suposição errada.

    Por si só, o StaTaskScheduler não instala nenhum contexto de synchronization nos encadeamentos STA que cria. Assim, você está confiando no CLR para enviar mensagens para você. Eu só encontrei uma confirmação implícita de que o CLR bombeia em threads STA, em Apartments e Pumping no CLR por Chris Brumme:

    Eu continuo dizendo que o bloqueio gerenciado irá realizar “algum bombeamento” quando chamado em um segmento STA. Não seria ótimo saber exatamente o que será bombeado? Infelizmente, o bombeamento é uma arte negra que está além da compreensão mortal. No Win2000 e acima, simplesmente delegamos ao serviço CoWaitForMultipleHandles do OLE32 .

    Isso indica que o CLR usa CoWaitForMultipleHandles internamente para encadeamentos STA. Além disso, os documentos do MSDN para o sinalizador COWAIT_DISPATCH_WINDOW_MESSAGES mencionam isto :

    … no STA é apenas um pequeno conjunto de mensagens especiais enviadas.

    Eu fiz alguma pesquisa sobre isso , mas não consegui WM_TEST o WM_TEST de seu código de exemplo com CoWaitForMultipleHandles , discutimos isso nos comentários da sua pergunta. Pelo que entendi, o pequeno conjunto de mensagens especiais é muito limitado a algumas mensagens específicas do COM Marshaller, e não inclui mensagens regulares de uso geral, como o WM_TEST .

    Então, para responder sua pergunta:

    … Devo ter implementado um contexto de synchronization personalizado, que explicitamente iria bombear mensagens com CoWaitForMultipleHandles e instalá-lo em cada thread STA iniciado por StaTaskScheduler?

    Sim, acredito que criar um contexto de synchronization personalizado e replace o SynchronizationContext.Wait é, de fato, a solução correta.

    No entanto, você deve evitar o uso de MsgWaitForMultipleObjectsEx e usar MsgWaitForMultipleObjectsEx . Se MsgWaitForMultipleObjectsEx indica que há uma mensagem pendente na fila, você deve manualmente PeekMessage(PM_REMOVE) lo com PeekMessage(PM_REMOVE) e DispatchMessage . Então você deve continuar esperando pelas alças, todas dentro da mesma chamada SynchronizationContext.Wait .

    Observe que há uma diferença sutil, mas importante, entre MsgWaitForMultipleObjectsEx e MsgWaitForMultipleObjects . O último não retorna e continua bloqueando, se houver uma mensagem já vista na fila (por exemplo, com PeekMessage(PM_NOREMOVE) ou GetQueueStatus ), mas não removida. Isso não é bom para o bombeamento, porque seus objects COM podem estar usando algo como PeekMessage para inspecionar a fila de mensagens. Isso pode posteriormente fazer com que MsgWaitForMultipleObjects bloqueie quando não for esperado.

    OTOH, MsgWaitForMultipleObjectsEx com sinalizador MWMO_INPUTAVAILABLE não tem essa lacuna e retornaria nesse caso.

    Um tempo atrás eu criei uma versão customizada do StaTaskScheduler ( disponível aqui como ThreadAffinityTaskScheduler ) na tentativa de resolver um problema diferente : manter um conjunto de encadeamentos com afinidade de encadeamento para posteriores continuações de await . A afinidade de thread é vital se você usar objects STA COM em vários awaits . O StaTaskScheduler original exibe esse comportamento somente quando seu pool é limitado a 1 thread.

    Então eu fui em frente e fiz mais algumas experiências com o seu caso WM_TEST . Originalmente, instalei uma instância da class SynchronizationContext padrão no encadeamento STA. A mensagem WM_TEST não foi bombeada, o que era esperado.

    Em seguida, anulei SynchronizationContext.Wait para apenas encaminhá-lo para SynchronizationContext.WaitHelper . Ele foi chamado, mas ainda não bombeou.

    Finalmente, eu implementei um ciclo completo de mensagens, aqui está a parte principal:

     // the core loop var msg = new NativeMethods.MSG(); while (true) { // MsgWaitForMultipleObjectsEx with MWMO_INPUTAVAILABLE returns, // even if there's 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; } 

    Isso funciona, WM_TEST é bombeado. Abaixo está uma versão adaptada do seu teste:

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

    A saída :

     Tópico inicial nº 9
     Na linha STA # 10
     Poste algumas mensagens WM_TEST ...
     Pressione Enter para continuar ...
     WM_TEST processado: 1
     WM_TEST processado: 2
     WM_TEST processado: 3
    
     Depois de esperar, o fio # 10
     Mensagens pendentes na fila: false
     Saindo do fio STA # 10
     Tópico atual # 12
     aperte qualquer tecla para sair 

    Observe que essa implementação suporta a afinidade de encadeamento (permanece no encadeamento # 10 após await ) e a mensagem é bombeada. O código-fonte completo contém partes reutilizáveis ​​( ThreadAffinityTaskScheduler e ThreadWithAffinityContext ) e está disponível aqui como aplicativo de console autocontido . Ele não foi totalmente testado, portanto, use-o por sua conta e risco.

    O assunto do bombeamento de threads STA é grande, com poucos programadores tendo um tempo agradável resolvendo o deadlock. O artigo seminal sobre isso foi escrito por Chris Brumme, um cara inteligente que trabalhou no .NET. Você vai encontrá-lo nesta postagem do blog . Infelizmente, é bastante curto em detalhes, ele não vai além de notar que o CLR faz um pouco de bombeamento, mas sem quaisquer detalhes sobre as regras exatas.

    O código do qual ele está falando, incluído no .NET 2.0, está presente em uma function CLR interna chamada MsgWaitHelper (). O código-fonte para o .NET 2.0 está disponível através da distribuição SSCLI20. Muito completo, mas a fonte para MsgWaitHelper () não está incluída. Bastante incomum. Descompilar é uma causa perdida, é muito grande.

    A única coisa a tirar de seu blog é o perigo da reinput . Bombear em um thread STA é perigoso por sua capacidade de enviar mensagens do Windows e executar código arbitrário quando seu programa não está no estado correto para permitir que esse código seja executado. Algo que a maioria dos programadores VB6 sabe quando ele usou DoEvents () para obter um loop modal em seu código para parar o congelamento da interface do usuário. Eu escrevi um post sobre seus perigos mais típicos. MsgWaitHelper () faz exatamente este tipo de bombeamento, mas é muito seletivo sobre exatamente que tipo de código ele permite rodar.

    Você pode obter algumas informações sobre o que ele faz dentro de seu programa de teste executando o programa sem um depurador anexado e, em seguida, anexando um depurador não gerenciado. Você verá isso bloqueando em NtWaitForMultipleObjects (). Eu dei um passo além e defini um ponto de interrupção em PeekMessageW (), para obter este rastreamento de pilha:

     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 

    Cuidado que eu gravei esse rastreamento de pilha no Windows 8.1, ele será bem diferente nas versões mais antigas do Windows. O loop modal COM foi muito usado no Windows 8, e também é um grande negócio para os programas WinRT. Não sei muito sobre isso, mas parece ter outro modelo de encadeamento STA chamado ASTA que faz um tipo mais restritivo de bombeamento, consagrado no CoWaitForMultipleObjects () adicionado

    ObjectNative :: WaitTimeout () é onde o SemaphoreSlim.Wait () dentro do método BlockingCollection.Take () inicia a execução do código CLR. Você vê isso percorrendo os níveis do código CLR interno para chegar à function mítica MsgWaitHelper () e, em seguida, alternando para o infame loop de dispatcher modal COM.

    O sinal do sinal do morcego fazendo o tipo “errado” de bombeamento no seu programa é a chamada para o método CliModalLoop :: PeekRPCAndDDEMessage (). Em outras palavras, está considerando apenas o tipo de mensagens de interoperabilidade que são postadas em uma janela interna específica que despacha as chamadas COM que cruzam um limite de um apartamento. Não vai bombear as mensagens que estão na fila de mensagens para sua própria janela.

    Isso é um comportamento compreensível, o Windows só pode ter certeza absoluta de que a reinput não mata o programa quando ele perceber que o thread da interface do usuário está ocioso . Ele está ocioso quando bombeia o próprio loop de mensagens, uma chamada para PeekMessage () ou GetMessage () indica esse estado. O problema é que você não se bombeia. Você violou o contrato principal de um segmento STA, deve bombear o loop de mensagem. Esperar que o loop modal COM faça o bombeamento para você é, portanto, uma esperança inativa.

    Você pode consertar isso, mesmo que eu não recomende. O CLR o deixará para o próprio aplicativo para executar a espera por um object SynchronizationContext.Current construído adequadamente. Você pode criar um derivando sua própria class e replace o método Wait (). Chame o método SetWaitNotificationRequired () para convencer o CLR que deve deixar isso para você. Uma versão incompleta que demonstra a abordagem:

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

    E instalá-lo no início do seu segmento:

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

    Agora você verá sua mensagem WM_TEST sendo enviada. É a chamada para Application.DoEvents () que o despachou. Eu poderia ter encoberto usando PeekMessage + DispatchMessage, mas que ofuscaria o perigo deste código, melhor não furar DoEvents () sob a tabela. Você realmente está jogando um jogo de reinput muito perigoso aqui. Não use esse código.

    Para encurtar a história, a única esperança de usar o StaThreadScheduler corretamente é quando ele é usado em código que já implementou o contrato STA e bombas como um encadeamento STA deve fazer. Foi realmente concebido como um band-aid para o código antigo, onde você não precisa se dar ao luxo de controlar o estado do thread. Como qualquer código que tenha começado a vida em um programa VB6 ou em um suplemento do Office. Experimentando um pouco com isso, eu não acho que realmente possa funcionar. Notável também é que a necessidade disso deve ser completamente eliminada com a disponibilidade de asych / await.