Come utilizzare i thread in C#

1. Introduzione

Quando richiediamo l’esecuzione di un programma, il sistema operativo crea un’istanza di un particolare oggetto del kernel chiamato process, a cui assegna un ben definito (e isolato) spazio di indirizzamento in memoria.

Un processo, di per sé, non è in grado di eseguire alcun codice e svolge il compito di puro e semplice contenitore di quelle che potremmo definire come le entità funzionali elementari del sistema operativo: i thread.

Ogni processo dispone di almeno un thread, chiamato primary thread, al termine del quale esso viene distrutto, liberando lo spazio di memoria e le risorse a esso assegnate.

Il normale ciclo di vita di un’applicazione consiste nell’esecuzione sequenziale di numerosi blocchi di codice richiamati dal thread principale, che a loro volta possono richiamare ulteriori blocchi di codice.

Quando questo avviene, il metodo chiamante risulta ovviamente bloccato fintanto che la routine invocata non giunge al termine: si tratta di un operazione non sempre percorribile in quanto appesantirebbe notevolmente l’utilizzo delle applicazioni.

Fortunatamente ogni thread ha la possibilità di assegnare ad un thread secondario l’esecuzione di un metodo; in questo caso, la chiamata a quest’ultimo ritorna immediatamente il controllo al thread chiamante e i due blocchi di codice possono effettivamente essere eseguiti in parallelo.

2. Esempio applicativo

La classe System.Threading.Thread viene usata per lavorare con i thread.

Quando un programma viene lanciato, viene creato automaticamente il corrispettivo thread principale, mentre i thread che vengono creati dallo sviluppatore tramite la classe Thread sono chiamati thread figli.

Nell’esempio riportato di seguito, viene illustrato come creare un thread parallelo ausiliario, da utilizzare per l’elaborazione in parallelo con il thread primario.

Vogliamo creare una classe chiamata Worker che contiene il metodo DoWork che verrà eseguito dal nostro thread di lavoro. Questo è essenzialmente la funzione Main del thread.

Quando verrà eseguito, il thread di lavoro chiamerà questo metodo e terminerà automaticamente quando il metodo verrà restituito.

public void DoWork()
{
    while (!_shouldStop)
    {
        Console.WriteLine("worker thread: working...");
    }
    Console.WriteLine("worker thread: terminating gracefully.");
}

La classe Worker contiene inoltre un altro metodo utilizzato per indicare che DoWork deve essere restituito, questo metodo, denominato RequestStop, è analogo al seguente

public void RequestStop()
{
    _shouldStop = true;
}

Il metodo RequestStop assegna semplicemente l’attributo _shouldStop a true.

Poiché questo attributo è controllato dal metodo DoWork, si ottiene l’effetto indiretto di causare la restituzione di DoWork terminando in questo modo il thread di lavoro.

È tuttavia importante tenere presente che DoWork e RequestStop verranno eseguiti da thread differenti.

DoWork viene eseguito dal thread di lavoro, mentre RequestStop viene eseguito dal thread primario, quindi l’attributo _shouldStop viene dichiarato volatile, come riportato di seguito

private volatile bool _shouldStop;

La parola chiave volatile avvisa il compilatore che più thread accederanno al membro dati _shouldStop e che pertanto non deve formulare ipotesi di ottimizzazione sullo stato di questo membro.

Il modificatore volatile è utilizzato in genere per un campo al quale accedono più thread senza ricorrere all’istruzione lock per la serializzazione dell’accesso.

L’utilizzo di questo modificatore è così comodo perché _shouldStop è un valore bool. Se tuttavia questo membro dati fosse un oggetto complesso, l’accesso da più thread genererebbe un danneggiamento dei dati.

Prima di creare il thread di lavoro, la funzione Main crea un oggetto Worker e un’istanza di Thread.

L’oggetto thread viene configurato per utilizzare il metodo Worker.DoWork passando al costruttore Thread un riferimento a questo metodo, come riportato di seguito

