StaTaskScheduler e STA thread message pumping

TL; DR: un deadlock all’interno di un’attività eseguita da StaTaskScheduler . Versione lunga:

Sto usando StaTaskScheduler da ParallelExtensionsExtras di Parallel Team, per ospitare alcuni oggetti STA COM legacy forniti da terze parti. La descrizione dei dettagli di implementazione di StaTaskScheduler dice quanto segue:

La buona notizia è che l’implementazione di TPL è in grado di essere eseguita su thread MTA o STA e tiene conto delle differenze rilevanti relative alle API sottostanti come WaitHandle.WaitAll (che supporta solo i thread MTA quando al metodo vengono forniti più handle di attesa).

Pensavo che ciò significherebbe che le parti di blocco di TPL userebbero un’API di attesa che pompa messaggi, come CoWaitForMultipleHandles , per evitare situazioni di deadlock quando viene chiamato su un thread STA.

Nella mia situazione, credo che stia succedendo quanto segue: l’object COM STA in-proc A effettua una chiamata all’object out-of-proc B, quindi si aspetta una richiamata da B tramite come parte della chiamata in uscita.

In una forma semplificata:

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

Il problema è che a.Method(b) non ritorna mai. Per quanto posso dire, ciò accade perché un’attesa di blocco da qualche parte all’interno di BlockingCollection non pompa i messaggi, quindi la mia ipotesi sulla dichiarazione citata è probabilmente sbagliata.

MODIFICATO Lo stesso codice funziona quando viene eseguito sul thread UI dell’applicazione WinForms di test (ovvero, fornendo TaskScheduler.FromCurrentSynchronizationContext() anziché staTaskScheduler a Task.Factory.StartNew ).

Qual è il modo giusto per risolvere questo? Devo implementare un contesto di sincronizzazione personalizzato, che CoWaitForMultipleHandles esplicitamente i messaggi con CoWaitForMultipleHandles e installarlo su ogni thread STA avviato da StaTaskScheduler ?

In tal caso, l’implementazione sottostante di BlockingCollection chiamerà il metodo SynchronizationContext.Wait ? Posso utilizzare SynchronizationContext.WaitHelper per implementare SynchronizationContext.Wait ?


MODIFICATO con un codice che mostra che un thread STA gestito non pompa quando si effettua un’attesa di blocco. Il codice è un’app console completa pronta per il copia / incolla / esecuzione:

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

Questo produce l’output:

 Test senza pompaggio ...
 L'argomento della raccolta è vuoto ed è stato contrassegnato come completo per quanto riguarda le aggiunte.

 Prova con il pompaggio ...
 L'argomento della raccolta è vuoto ed è stato contrassegnato come completo per quanto riguarda le aggiunte.
 Ora inizia a pompare ...
 WM_TEST elaborato
 Premere Invio per uscire

La mia comprensione del problema: si sta utilizzando StaTaskScheduler solo per organizzare il classico appartamento COM STA per gli oggetti COM legacy. Non stai eseguendo un loop di messaggi di core WinForms o WPF sul thread STA di StaTaskScheduler . Cioè, non stai usando nulla come Application.Run , Application.DoEvents o Dispatcher.PushFrame all’interno di quel thread. Correggimi se questo è un presupposto errato.

Di per sé, StaTaskScheduler non installa alcun contesto di sincronizzazione sui thread STA che crea. Pertanto, stai facendo affidamento sul CLR per pompare messaggi per te. Ho trovato solo una conferma implicita che le pompe CLR sui thread STA, in Appartamenti e Pumping nel CLR di Chris Brumme:

Continuo a dire che il blocco gestito eseguirà “un po ‘di pompaggio” quando viene chiamato su un thread STA. Non sarebbe bello sapere esattamente cosa verrà pompato? Sfortunatamente, pompare è un’arte nera che va oltre la comprensione dei mortali. Su Win2000 e versioni successive , deleghiamo semplicemente al servizio CoWaitForMultipleHandles di OLE32.

Ciò indica che CLR utilizza CoWaitForMultipleHandles internamente per i thread STA. Inoltre, il documento MSDN per il flag COWAIT_DISPATCH_WINDOW_MESSAGES menziona questo :

… in STA è solo una piccola serie di messaggi con involucro speciale inviati.

Ho fatto qualche ricerca su questo , ma non ho potuto ottenere il WM_TEST dal tuo codice di esempio con CoWaitForMultipleHandles , ne abbiamo discusso nei commenti alla tua domanda. La mia comprensione è che il suddetto piccolo insieme di messaggi con casing speciale è veramente limitato ad alcuni messaggi specifici del marshaller COM e non include alcun normale messaggio generico come il tuo WM_TEST .

