Compartilhar via


Applicazioni multi-threading: accediamo ai controlli in modo sicuro

Probabilmente vi sarà capitato, anche in ambiti diversi di sviluppo, di osservare che operazioni relativamente banali di accesso a controlli della GUI -l’interfaccia utente- da parte di thread diversi dal principale producono situazioni UNSAFE quali race condition o dedalock.

In questo articolo, corredato di screencast e progetto di esempio, analizzerò la relazione fra Thread e Controlli dell’interfaccia utente; argomento, questo, indispensabile da conoscere per chiunque sviluppi applicazioni multithreading e quindi tocchi temi quali asincronia e parallelismo, ma background fondamentale anche per qualunque sviluppatore Windows.

Questo l’assioma: solamente il thread che crea un controllo dell’interfaccia utente ha diritto ad utilizzarlo, sia in scrittura che in lettura. Quindi gli sono vietate operazioni anche relativamente banali quali impostare o anche solo leggere lo stato di un controllo, aggiungere un elemento ad una listbox e così via.

Vediamo un esempio pratico: partiamo da un’applicazione Windows Forms che contiene una listbox e un bottone; premendo il bottone, supponiamo che venga creato un nuovo trhead e che il thread principale rimanga in attesa che il secondo si completi (metodo Wait, o Join in .NET); infine immaginiamo che il thread così creato, su cui è in attesa il thread principale, esegua un’operazione apparentemente innocua come aggiungere un elemento alla listbox:

Post_01

Ora, come sappiamo, quando si richiede questo effetto sotto Windows il sistema operativo invierà una SendMessage alla listbox o alla sua finestra parent (la Form, in questo caso); è noto anche che la finestra è stata creata dal thread principale, nel quale “gira” la pompa dei messaggi che riceve e distribuisce tutti i messaggi destinati alla finestra principale e alle sua finestre figlie, fra cui la listbox.

Il problema però è che il trhead principale è in attesa della terminazione del thread secondario da cui è partita la richiesta di aggiunta di un elemento alla listbox. Benvenuti nel mondo dei deadlock.

Da notare che da queste situazioni si esce solamente “killando” brutalmente il processo. Sotto Visual Studio, se eseguiamo l’applicazione in modalità di debug, verremo notificati del problema dall’ambiente stesso:

Post_02

Questo il problema. Ora vediamo le possibili soluzioni: ce ne sono almeno tre.

La prima soluzione –la più semplice- consiste nell’evitare di toccare la GUI da un thread diverso dal principale, eventualmente condividendo da e verso il thread principale determinate informazioni o richieste.

La seconda soluzione è più complessa e prevede tre passi:

  1. aggiungere un oggetto delegate alla classe che rappresenta l’oggetto di interfaccia (come la listbox di prima, oppure la Form che è la sua finestra padre)
  2. “incapsulare” il metodo che accede all’interfaccia nell’istanza del delegate
  3. -il terzo passo è il più significativo- si richiama il delegate dal thread secondario attraverso il metodo Invoke. La Invoke infatti esegue il metodo incapsulato nel delegate che le si passa all’interno del thread che ha creato l’oggetto cui il delegate appartiene.

 

C’è infine una terza soluzione che funziona esclusivamente in ambito .NET e permette di risolvere il problema in modo ancora più semplice. La soluzione consiste nell’utilizzare la classe BackgroundWorker.

La classe BackgroundWorker consente di isolare un pezzo di codice all’interno di un evento chiamato DoWork perché venga eseguito in un thread separato e dedicato lanciato attraverso il metodo RunWorkerAsync.

Anche in questo caso quindi bisogna stare attenti ad evitare di accedere ai controlli dall’interno di DoWork, tuttavia dall’interno di DoWork è possibile utilizzare il metodo ReportProgress che fa scattare un evento (che si chiama ProgressChanged) il quale viene lanciato nel contesto del thread principale.

Per comprendere ancora meglio questi principi vi invito a guardare lo screencast che segue e a scaricare la demo sviluppata da zero durante la stessa registrazione.

mauro