Worker workerObject = new Worker();
Thread workerThread = new Thread(workerObject.DoWork);

A questo punto, anche se l’oggetto thread esiste ed è configurato, questo non è ancora stato creato, per fare ciò è necessario lanciare il metodo Start

workerThread.Start();

A questo punto il sistema avvia l’esecuzione del thread di lavoro, ma in modo asincrono rispetto al thread primario. La funzione Main continua infatti ad eseguire immediatamente il codice mentre il thread di lavoro viene sottoposto contemporaneamente a inizializzazione.

Per evitare che Main tenti di terminare il thread di lavoro prima che venga eseguito, la funzione Main esegue un ciclo finché la proprietà IsAlive dell’oggetto thread di lavoro non viene impostata su true:

while (!workerThread.IsAlive);

Successivamente il thread primario viene interrotto brevemente con una chiamata a Sleep.

In questo modo la funzione DoWork del thread di lavoro eseguirà il ciclo all’interno del metodo DoWork per alcune iterazioni prima che la funzione Main esegua altri comandi

Thread.Sleep(1);

Trascorso un millisecondo, Main segnala all’oggetto thread di lavoro che deve terminare utilizzando il metodo Worker.RequestStop descritto in precedenza

workerObject.RequestStop();

È inoltre possibile terminare un thread da un altro thread utilizzando una chiamata a Abort. In questo modo il thread interessato viene terminato in modo forzato anche se non ha completato l’attività e non consente la pulitura delle risorse.

Infine la funzione Main chiama il metodo Join sull’oggetto thread di lavoro.
Tramite questo metodo il thread corrente si blocca oppure attende finché non termina il thread rappresentato dall’oggetto.

Pertanto Join non verrà restituito finché non viene terminato, il thread di lavoro

workerThread.Join();

Il metodo Join accetta come parametro anche un timeout, che rappresenta il tempo massimo di attesa al termine del quale proseguire con l’applicazione.

Di seguito è riportato l’esempio completo.

using System;
using System.Threading;

public class Worker
{
    // Questo metodo viene passato a costruttore come delegato
    // alla inizializzazione della classe Thread
    public void DoWork()
    {
        while (!_shouldStop)
        {
            Console.WriteLine("worker thread: working...");
        }
        Console.WriteLine("worker thread: terminating gracefully.");
    }
    public void RequestStop()
    {
        _shouldStop = true;
    }
    // Volatile permette di evitare il lock su attributi booleani
    // che vengono acceduti da più thread
    private volatile bool _shouldStop;
}

public class WorkerThreadExample
{
    static void Main()
    {
        Worker workerObject = new Worker();
        Thread workerThread = new Thread(workerObject.DoWork);

        workerThread.Start();
        Console.WriteLine("main thread: Starting worker thread...");

        // Aspetta fino a che il thread non viene ativato
        while (!workerThread.IsAlive);

        // Aspetto 1ms in modo che il worker faccia qualcosa
        Thread.Sleep(1);

        workerObject.RequestStop();

        // Blocco il thread corrente fino a che il worker
        // non viene terminato con successo
        workerThread.Join();
        Console.WriteLine("main thread: Worker thread has terminated.");
    }
}

L’output di questo programma è il seguent

main thread: starting worker thread...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: terminating gracefully...
main thread: worker thread has terminated

3. Il ThreadPool

Nello sviluppo di un’applicazione multithreading dobbiamo tenere presente che:

  • ogni thread consuma risorse di sistema ed impiega una certa quantità di memoria
  • la creazione e la distruzione di un thread sono, in generale, operazioni onerose

Per limitare questi inconvenienti posso evitare di distruggere un thread ogni volta che questi abbia terminato l’esecuzione di mantenerlo attivo per un certo periodo di tempo in stato idle, in modo che possa essere riutilizzato in seguito.