Quindi, per rispondere alla tua domanda:

… Dovrei implementare un contesto di sincronizzazione personalizzato, che pomperebbe esplicitamente i messaggi con CoWaitForMultipleHandles e installarlo su ogni thread STA avviato da StaTaskScheduler?

Sì, credo che la creazione di un contesto di sincronizzazione personalizzato e l’annullamento di SynchronizationContext.Wait sia davvero la soluzione giusta.

Tuttavia, evitare di utilizzare CoWaitForMultipleHandles e utilizzare invece MsgWaitForMultipleObjectsEx . Se MsgWaitForMultipleObjectsEx indica che c’è un messaggio in sospeso nella coda, dovresti pomparlo manualmente con PeekMessage(PM_REMOVE) e DispatchMessage . Quindi dovresti continuare ad aspettare gli handle, tutti all’interno della stessa chiamata SynchronizationContext.Wait .

Nota c’è una sottile ma importante differenza tra MsgWaitForMultipleObjectsEx e MsgWaitForMultipleObjects . Quest’ultimo non ritorna e continua a bloccare, se c’è un messaggio già visto in coda (ad es. Con PeekMessage(PM_NOREMOVE) o GetQueueStatus ), ma non rimosso. Questo non va bene per il pompaggio, perché gli oggetti COM potrebbero usare qualcosa come PeekMessage per ispezionare la coda dei messaggi. Ciò potrebbe in seguito causare il blocco di MsgWaitForMultipleObjects quando non previsto.

OTOH, MsgWaitForMultipleObjectsEx con il flag MWMO_INPUTAVAILABLE non presenta tale mancanza e ritornerebbe in questo caso.

Qualche tempo fa ho creato una versione personalizzata di StaTaskScheduler ( disponibile qui come ThreadAffinityTaskScheduler ) nel tentativo di risolvere un problema diverso : mantenere un pool di thread con affinità di thread per successive continuazioni di await . L’affinità del thread è fondamentale se si utilizzano oggetti COM STA su più awaits . StaTaskScheduler originale mostra questo comportamento solo quando il pool è limitato a 1 thread.

Così sono andato avanti e ho fatto un po ‘di più a sperimentare il tuo caso WM_TEST . Inizialmente, ho installato un’istanza della class SynchronizationContext standard sul thread STA. Il messaggio WM_TEST non è stato pompato, cosa prevista.

Quindi ho sostituito SynchronizationContext.Wait per inoltrarlo semplicemente a SynchronizationContext.WaitHelper . È stato chiamato, ma non ha ancora pompato.

Infine, ho implementato un ciclo di pump dei messaggi completo, ecco la parte centrale di questo:

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

WM_TEST , WM_TEST viene pompato. Di seguito è riportata una versione adattata del 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); } 

L’output :

 Discussione iniziale n. 9
 Sul thread # 10 STA
 Pubblica alcuni messaggi WM_TEST ...
 Premi Invio per continuare ...
 WM_TEST elaborato: 1
 WM_TEST elaborato: 2
 WM_TEST elaborato: 3

 Dopo l'attesa, filetto n. 10
 Messaggi in attesa in coda: Falso
 Chiusura del thread n. 10 STA
 Thread corrente # 12
 premi qualsiasi tasto per uscire 