Comments

  • Anonymous
    September 06, 2009
    Gran bell'articolo! La (o le) soluzione al problema di accedere alla UI da thread esterni è fondamentale per scorporare la gestione dei dati dalla UI. Quello che mi perplime nell'uso di BackgroundWorker è che sembra sia pensato esplicitamente per aggiornare il progresso della Progressbar, che essendo un componente UI, doveva essere accessibile da un'altro thread. Cioè se non avessi progress bar che senso avrebbe inserire dati ad esempio in una combobox tenendo conto del progresso? Senza dubbio è una classe comoda, ma preferirei lavorare ad una soluzione custom con i delegate e invoke, per renderla più conforme ad esigenze di "General-UI-access" e non solo "Progressbar-oriented". Proverò a mettere su una soluzione alternativa a BW sul mio blog ( http://blogs.academicclub.org/codezero/?p=152 )...stay tuned ;)

  • Anonymous
    September 06, 2009
    Scusate, non si capisce bene il soggetto a cui si riferisce la frase "Quindi gli sono vietate operazioni anche relativamente banali..." E' possibile esplicitarlo meglio? Grazie.

  • Anonymous
    September 06, 2009
    Ciao Federico, significa che al thread secondario è vietato accedere con una chiamata diretta (es. TextBox1.Text) ai controlli dell'interfaccia utente, anche solamente per interrogarne lo stato. mauro

  • Anonymous
    September 10, 2009
    Devo fare una precisazione doverosa dato che la parte finale dell'articolo non è esaustiva (vedasi primo commento). Per quanto riguarda la classe BackgroundWorker l'aggiornamento della UI deve essere fatto nell'apposito evento RunWorkerCompleted.Tale evento infatti viene eseguito sul thread principale. L'evento ReportProgress è fatto proprio per aggiornare l'avanzamento dell'operazione. La semantica delle operazioni è stata implementata correttamente.

  • Anonymous
    September 10, 2009
    L'aggiornamento della UI può (non "deve") essere fatto in RunWorkerCompleted, ma a quel punto non risolve più il problema di accedere ai controlli durante l'esecuzione di un thread secondario. L'alternava è usare ProgressChanged che, come RunWorkerCompleted, gira nel thread principale e tipicamente si usa per aggiornare l'interfaccia utente sullo stato di avanzamento del thread durante l'esecuzione dello stesso, come faccio nello screencast quando scrivo la stringa "siamo a metà lavoro" nella TextBox. ReportProgress è un metodo (non un evento) e serve a far scattare l'evento ProgressChanged. Nell'ultima parte dello screencast ho mostrato come usare entrambi gli eventi. Personalmente sono d'accordo con Davide -ottimo il suo post-: anche se BW risolve pure il problema anch'io preferisco usare gli asynchronous delegate.

  • Anonymous
    September 10, 2009
    Pur apprezzando l'ottimo articolo di Dave non sono affatto d'accordo sulla sua asserzione che il  BackgroundWorker sia pensato esplicitamente per aggiornare la Progressbar, o similia. Ogni "lavoro" ha insito il concetto di "progresso" e l'uso BackgroundWorker è conforme ad ogni esigenza di "General-UI-access". A tal proposito vi racconto l'uso che ne ho fatto in un progetto passato: dovevo caricare delle foto da una cartella selezionata dall'utente e presentare in una list della Form principale le anteprime delle stesse; la cartella poteva contenere migliaia di foto e la generazione dell'anteprima di una foto non è un'operazione velocissima e, quindi, necessitava essere svolta in un  trhead a parte. Ho risolto con il BackgroundWorker e, in questo caso, il "progresso" era la generazione di una thumbnail con successiva aggiunta nella lista sulla Form (e non c'era neppura una Progressbar!).

  • Anonymous
    September 10, 2009
    Un buon esempio, Enzo. A sostegno del fatto che l'update della GUI deve avvenire da ProgressChanged e non da RunWorkerCompleted. Me ne vengono in mente altri: un analizzatore ortografico che corregga le parole di un testo mentre l'utente scrive, una coda di stampa, un sistema di processo ordini in background. Naturalmente tutti risolvibili anche con Invoke & delegate, che ho indicato nella seconda soluzione del post. In tutti quest i casi, l'uso di una progressbar sebbene non fondamentale sarebbe comunque utile per indicare all'utente lo stato di avanzamento del lavoro lanciato in background. ...e anche se non si usa l'informazione, siamo comunque costretti a specificare lo stato di completamento nella chiamata a ReportProgress, che ha un nome decisamente esplicativo. Se il metodo si fosse chiamato RunOnGuiThread() probabilmente Dave non avrebbe fatto la stessa osservazione :-)

  • Anonymous
    September 12, 2009
    In effetti è il fatto di dover obbligatoriamente passare un parametro che aggiorna il progresso che mi incuriosisce. Il passaggio di un parametro indica che è pensato appositamente per eseguire delle operazioni specifiche. Potrei pensare di passare sempre "zero" come aggiornamento e ignorarlo, ma sarebbe uno spreco di memoria. Qualche anno fa non ci avrei pensato su molto, ma questa fissazione per l'ottimizzazione delle risorse un po' è derivata dalla collaborazione con G Magg. Se ad esempio prendete questo articolo su XNA che abbiamo scritto http://blogs.academicclub.org/xna/?m=200902, noterete che ci sono due ottimizzazioni di cui una fondamentale:

  • il "serpente" è 2D e ci vogliono entrambe le coord X,Y per rappresentare i punti, ma la funzione sen( X )  poteva rappresentare da sola la forma e quindi la Y anzichè buttarla, la abbiamo sfruttata per immagazzinare dati di illuminazione. Sostanzialmente ad ogni refresh (circa 60/sec) ci evitiamo il calcolo di un intero da 32 bit, significa 32*60 = 1920 calcoli in meno al sec, e se pensiamo che siamo in real-time (così come nell'aggiornamento della UI), ciò va sicuramente a vantaggio dell'applicazione, con 115200 operazioni in meno al minuto ;) Dave
  • Anonymous
    December 20, 2009
    Il solo controllo ad InvokeRequired è sufficiente? In genere come descritto nella documentazione MSDN (http://msdn.microsoft.com/en-us/library/system.windows.forms.control.invokerequired.aspx) "..In the case where the control's handle has not yet been created, you should not simply call properties, methods, or events on the control. This might cause the control's handle to be created on the background thread, isolating the control on a thread without a message pump and making the application unstable..." controllo sempre se l'handle del controllo è stato creato e il controllo non distrutto. Il codice che uso per chiamare un metodo su un altro thread è: if (uiElement.InvokeRequired) {    uiElement.Invoke(actionToInvoke); } else {    if (uiElement.IsDisposed)    {        throw new ObjectDisposedException("Control is already disposed.");    }    if (uiElement.IsHandleCreated)    {        actionToInvoke();    } } dove uiElement è un Control e actionToInvoke è un Action, tutti queste verifiche sono forse superflue?