Il CLR mette a disposizione un contenitore, chiamato ThreadPool, all’interno del quale mantiene la lista di thread attivi e tramite il quale permette di gestire le code dei task che vengono ad esso via via assegnati.

La classe statica ThreadPool espone il metodo QueueUserWorkItem, che accetta un delegate di tipo WaitCallback, tramite il quale possiamo accodare un nuovo task da gestire in parallelo.

Sottolineo che la sincronizzazione dei task eseguiti in questo modo è implicita e non richiede alcun accorgimento di codice: non essendo disponibile alcuna istanza di Thread, non è possibile usare il metodo Join per poterne attendere la conclusione.

Allo scopo, possiamo utilizzare la classe ManualResetEvent.

public class Example {
    public static void Main() {
        // Inserisco il metodo nel ThreadPool,
        // questo viene fatto partire automaticamente
        //senza l'utilizzo di Start,
        // e termina senza l'utilizzo di Join
        ThreadPool.QueueUserWorkItem(new WaitCallback(ThreadProc));

        Console.WriteLine("Main thread does some work, then sleeps.");
        // Inserisco uno Sleep in quanto, altrimenti,
        // il programa (quindi il main thread) terminerebbe,
        // portando ad un abort anche di tutti i suoi thread figli
        Thread.Sleep(1000);

        Console.WriteLine("Main thread exits.");
    }

    // This thread procedure performs the task.

    static void ThreadProc(Object stateInfo) {
        Console.WriteLine("Hello from the thread pool.");
    }
}
// L'output dell'applicazione sarà il seguente:
//       Main thread does some work, then sleeps.
//       Hello from the thread pool.
//       Main thread exits.

4. Modello di programmazione asincrono

Il comando BeginInvoke permette di eseguire delegate in maniera asincrona, in quanto questo ritorna immediatamente il controllo al thread chiamante e parallelamente fa partire il codice del delegato.

Internamente il BeginInvoke utilizza il threadPool del CLR.

Ci sono due fondamentali differenze tra usare questo metodo e le classi Thread o ThreadPool illustrate in precedenza:

  • il passaggio di parametri al metodo asincrono avviene in maniera tipizzata, mentre con Thread o ThreadPool l’eventale parametro è sempre object;
  • i delegate risultano particolarmente** comodi per intercettare il termine dell’elaborazione parallela** e, in particolare, per recuperare il risultato.

Esistono tre diverse modalità per gestire l’esecuzione parallela e recuperarne il risultato, descritte di seguito.

4.1 Utilizzo del metodo EndInvoke

Insieme al metodo BeingInvoke indicato in precedenza, ogni delegato espone anche il metodo EndInvoke che può essere utilizzato per attendere la conslusione dell’operazione asincrona e recuperarne il risultato.
BeginInvoke restituisce un oggetto IAsyncResult che può essere utilizzato per monitorare l’avanzamento della chiamata asincrona ed inoltre viene passato come parametro al metodo EndInvoke.

EndInvoke blocca effetivamente in thread in esecuzione fino al termine dell’operazione asincrona, consentendoci quindi di sincronizzare i due thread.

Qualora il delegate preveda un valore di ritorno, EndInvoke lo restituisce come output.

4.2 Utilizzo di IAsyncResult e polling

Il metodo BeginInvoke restituisce un ogetto che implementa l’interfaccia di tipo IAsyncResult.

La proprietà più importante di questo oggetto è AsyncWaitHandle che restituisce un oggetto di tipo WaitHandle, analogo al ManualResetEvent indicato in precedenza.

Tramite questo metodo è possibile implementare un algoritmo di polling per verificare quando un thread ha concluso il suo lavoro nel seguente modo

while(!asyncResult.isCompleted)
{
  asyncResult.AsyncWaitHandle.WaitOne(200);
}

4.3 Utilizzare un metodo di calback

In alcune occasioni vi è la necessità di evitare che sia il metodo chiamante a gestire il termine del thread; in questo caso possiamo configurare il delegate in modo che esegua un metodo di callback, passando il metodo come secondo parametro al BeginInvoke.

