adszone.org
venerdì 20 settembre 2024
"Il modo corretto di pensare il software"
home
consulenza  vb.net  contatti 
  Visual Basic .NET
  Threading
Domande

Threading

Un'applicazione che esegue piu' task (funzioni) simultaneamente e' nota come applicazione multithreading. Queste applicazioni sono in grado di rispondere prontamente all'input dell'utente perche' la loro interfaccia utente rimane attiva e, nel mentre, le funzioni che impegnano il processore vengono eseguite in parallelo da altri thread.

Threads in VB.Net

In .NET il multithreading si realizza dichiarando variabili oggetto di tipo Thread (Namespace System.Threading). Queste variabili si costruiscono fornendo loro l'indirizzo della procedura o metodo che noi vogliamo sia eseguita nel thread. L'indirizzo l'otteniamo con l'istruzione AddressOf.

Dim th As New Thread(AddressOf NomeProcedura)

Dichiarato il thread, possiamo avviarlo con th.Start(). Per terminarlo utilizziamo th.Abort(). Inoltre:

  • Attributes - modifica gli attributi
  • Start - Avvia il thread
  • Sleep - Mette in pausa il thread in base al tempo specificato
  • Suspend - Sospende il thread, raggiunto un Safe Point (*)
  • Resume - Riavvia il thread
  • Abort - Ferma il thread non appena raggiunge un Safe Point (*)
  • Join - Il thread che chiama Join aspetta che un altro thread finisca.

(*) I Safe Points sono punti nel codice dove non e' critico per il CLR eseguire la Garbage Collection di oggetti non piu' utilizzati.

Alcune proprieta' della classe Thread illustrano il suo funzionamento:

IsAlive - Indica se il thread e' attivo. Il fatto che un thread sia attivo non implica necessariamente che esso sia in esecuzione.

IsBackground - Indica se il thread e' un thread in background. Un processo puo' contenere contemporaneamente thread in foreground e background. I thread in foreground hanno una piena autonomia nel gestire il loro ciclo di vita, quelli in backgound, invece, terminamo necessariamente quando tutti i thread in foreground sono terminati.

Priority - Indica il tipo di priorita' del thread. Quando abbiamo un unico processore, il multithreading e' simulato assegnando ciclicamente a ciascun thread una fetta di tempo. La gestione delle priorita' permette di variare l'ampiezza di queste fette, in modo da distribuire il carico della CPU in maniera adeguata alle varie esigenze. I valori assegnabili alla proprieta' Priority si trovano nell'enumerazione ThreadPriority (namespace System.threading)

ThreadState - Descrive lo stato attuale del thread. In genere, lo stato del thread e' determinato dal tipo di operazione al quale lo abbiamo sottoposto. Il metodo Sleep, ad esempio, lo mette in stato di attesa, Suspend lo mette in pausa, Abort lo pone in fase di terminazione, etc. E' possibile che il thread venga identificato da piu' di uno stati, questo si verifica ad esempio, quando un thread si e' messo in stato di attesa chiamando Wait (namespace System.Threading.Monitor) e nel frattempo tentiamo di terminarlo con Abort. In tal caso lo stato sara' contrassegnato come Waiting e AbortRequested.

Scambiare dati con i Thread

Scambiare dati con un thread risulta artificioso perche' il costruttore non prevede alcun meccanismo di passaggio di parametri alla procedura che il thread eseguira'.

L'idea, allora, e' quella di incapsulare la procedura all'interno di una classe e aggiungere o dei campi pubblici oppure delle proprieta'. Questi campi conterranno i parametri da fornire al thread. Esempio:

Vogliamo che il thread fornisca come risultato, la concatenazione di due stringhe che gli vengono passate come parametro. Le stringhe, s1 ed s2, "appartengono" alla classe ConcatenaWrapper che continene la procedura Concatena, da far eseguire al thread.

    Public Class ConcatenaWrapper
        Public s1 As String, s2 As String
        Public risultato As String 
        Public Sub Concatena() 
            risultato = s1 & s2
        End Sub

    End Class

Notate che costruiamo il thread passando al costruttore l'indirizzo di un metodo, invece di quello di una semplice procedura. Inoltre, prima di avviare il thread, impostiamo le sue variabili membro pubbliche con le parole che vogliamo fargli concatenare. Terminato il thread, dopo Join, otteniamo il risultato, sempre dalla classe wrapper, nel campo Risultato.

    Private Sub btnThreadParam_Click(ByVal sender As System.Object, _
             ByVal e As System.EventArgs) Handles btnThreadParam.Click
        Dim th As System.Threading.Thread
        Dim cw As New ConcatenaWrapper()

        th = New System.Threading.Thread(AddressOf cw.Concatena)
        cw.s1 = "Hello "
        cw.s2 = "Thread"
        th.Start()
        th.Join()
        MessageBox.Show(cw.risultato)
    End Sub
		

Sincronizzazione

