WPF Dispatcher

Nello sviluppo di applicazioni WPF può capitare di dover scontrarsi con operazioni onerose dal punto di vista del tempo di esecuzione. Ad esempio, popolare una ListView con migliaia di record, potrebbe risultare in un rallentamento (se non nel blocco temporaneo) dell’interfaccia, degradando l’esperienza utente. La soluzione usata solitamente, è quella di creare un nuovo thread dove eseguire le operazioni “pesanti”, nel nostro esempio procurarsi da qualche sorgente i record e popolare la ListView. Questa operazione non è del tutto banale: in questo articolo si parlerà del Dispatcher, definendo quali funzionalità espone per risolvere i problemi che si possono incontrare nello scenario sopra descritto.

Il Threading Model di WPF

All’inizio del suo ciclo di vita, ogni applicazione WPF viene eseguita con due thread:

  • Rendering Thread: è un thread eseguito in background e quindi nascosto.
  • UI Thread: è il thread che riceve gli input, gestisce gli eventi e si occupa di eseguire il codice dell’applicazione.

Quindi l’UI Thread è, tipicamente, responsabile dell’esecuzione del codice “code-behind” delle nostre applicazione. Inoltre, la maggior parte degli oggetti WPF (per esempio i controlli) sono strettamente legati a questo thread, ossia è possibile accedere direttamente a questi oggetti solo dal thread che li ha generati. Tentando di accedere ad un oggetto da un thread diverso da quello che ha generato l’oggetto stesso, verrà sollevata una System.InvalidOperationException.

Il modo corretto di manipolare gli oggetti in questo scenario è utilizzando il Dispatcher. Questo oggetto si occupa di gestire una coda a priorità di operazioni inviategli e quindi di eseguire tali istruzioni in sequenza, tenendo conto delle priorità delle singole operazioni, in modalità FIFO.

Utilizzo del Dispatcher

Vediamo ora un caso pratico, partendo dal percorso che potrebbe essere seguito qualora si affrontasse il problema per la prima volta. Diciamo di voler popolare una ListBox con 100000 elementi. Non ci occuperemo di come questi elementi vengano recuperati: potrebbero essere caricati da un file o essere il risultato di una query eseguita su un DB.

Passo 1: utilizzo di un singolo thread

Come primo approccio uno sviluppatore potrebbe decidere di eseguire l’operazione di popolamento della ListBox utilizzando un solo thread (quindi l’UI Thread di default). Diciamo che il caricamento inizi alla pressione di un Button. Questo potrebbe essere il codice relativo all’evento Button_Click:

private void Button_Click(object sender, RoutedEventArgs e){
    //Eseguo il clear della ListBox
    myListBox.Items.Clear();

    // Popolo la ListBox con 100000 items
    for (int i = 0; i < 100000; i++)
      myListBox.Items.Add("Item " + i);
}

Eseguendo il programma, ci accorgeremo subito di una cosa: cliccando il Button, l’interfaccia si blocca completamente e l’applicazione diventerà non responsiva per un numero variabile di secondi (questo a seconda della potenza della vostra macchina). Questo è dovuto al fatto che tutte le operazioni “UI” sono eseguite su un singolo UI Thread. Le operazioni che necessitano di lunghi tempi di esecuzione, bloccano le altre operazioni causando il blocco dell’aggiornamento dell’interfaccia, che non risponde inoltre agli input dell’utente.

Passo 2: aggiunta di un nuovo thread

Per evitare che le operazioni “long-running” blocchino la UI, si può pensare di creare un nuovo thread ed eseguire le notre 100000 iterazioni nel nuovo thread. Il codice potrebbe essere qualcosa del genere:

private void Button_Click(object sender, RoutedEventArgs e){
    //Eseguo il clear della ListBox
    myListBox.Items.Clear();

    //Dichiaro e avvio un nuovo thread per popolare la ListBox
    ThreadStart ts = new ThreadStart(()=>
    {
        // Popolo la ListBox con 100000 items
        for(int i = 0 ; i < 100000 ; i++)
            myListBox.Items.Add("Item " + i);
    });

    //Avvio il thread
    Thread myThread = new Thread(ts);
    myThread.Start();
}

Avviamo il programma. Cliccando sul Button, questa volta, ci aspetteremo di aver risolto il problema e di veder eseguito il comportamento desiderato. Invece una volta eseguito i click, il sistema solleverà un eccezione di tipo System.InvalidOperationException. Questo perché, in accordo con il sistema di threading di WPF, non possiamo accedere direttamente ad un’oggetto (in questo caso la ListBox) da un thread diverso da quello in cui l’oggetto è stato creato.

Passo 3: utilizziamo il Dispatcher

Per rispettare le regole del modello, utilizziamo l’oggetto Dispatcher dell’UI Thread. Delegheremo quindi le operazioni di popolamento della ListBox a questo oggetto, che gestirà la coda delle nostre richieste come spiegato in precedenza.

private void Button_Click(object sender, RoutedEventArgs e){
    //Eseguo il clear della ListBox
    myListBox.Items.Clear();

    //Dichiaro e avvio un nuovo thread per popolare la ListBox
    ThreadStart ts = new ThreadStart(()=>
    {
        // Popolo la ListBox con 100000 items
        for(int i = 0 ; i < 100000 ; i++){

            Dispatcher.Invoke(DispatcherPriority.Normal, new Action(()=>
            {
                myListBox.Items.Add("Item " + i);
            }));
    });

    //Avvio il thread
    Thread myThread = new Thread(ts);
    myThread.Start();
}

Il codice Dispatcher.Invoke(…) inserisce l’istruzione myListBox.Items.Add(…) nella coda del Dispatcher, per ogni iterazione del ciclo for. Il resto viene gestito dall’oggetto Dispatcher che prenderà in considerazione anche le priorità degli elementi in coda.

Note importanti

Va sottolineato il fatto che esiste un solo Dispatcher per ogni UI Thread. Inoltre, ogni oggetto in WPF possiede un riferimento a quest’unico Dispatcher, relativo al thread che ha creato l’oggetto, attraverso la proprietà Dispatcher. Ne consegue che, invece di scrivere:


Dispatcher.Invoke(DispatcherPriority.Normal, new Action(()=>
{
    myListBox.Items.Add("Item " + i);
}));

potremmo scrivere equivalentemente:


myListBox.Dispatcher.Invoke(DispatcherPriority.Normal, new Action(()=>
{
    myListBox.Items.Add("Item " + i);
}));

perchè entrambe le proprietà (Dispatcher della classe Windows e Dispatcher della classe ListBox) contengono un riferimento alla stessa singola istanza del Dispatcher dell’UI Thread, creata quando lo UI Thread è stato avviato.
Va notato anche che il tempo totale di esecuzione del popolamento della ListBox potrebbe essere superiore rispetto alla prima soluzione, in cui utilizziamo il singolo thread. Questo perchè il metodo Invoke del Dispatcher introduce del tempo di attesa, considerato che ogni iterazione deve essere accodata e poi gestita dal Dispatcher. Tuttavia dal punto di vista dell’utente, l’applicazione viene percepita come responsiva e fluente.