La firma del metodo di callback deve rispettare quella del delagte AsyncCalback, quindi deve ritornare void e prendere come unico parametro in ingresso un oggetto di tipo IAsyncResult

5. Operazioni cross-thread con Windows Form

5.1 Problema

Accedere a dei controlli Windows Forms (quindi andare a eseguire operazioni nel thread di gestione dell’I/O) non è intrinsicamente thread-safe, infatti potrei avere delle inconsistenze nei dati o perfino dei deadlock.
E’ insicuro chiamare un controllo da un thread che non sia colui che ha creato e gestisce il controllo, senza usare il metodo BeginInvoke.
Nell’esempio seguente ho una chiamata non thread safe:

Private Sub setTextUnsafeBtn_Click(ByVal sender As Object, ByVal e As EventArgs) Handles setTextUnsafeBtn.Click
Me.demoThread = New Thread(New ThreadStart(AddressOf Me.ThreadProcUnsafe))
Me.demoThread.Start()
End Sub

' Effettuo una modifica all'I/O da un thread esterno
Private Sub ThreadProcUnsafe()
Me.textBox1.Text = "This text was set unsafely."
End Sub

Il debugger .NET rileva queste situazioni lanciando un eccezione InvalidOperationException con il messaggio:

Control control name accessed from a thread other than the thread it was created on.

5.2 Soluzione

Per chiamare un controllo Windows Form in modo thread-safe devo effettuare i seguenti controlli:

  1. Controllare la proprietà InvokeRequired della classe
  2. Se InvokeRequired ritorna true, chiamare il metodo BeginInvoke con un delegate con la stessa firma del metodo attuale
  3. Se InvokeRequired ritorna false, chiamare il metodo direttamente

Il metodo InvokeRequired compara il thread ID del thread chiamante con il thread ID del thread corrente: se questi thread sono differenti ritorna True.

'Quando clicco un pulsante creo un thread che chiama un controllo Windows Form in modo, questa volta, sicuro
Private Sub setTextUnsafeBtn_Click(ByVal sender As Object, ByVal e As EventArgs) Handles setTextUnsafeBtn.Click
Me.demoThread = New Thread(New ThreadStart(AddressOf Me.ThreadProcUnsafe))
Me.demoThread.Start()
End Sub

' Effettuo una modifica all'I/O da un thread esterno
Private Sub ThreadProcUnsafe()
Me.SetText("This text was set safely.")
End Sub

Delegate Sub SetTextDelegate(text As String)

Private Sub SetText(ByVal text As String)
If Me.textBox1.InvokeRequired Then
Me.BeginInvoke(New SetTextDelegate(AddressOf SetText), New Object() {text})
Else
Me.textBox1.Text = text
End If
End Sub

Se InvokeRequired ritorna True sto quindi facendo una chiamata cross-thread: in questo caso il metodo crea una nuova istanza del delagate che permette di autochiamarsi in modo asincrono dal thread corretto.

6. Tips generici

Questo articolo è una libera traduzione di questo blog post

6.1 I thread condividono le variabili se hanno una reference comune all’istanza dello stesso oggetto

Consideriamo il seguente esempio:

class SomeClass
{
    private bool _isWorkDone;
    static void Main(string[] args)
    {
        SomeClass someClass = new SomeClass();
        // Passso il metodo dell'istanza someClass
        Thread newThread = new Thread(someClass.DoWork);
        newThread.Start();
        // Chiamo il metodo dalla stessa istanza del thread
        someClass.DoWork();
        Console.Read();
    }
    void DoWork()
    {
        if (!_isWorkDone)
        {
            _isWorkDone = true;
            Console.WriteLine("Work done");
        }
    }
}