Poiche' piu' thread girano in maniera autonoma, 1) diventa difficile prevedere ed imporre loro un ordine di esecuzione, in modo che si aspettino e collaborino. Inoltre, 2) si creano confilitti per quanto riguarda l'accesso a risorse condivise. Abbiamo, quindi, bisogno di meccanismi tramite i quali i thread si scambiano segnali. Questi meccanismi sono detti sincronizzazione, il cui principio di funzionamento e' simile a quello di clacson e semafori agli incroci stradali. Vediamo un tipico problema di accesso a risorse condivise:

Supponiamo che due thread debbano incrementare una variabile condivisa, X, che abbia inizialmente il valore 2. E' bene tener presente che anche le operazioni piu' elementari, come un incremento, soprattutto in linguaggi ad alto livello, vengono eseguite con piu' passi elementari, cioe' esse non sono atomiche. Listiamo, ora, i passi che un ipotetico processore compierebbe per implementare l'incremento:

  • X -> Acc (Preleva il valore di X nell'accumulatore)
  • Acc = Acc + 1 (Incrementa l'accumulatore)
  • Acc -> X (Memorizza il contenuto dell'accumulatore in X)

Notate che il valore della variabile da incrementare viene copiato in un accumulatore. E' l'accumulatore ad essere incrementato. Successivamente il suo valore viene scritto nella variabile target.

La tabella seguente riporta come potrebbero venir eseguiti gli incrementi da parte di due thread, quasi in simultanea.

Thread 1 Thread 2 Valore Acc 1 Valore Acc 2 Valore X
X -> Acc   2 - 2
Acc = Acc + 1 X -> Acc 3 2 2
Acc -> X Acc = Acc + 1 3 3 3
  Acc -> X 3 3 3

Un corretto funzionamento comporterebbe che, al termine degli incrementi, il valore di X sia passato da 2 a 4. Invece otteniamo 3. Questo avviene perche' quando un thread sta incrementando il valore 'copia', l'altro thread sta copiando il valore di X non ancora aggiornato. Se gli incrementi fossero stati atomici (eseguiti come fossero una sola istruzione), un solo thread alla volta avrebbe potuto accedere ad X, e quindi non ci sarebbero state interferenze.

Wait Handles

La classe WaitHandle e' la classe base da cui deriva un primo insieme di oggetti di sincronizzazione, essi sono: AutoResetEvent, ManualResetEvent e Mutex. Possiamo considerarli come semafori, come tali hanno due stati che corrispondono al rosso e verde. Nel gergo si dice che un WaitHandle puo' essere o segnalante o non segnalante. In generale, i thread si mettono in stato di attesa invocando il metodo WaitOne sul WaitHandle. Quando un thread e' in attesa sul WaitHandle e questo diventa segnalante, allora il thread si sblocca e puo' continuare l'esecuzione del codice.

  • AutoResetEvent - Questo tipo di WaitHandle e' automaticamente Resettato (non segnalante) dopo che ha svegliato un thread dal suo stato di attesa.
  • ManualResetEvent - Dopo che questo WaitHandle ha segnalato un thread, e' compito del thread Resettarlo
  • Mutex - Dopo che un thread e' svegliato dall'attesa sul WaitOne del mutex, esso acquisisce il mutex e blocca altri eventuali thread che stanno in wait sullo stesso mutex. Il thread rilascia il mutex con il metodo ReleaseMutex, in modo che esso possa essere acquisito, con la stessa modalita' da qualche altro thread.

Vediamo come funziona, in pratica, un WaitHandle. Prenderemo in considerazione l'AutoResetEvent. Il seguente esempio simula il problema degli incrementi simultanei di un contatore condiviso. Esso e', sostanzialmente, una soluzione al problema della variabile X, che abbiamo posto precedentemente.

In questo caso la nostra variabile X e' il contatore che abbiamo chiamato conteggio. Lo abbiamo avvolto in una classe CContatore in modo da corredarlo di un WaitHandle, wh, che gestisca la concorrenza di piu' thread che lo utilizzano (Race Condition).

Imports System.Threading

Public Class CContatore
    Public conteggio As Integer
    Public wh As WaitHandle
    Sub New()
        conteggio = 0
    End Sub
End Class

Creeremo due thread che eseguono il metodo Incrementatore della seguente classe ThreadWrapper. Con questa classe possiamo fornire a ciascuna istanza del thread un nome, m_Nome ed il contatore da incrementare, m_Risorsa. L'Incrementatore incrementa il contatore tre volte. Per fare in modo che solo un thread alla volta incrementi il contatore (con i tre step dell'accumulatore), ci mettiamo in attesa sul WaitHandle, WaitOne(). Soltanto quando l'Handle e' segnalante, WaitOne termina, ed allora viene eseguito il codice successivo. Contemporaneamente, poiche' stiamo utilizzando un AutoResetEvent, l'Handle torna ad essere non segnalante, bloccando ogni altro thread. Dopo aver eseguito l'incremento, eseguiamo il metodo Set sull'Handle e cosi' sblocchiamo un altro thread in attesa. Notate che poiche' wh e' un generico WaitHandle che pero' si riferice, come vedremo, ad un AutoResetEvent, allora dobbiamo fare una conversione esplicita con CType.

Public Class ThreadWrapper
    Public m_Nome As String
    Public m_Risorsa As CContatore

    Public Sub Incrementatore()
        Dim acc As Integer, i As Integer

        For i = 1 To 3

            m_Risorsa.wh.WaitOne()

            acc = m_Risorsa.conteggio
            Thread.Sleep(1000)
            acc += 1
            m_Risorsa.conteggio = acc

            System.Console.WriteLine(Me.m_Nome & " effettua l'incremento")

            CType(m_Risorsa.wh, AutoResetEvent).Set() 
        Next
    End Sub
End Class

La parte principale del nostro codice crea un oggetto contatore, due thread e, a ciascun thread, da' un nome e la medesima risorsa contatore. Notate che a tale risorsa e' stato associato un AutoResetEvent. Il suo parametro True lo fa partire come segnalante. Il main si limita ad avviare i thread ed aspettare il loro termine. Nel frattempo essi avranno stampato dei messaggi sulla console, indicando cosa e' accaduto.

Module Module1

    Dim contatore As New CContatore()

    Sub Main()
        contatore.wh = New AutoResetEvent(True) 

        Dim tw1 As New ThreadWrapper()
        Dim tw2 As New ThreadWrapper()

        tw1.m_Nome = "Thread 1"
        tw1.m_Risorsa = contatore 
        tw2.m_Nome = "Thread 2"
        tw2.m_Risorsa = contatore 

        Dim t1 As New Thread(AddressOf tw1.Incrementatore)
        Dim t2 As New Thread(AddressOf tw2.Incrementatore)

        t1.Start()
        t2.Start()

        t1.Join()
        t2.Join()

        Console.WriteLine("Conteggio: " & contatore.conteggio)
        Console.ReadLine()
    End Sub

End Module
		

Al termine del programma scriviamo anche il valore finale del conteggio. Poiche' ciascun thread incrementa il contatore tre volte ed esso parte da zero, il suo valore sara' 6. Provate, invece, a commentare la riga "m_Risorsa.wh.WaitOne()" nel thread e verificate che il risultato che otterrete e' errato. Lo Sleep(1000), durante la procedura d'incremento serve a creare il conflitto fra i thread. Infatti, la semplicita' di questa applicazione fa' in modo che l'incremento sia troppo veloce perche' i thread entrino in Race Condition.

La classe Interlocked

Esiste gia' un metodo implementato nel .NET Framework per ottenere il risultato dell'esempio precedente. La classe Interlocked implementa quattro operazioni atomiche: incremento, decremento di una variabile intera o long, scambio dei valori tra due variabili integer, object o single, e confronto con eventuale scambio di valori.

  • Interlocked.Increment - Incrementa atomicamente il suo parametro.
  • Interlocked.Decrement - Decrementa atomicamente il suo parametro.
  • Interlocked.Exchange - Scambia atomicamente i valore dei due parametri.
  • Interlocked.CompareExchange - Atomicamente, confronta il primo parametro con il terzo, se sono uguali scambia i valori dei primi due parametri.

La classe Monitor

La classe Monitor serve a rendere atomico l'accesso ad una sezione di codice. Questa sezione viene comunemente detta Critical Section. Un thread che ottiene il lock di una sezione impedisce agli altri thread di entrarvi. Si avvale in particolare dei seguenti metodi statici:

  • Enter - Aspetta di acquisire e bloccare la sezione di codice di cui marca l'inizio.
  • TryEnter - Come Enter, ma non aspetta l'acquisizione. Restituisce un booleano che indica se l'acquisizione e' riuscita.
  • Exit - Visivamente, marca la fine della sezione critica, operativamente ne rilascia il lock.

Lo stesso risultato dei precedenti metodi si puo' raggiungere con le istruzioni SyncLock... End SyncLock.

Il Monitor controlla un oggetto che gli viene esplicitamente passato tramite Enter o SyncLock e fa' in modo che esso non venga alterato fintanto che un thread ne ha il lock.

La classe ReaderWriterLock

Ci sono casi in cui non c'e' bisogno di ottenere un accesso esclusivo ad una risorsa se con esso non bisogna modificarla. La classe ReaderWriterLock e' utile per bloccare in modo non esclusivo una risorsa quando ci limitiamo ad accedere al suo contenuto e per bloccarla in modo esclusivo quando la dobbiamo modificare.

L'oggetto ReaderWriterLock si avvale dei seguenti metodi per la demarcazione di una sezione di codice: AquireWriterLock... ReleaseWriterLock, AquireReaderLock... ReleaseReaderLock. Con questa tecnica piu' thread possono accedere alla risorsa in lettura, ma soltanto un thread puo' accedervi in scrittura e questo riesce solo quando nessun thread blocca gia' la risorsa in lettura o scrittura.

Ultimo aggiornamento 13/01/2004