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
|