Rientranza delle chiamate in WF
In questi giorni, a seguito di una consulenza su WF, ho potutoverificare in dettaglio alcuni aspetti relativi alla gestione dei thread e delle code in WF. Il motore del WF Runtime utilizza, per default, uno scheduler (DefaultWorkflowSchedulerService) che fornisce al runtime, come impostazione predefinita, un pool di 5 thread, nel caso di macchine mono-processore, oppure un pool di 4 thread per ciascun processore, nel caso di macchine multi-processore. Si tratta di impostazioni modificabili, ma questi numeri hanno un senso, soprattutto rispetto alla scalabilità delle applicazioni server-side, quindi non alzateli "a manetta" perché potrebbe dare effetti peggiori. Ricordate sempre che i thread non sono infiniti e se c'è in mezzo ASP.NET, già lui utilizza il ThreadPool di .NET.
Ciascuna istanza di workflow è eseguita da uno e uno solo di questi thread. Significa che non c'è la possibilità, se non cambiando lo scheduler di default, di eseguire passi di un singolo workflow in parallelo. Se vi state chiedendo come sia possibile allora avere una activity di tipo ParallelActivity o di tipo ListenActivity in un workflow ... ecco una brillante ed esaustiva risposta da parte del team di WF. In generale vi consiglio di leggere e monitorare l'intero blog.
Ora provate ad immaginare un workflow che utilizza una CallExternalMethodActivity per chiamare l'host e il codice dell'host, relativo alla CallExternalMethodActivity, che a sua volta utilizza un evento per richiamare il workflow, che ha una HandleExternalEventActivity definita.
Pensiamo al caso sequenziale:
Vi ricordo che il thread che esegue il workflow invoca l'host senza preoccuparsi della thread safety, quindi può chiamare ad esempio un'applicazione Windows Forms che faccia da host, senza utilizzare il thread di UI. Per questa ragione dobbiamo sempre preoccuparci di verificare se siamo o meno sul thread di UI prima di utilizzare eventuali informazioni ottenute dal workflow.
Pensate quindi che si crei un deadlock? Il workflow chiama l'host, l'host chiama il workflow, ma il workflow sta aspettando il "rientro" della chiamata verso l'host. Ovviamente no! La richiesta di far scattare un evento nel workflow, sbloccando la HandleExternalEventActivity, è intercettata dall'ExchangeDataService (servizio di WF Runtime preposto a gestire la comunicazione tra l'host e il workflow) ed è accodata su una coda che verrà gestita solo nel momento in cui il workflow sarà "pronto". In pratica mettendo un po' di tracing nel codice avremo una sequenza di questo tipo:
- Pre-Method Invoke - Thread Id: 6 - Physical Thread Id 1128
- Method Invoking - Thread Id: 6 - Physical Thread Id 1128
- Method Invoked - Thread Id: 6 - Physical Thread Id 1128
- Event Invoking - Thread Id: 6 - Physical Thread Id 1128
- OnTestEvent Invoked - Thread Id: 6 - Physical Thread Id 1128
- Post-Method Invoke - Thread Id: 6 - Physical Thread Id 1128
- Event Invoked - Thread Id: 6 - Physical Thread Id 1128
- Post-Event Invoke - Thread Id: 6 - Physical Thread Id 1128
In rosso sono i trace da dentro il workflow, mentre in blu i trace nell'host. Notate che tutte le righe sono relative allo stesso thread e che l'evento scatta (penultima riga) nel workflow, solo dopo che l'esecuzione del metodo dell'host è stata completata.
Questo comportamento "sincrono" è appunto dovuto al fatto che lo scheduler utilizza un unico thread per l'esecuzione del workflow.
Vediamo ora che succede con un workflow a stati, anziché sequenziale. Immaginiamo di avere il seguente flusso a stati:
La StateInitializationActivity dello stato iniziale si limita, per brevità, a rimandare il flusso nello stato successivo.
Da qui la StateInitializationActivity dello stato "IntermediateState" chiama l'host del flusso con una CallExternalMethodActivity, oltre a tracciare alcune informazioni a fini di debug.
Da qui il codice che viene invocato nell'host richiama il flusso, come spiegavo all'inizio del post. L'evento richiamato dall'host è proprio quello per cui sta in attesa l'evento eventDrivenActivity1 all'interno dello stato "IntermediateState".
Qui arriva il bello! Ci aspetteremmo un comportamento analogo a quello del flusso sequenziale: la coda prende in carico l'evento e quando il controllo torna al flusso la gestione riprende, proprio dall'evento. Invece no! Siamo nello fase di inizializzazione dello stato "IntermediateState" e le code dei suoi eventi non sono ancora state attivate, perchè potrebbe essere prematuro ricevere eventi senza che lo stato sia completamente inizializzato. Infatti in questo caso ecco il tracing estratto dal log.
- Pre-Method Invoking - Thread Id: 7 - Physical Thread Id 4964
- Method Invoking - Thread Id: 7 - Physical Thread Id 4964
- Method Invoked - Thread Id: 7 - Physical Thread Id 4964
- Event Invoking - Thread Id: 7 - Physical Thread Id 4964
- OnTestEvent Invoked - Thread Id: 7 - Physical Thread Id 4964
- A first chance exception of type 'System.Workflow.Activities.EventDeliveryFailedException' occurred in System.Workflow.Activities.dll
Exception - Thread Id: 7 - Physical Thread Id 4964
Event "TestEvent" on interface type "Test_WF_Events.ICommunication" for instance id "53b2d8c4-2cd0-4f66-a6b8-07b9b963332b" cannot be delivered.
A first chance exception of type 'System.Workflow.Activities.EventDeliveryFailedException' occurred in Use-Test-WF-Events.exe
Al solito il log rosso proviene dal workflow, mentre il log blu riguarda l'host. Come si vede ci prendiamo un bel EventDeliveryFailedException!
La InnerException ci spiega che si tratta in realtà di un errore di comunicazione perché l'evento TestEvent non può essere notificato (cannot be delivered).
Cercando con Google potreste trovare suggerimenti che consigliano di utilizzare un bel QueueUserWorkItem del ThreadPool per far scattare l'evento nel workflow. In pratica:
ThreadPool.QueueUserWorkItem(delegate(Object args) { OnTestEvent((ExternalDataEventArgs)args); }, e);
invece di:
OnTestEvent(e);
A prima vista sembrerebbe corretto, perché accodando l'invocazione dell'evento sul ThreadPool diamo un po' di "respiro" al thread che sta eseguendo l'istanza di workflow, così da consentirgli di uscire dall'host e rientrare nel flusso. Purtroppo però si tratta di una soluzione non corretta! Infatti mettere la notifica dell'evento nel ThreadPool sposta solo il problema. In alcuni casi infatti tutto funzionerà a meraviglia, in altri avremo comunque un errore, ma di natura diversa. Dipende dal carico della CPU o delle CPU. Infatti su sistemi multiprocessore il problema si enfatizza, perché abbiamo più cicli macchina a disposizione ed è più facile che il ThreadPool evada la richiesta di notifica dell'evento _prima_ che il thread rientri nel workflow. Per verificare sistematicamente il problema è sufficiente mettere una bella Thread.Sleep dopo l'accodamento dell'evento nel ThreadPool, per prolungare la permanenza del thread del workflow nel codice host.
ThreadPool.QueueUserWorkItem(delegate(Object args) { OnTestEvent((ExternalDataEventArgs)args); }, e);
Thread.Sleep(TimeSpan.FromSeconds(15));
Eccoci! A questo punto abbiamo creato un mostro :-) ! Ad ogni giro il workflow ci dirà che non è in grado di evadere l'evento e capiremo che la vera ragione è che le code sono appunto "disabled" come spiegavo alcune righe più sopra. Ecco l'errore:
Queue 'Message Properties Interface Type:Test_WF_Events.ICommunication Method Name:TestEvent CorrelationValues:' is not enabled.
Il ragionamento fila. Infatti se guardiamo con .NET Reflector possiamo notare che ad un certo punto il metodo SafeEnqueueEvent della classe WorkflowQueuingService verifica se le code sono enabled e se non lo sono scatena proprio questo tipo di eccezione.
// [... omissis ...]
if (!queueState.Enabled)
{
throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, ExecutionStringManager.QueueNotEnabled, new object[] { queueName }));
}
queueState.Messages.Enqueue(item);
// [... omissis ...]
Come fare quindi? Possiamo osservare che tutti gli eventi che scattano dall'host verso il workflow richiedono un EventArgs particolare, di tipo ExternalDataEventArgs o da esso derivato. Questa classe prevede una proprietà WaitForIdle che fa proprio al caso nostro. Infatti sempre con .NET Reflector possiamo notare che l'evento EventHandler della classe WorkflowMessageHandler al suo interno verifica questa proprietà:
// [... omissis ...]
if (eventArgs.WaitForIdle)
{
wfInstance.EnqueueItemOnIdle(queueName, message, pendingWork, obj);
}
else
{
wfInstance.EnqueueItem(queueName, message, pendingWork, obj);
}
// [... omissis ...]
Nel caso in cui sia asserita, anziché evadere subito l'evento, lo accoderà in attesa che il flusso diventi Idle. In questo modo il flusso avrà tutto il tempo di concludere la chiamata all'host, qualunque sia il tempo richiesto, per poi passare la palla all'evento. Ecco il log delle fasi, nel caso in cui impostiamo correttamente il flag WaitOnIdle a true.
- Pre-Method Invoking - Thread Id: 10 - Physical Thread Id 3248
- Method Invoking - Thread Id: 10 - Physical Thread Id 3248
- Method Invoked - Thread Id: 10 - Physical Thread Id 3248
- Event Invoking - Thread Id: 10 - Physical Thread Id 3248
- OnTestEvent Invoked - Thread Id: 12 - Physical Thread Id 3176
- Post-Method Invoked - Thread Id: 10 - Physical Thread Id 3248
- Event Invoked - Thread Id: 12 - Physical Thread Id 3176
- Post-Event Invoked - Thread Id: 12 - Physical Thread Id 3176
Notate che in questo caso c'è il cambio di thread (a causa dell'accodamento nel ThreadPool) e che il giro si chiude correttamente. Rimane comunque consigliabile accodare nel ThreadPool l'evento di callback per non bloccare il codice chiamante, anche perché senza questo passaggio avremmo comunque un errore di delivery verso il nostro evento, in quanto andremmo a creare un deadlock. Infatti la situazione diventerebbe la seguente:
- Il thread corrente accoda un evento, in sincrono, che aspetta che il flusso sia Idle prima di scattare
- Il flusso, con quello stesso thread, sta aspettando che il metodo che esegue il codice precedente finisca, per poi completare l'inizializzazione dello stato e mettersi Idle
Risultato: deadlock! Quindi avremmo una EventDeliveryFailedException.
Ok quindi in questi casi occorre:
- Definire WaitOnIdle = true
- Chiamare l'evento in un thread secondario, ad esempio usando il ThreadPool
Nessuno dei due step precedenti risolve, da solo, il problema. Servono entrambi.
L'unica altra alternativa che abbiamo, ma non cambia il risultato finale, è definire nello stato "IntermediateState" un evento ad inizio immediato, tramite una DelayActivity con TimeoutDuration impostata a 0 (zero), anziché una StateInitializationActivity. In questo modo infatti le code del flusso, relative agli eventi dello stato, verrebbero attivate prima della chiamata all'host. In questo caso non è strettamente necessario impostare WaitOnIdle a true perché comunque il thread che evade i messaggi in coda è uno solo, quindi comunque dovremmo aspettare la fine del primo evento per poter proseguire.
Ecco lo schema dell'ultimo caso.


Direi comunque che la soluzione più idonea è quella di utilizzare il ThreadPool o comunque un thread secondario e il WaitOnIdle impostato a true, sfruttando - se serve - lo StateInitialization.
Per chi volesse "divertirsi" con queste cose rendo disponibile il progettino "grezzo" che ho usato per approfondire l'argomento. C'è da giocare un po' con i commenti nel codice per abilitare e verificare il comportamento di WF nei vari casi... se non torna qualcosa potete mandarmi una email.
Spero di aver dato una fotografia della situazione utile ad altri e vi rimando alle sessioni su WF che terrò alla nostra DevCon 2007 per ulteriori dettagli sul funzionamento di Windows Workflow Foundation, anche in progetti reali.