Nota che questa implementazione supporta sia l’affinità del thread (rimane sul thread # 10 dopo l’ await ) sia il messaggio pumping. Il codice sorgente completo contiene parti riutilizzabili ( ThreadAffinityTaskScheduler e ThreadWithAffinityContext ) ed è disponibile qui come app console autonoma . Non è stato testato a fondo, quindi usalo a tuo rischio.

Il tema del pompaggio del filetto STA è molto ampio, con pochissimi programmatori che hanno un divertente deadlock nel risolvere il tempo. Il documento fondamentale su di esso è stato scritto da Chris Brumme, uno dei principali personaggi intelligenti che ha lavorato su .NET. Lo troverai in questo post del blog . Sfortunatamente è piuttosto corto su specifiche, non va oltre a notare che il CLR fa un po ‘ di pompaggio ma senza dettagli sulle regole esatte.

Il codice di cui sta parlando, aggiunto in .NET 2.0, è presente in una funzione CLR interna denominata MsgWaitHelper (). Il codice sorgente per .NET 2.0 è disponibile tramite la distribuzione SSCLI20. Molto completo, ma la fonte di MsgWaitHelper () non è inclusa. Piuttosto insolito Decompilare è piuttosto una causa persa, è molto grande.

L’unica cosa da togliere al suo post sul blog è il pericolo di re-entrancy . Il pompaggio in un thread STA è pericoloso per la sua capacità di inviare messaggi Windows e ottenere codice arbitrario da eseguire quando il programma non è nello stato corretto per consentire l’esecuzione di tale codice. Qualcosa che la maggior parte dei programmatori VB6 sa quando ha usato DoEvents () per ottenere un loop modale nel suo codice per fermare il congelamento dell’interfaccia utente. Ho scritto un post sui suoi pericoli più tipici. MsgWaitHelper () esegue esattamente questo tipo di pompaggio, tuttavia è molto selettivo su esattamente quale tipo di codice consente di eseguire.

È ansible ottenere alcune informazioni su ciò che fa all’interno del programma di test eseguendo il programma senza un debugger collegato e quindi allegando un debugger non gestito. Lo vedrai bloccare su NtWaitForMultipleObjects (). Ho fatto un ulteriore passo avanti e ho impostato un breakpoint su PeekMessageW (), per ottenere questa traccia dello stack:

 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 

Attenzione che ho registrato questa traccia dello stack su Windows 8.1, sembrerà piuttosto diversa nelle vecchie versioni di Windows. Il ciclo modale COM è stato pesantemente modificato con Windows 8, ed è anche un grosso problema per i programmi WinRT. Non ne so molto, ma sembra avere un altro modello di threading STA denominato ASTA che esegue un tipo di restrizione più restrittivo, racchiuso nell’aggiunta di CoWaitForMultipleObjects ()

ObjectNative :: WaitTimeout () è dove il SemaphoreSlim.Wait () all’interno del metodo BlockingCollection.Take () inizia l’esecuzione del codice CLR. Lo vedi scorrere attraverso i livelli del codice CLR interno per arrivare alla mitica funzione MsgWaitHelper (), passando poi al famigerato ciclo modal dispatcher di COM.

Il segnale del pipistrello che indica il tipo “sbagliato” di pompaggio nel programma è la chiamata al metodo CliModalLoop :: PeekRPCAndDDEMessage (). In altre parole, è solo considerando il tipo di messaggi di interoperabilità che vengono pubblicati in una finestra interna specifica che invia le chiamate COM che attraversano un confine di un appartamento. Non pompa i messaggi che si trovano nella coda dei messaggi per la propria finestra.

Questo è un comportamento comprensibile, Windows può essere assolutamente sicuro che re-entrancy non ucciderà il tuo programma quando vedrà che il thread dell’interfaccia utente è inattivo . È inattivo quando pompa il ciclo del messaggio stesso, una chiamata a PeekMessage () o GetMessage () indica quello stato. Il problema è che non ti pompi. Hai violato il contratto principale di un thread STA, deve pompare il loop dei messaggi. Sperando che il ciclo modale COM faccia il pompaggio per te è quindi una speranza inutile.

Puoi effettivamente sistemarlo, anche se non ti consiglio di farlo. Il CLR lascerà all’applicazione stessa l’esecuzione di un object SynchronizationContext.Current opportunamente costruito. È ansible crearne uno derivando la propria class e sovrascrivere il metodo Wait (). Chiama il metodo SetWaitNotificationRequired () per convincere il CLR che dovrebbe lasciarlo a te. Una versione incompleta che dimostra l’approccio:

 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 installalo all’inizio del tuo thread:

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

Ora vedrai il tuo messaggio WM_TEST essere inviato. È la chiamata a Application.DoEvents () che lo ha inviato. Avrei potuto coprirlo usando PeekMessage + DispatchMessage ma questo avrebbe offuscato il pericolo di questo codice, meglio non attaccare DoEvents () sotto la tabella. Stai giocando davvero un gioco di re-entrancy molto pericoloso qui. Non usare questo codice.

Per farla breve, l’unica speranza di utilizzare correttamente StaThreadScheduler è quando viene utilizzato nel codice che ha già implementato il contratto STA e le pompe come dovrebbe fare un thread STA. Era davvero inteso come un cerotto per il vecchio codice in cui non si ha il lusso di controllare lo stato del thread. Come qualsiasi codice che ha iniziato la vita in un programma VB6 o in un componente aggiuntivo di Office. Sperimentando un po ‘con esso, non penso che in realtà possa funzionare. Notevole è anche che la necessità di esso dovrebbe essere completamente eliminata con la disponibilità di asych / attese.