Il risultato di questo codice è la scritta “Work done” sullo schermo.
Come si può vedere dal codice, il metodo DoWork() viene chiamato da entrambi i thread a partire dalla stessa istanza di SomeClass: come risultato, dato che il campo _isWorkDone non è statico, questo viene condiviso dai due thread.
Conseguentemente, “Work done” viene printato a schermo una volta sola.

6.2 Il blocco finally nei thread in background non viene eseguito quando il processo termina

Consideriamo il seguente esempio:

class SomeClass
{
    static void Main(string[] args)
    {
        SomeClass someClass = new SomeClass();
        Thread backgroundThread = new Thread(someClass.DoWork);
        backgroundThread.Start();
        Console.WriteLine("Closing the program....");
    }
    void DoWork()
    {
        try
        {
            Console.WriteLine("Doing some work...");
            Thread.Sleep(1000);
        }
        finally
        {
            Console.WriteLine("This should be always executed");
        }
    }
}

Questo codice fornisce:

Doing some work...
Closing the program...

Questo esempio dimostra come quando il thread principale termina la sua esecuzione, il campo finally del thread in background non viene eseguito.
Non considerare questa eccezione può portare numerodi problemi quando ho lavori di dispose() nel blocco finally di un thread, che non verrebbero lanciati portando, in questo caso, a dei memory leak difficili da individuare

6.3 I valori trovati da lambda espressioni sono anch’essi condivisi

Consideriamo il seguente codice:

class SomeClass
{
    static void Main(string[] args)
    {
        for (int i = 0; i < 10; i++)
        {
            Thread thread = new Thread(()=> Console.Write(i));
            thread.Start();
        }
        Console.Read();
    }
}

Noi ci aspettiamo un risultato come 0123456789.
Il risultato ottenuto è invece assolutamente non deterministico!
Il trucco è che la variabile i si riferisce alla stessa locazione di memoria della variabile iterata nell’for, che quindi continua a cambiare.
La soluzione è utilizzare una variabile temporanea:

for (int i = 0; i<= 10; i++)
{
    int temp = i ;
    Thread thread = new Thread(()=> Console.Write(temp));
    thread.Start();
}

6.4 Un thread non è influenzato dalla presenza di un try catch esterno alla sua creazione

Consideriamo il seguente codice:

class SomeClass
{
      static void Main(string[] args)
      {
          try
          {
              Thread thread = new Thread( ()=&gt; Divide(10,0));
              thread.Start();
          }
          catch (Exception ex)
          {
              Console.WriteLine("An exception occured");
          }
      }
      static void Divide(int x, int y)
      {
          int z = x / y;
      }
}

L’eccezione di divisione per zero non verrà catturata dal catch del main, ma rimarrà uncaught portando allo spegnimento del programma.
Il modo migliore per risolvere questa cosa è banalmente spostare il blocco try catch all’interno del metodo del thread.


La parte introduttiva e conclusiva di questo articolo proviene dal libro C# 5(ISBN 978-8820352530), la seconda parte invece dalla documentazione ufficiale.

Indice

Share
Ultimi articoli
Join

Newsletter

Nessuno spam, solo articoli interessanti ;)

Focus

Post correlati

semaphoreslim

SmaphoreSlim 101

SemaphoreSlim è una classe che permette la sincronizzazione di n thread che hanno una risorsa (scarsa) condivisa limitandone l’uso ad un numero massimo.

interlocked

Interlocked 101

La sincronizzazione dei thread è un elemento fondamentale nella programmazione asincrona, ne ho infatti parlato in vari post. La soluzione più versatile è sicuramente utilizzare

event

Come testare gli eventi

Testare che degli eventi siano stato effettivamente lanciati in C# non è immediato. Tipicamente è possibile testare che un evento venga lanciato aspettando un ManualResetEvent

Codice Pragmatico

Contatti

Per informazioni, dubbi o consulenze non esitate a contattarmi.

Lascia un messaggio

Ricevi le ultime news

Iscrivi alla newsletter

Solo articoli interessanti, promesso ;)