- Multi-threading e data race: monitor e SyncLock per l'accesso a dati condivisi -
 
COSA SERVE PER QUESTO TUTORIAL
Download | Chiedi sul FORUM | Glossario un server che supporti ASP .Net
Problematiche del multi-threading in .Net

IL PROBLEMA
Accesso a dati condivisi da più thread paralleli.

Come detto nel precedente articolo i problemi con i thread sorgono quando si ha la necessità di accedere alla stessa risorsa (tipicamente una variabile) da due thread differenti in esecuzione parallela. Quando si scrive codice che deve leggere o scrivere su dati a cui potrebbe voler accedere anche un altro thread bisogna sempre tener presente che la possibilità che tra un'istruzione e l'altra il thread si interrompa e passi il controllo ad un altro. Questo secondo thread potrebbe alterare i dati su cui il primo stava lavorando creando comportamenti inattesi (tipicamente si verifica una data race, una corsa ai dati). Prendiamo un esempio classico:


Imports System.Threading

Module Main

    Const intSomma As Integer = 100
    Private intTotale As Integer = 0


    Sub Main()
        Console.Write("Numero di thread da creare: ")
        Dim intThreadTotal As Integer = CInt(Console.ReadLine())
        Dim thrSommatori(intThreadTotal) As Thread

        'Creiamo il numero di thread specificato, tutti sulla funzione Somma
        For C1 As Integer = 0 To intThreadTotal - 1
            thrSommatori(C1) = New Thread(AddressOf Somma)
        Next C1

        'Avviamo tutti i thread
        For C1 As Integer = 0 To intThreadTotal - 1
            thrSommatori(C1).Start()
        Next C1

        'Attendiamo la terminazione di tutti i thread
        For C1 As Integer = 0 To intThreadTotal - 1
            thrSommatori(C1).Join()
        Next C1

        'Stampiamo il risultato ottenuto e quello previsto
        Console.WriteLine("Risultato: {0} (atteso: {1})", intTotale, intSomma * intThreadTotal)
        Console.ReadLine()
    End Sub

    'Funzione che effettua somme
    Public Sub Somma()
        For CSum As Integer = 1 To intSomma
            intTotale += 1
        Next CSum
    End Sub

End Module


Questa semplice applicazione console non fa altro che creare un numero di thread specificato dall'utente, avviarli e attendere la loro terminazione. I thread incrementano di 1 100 volte una variabile globale. Se si prova ad eseguire il programma specificando un numero di thread sufficientemente elevato (provare con 10 e poi a salire di ordini di grandezza) si vedrà che il risultato finale non sarà quello atteso: ad esempio sulla macchina su cui è stato testato, con 1000 thread, il risultato finale era 99'800 invece che 100'000. Perché questo strano comportamento? Analizziamo la riga che crea il problema:

intTotale +=1

Niente di più semplice apparentemente, ma per capire bene dove nasce il problema dobbiamo entrare in profondità in questa istruzione: per prima cosa legge la variabile intTotale, effettua la somma e infine scrive il risultato in intTotale. È quindi equivalente al seguente codice:


Dim intTemp As Integer = intTotale
intTemp = intTemp + 1
intTotale = intTemp

L'errore si verifica per via del fatto che nel tempo che trascorre tra la lettura di intTotale e la sua riscrittura in memoria, un altro thread ha incrementato quella stessa variabile, ma di questo fatto il thread che era in esecuzione precedentemente non può essersi accorto e quindi l'incremento del secondo thread viene perso con la scrittura del nuovo valore.
Inoltre bisogna sempre considerare che oggi la memoria non si limita semplicemente alla memoria principale, ma esistono vari registri e livelli di cache, il che in un contesto multi-thread può creare notevoli problemi in quanto si rischia di avere a che fare con una variabile in cache mentre un valore più aggiornato potrebbe essere presente nella memoria principale.

I MONITOR
Come forzare la scrittura e lettura di una variabile dalla memoria principale e assicurarsi di avervi accesso esclusivo.

Per ovviare a questo problema ci si può servire dei monitor. In .Net ad ogni oggetto è associato (in teoria) un monitor, che può essere acquisito, così impedendo l'accesso a quell'oggetto da parte, e rilasciato, rendendo di nuovo disponibile l'oggetto ad altri thread. Acquisire il monitor (altresì detto lock) su una variabile significa quindi assicurarsi che fino a quando non lo si rilascerà, i dati della variabile non saranno utilizzati da nessun altro thread (che verrà quindi messo in stato di attesa fino al rilascio). Per acquisire un lock ci si server di Monitor.Enter e per rilasciarlo di Monitor.Exit. Si noti inoltre che una chiamata a Monitor.Enter sortisce anche l'effetto di forzare una lettura dalla memoria principale (una lettura che si definisce volatile) e viceversa Monitor.Exit forza una scrittura sulla memoria principale (scrittura che si definisce volatile), in modo da non avere versioni diverse della stessa variabile nei vari livelli di memoria.
In questo caso dovremo però servirci di un oggetto ausiliario in quanto la variabile intTotale è di tipo Integer e non deriva da Object e per questo non dispone di monitor; creiamo dunque una variabile globale di tipo Object per servirci del suo monitor:

Private objMonitor As New Object()

Modifichiamo quindi la routine Somma:


Public Sub Somma()
    For CSum As Integer = 1 To intSomma
        Monitor.Enter(objMonitor)
        intTotale += 1
        Monitor.Exit(objMonitor)
    Next CSum
End Sub

Se ora riproviamo ad eseguire il codice, otterremo il risultato sperato, ovvero nessun incremento è stato perso.

CONSIDERAZIONI FINALI
Monitor, eccezioni e la parola chiave SyncLock

Per imporre un lock su una variabile, Visual Basic, mette a disposizione anche un metodo più pulito, sicuro ed elegante rispetto a Monitor.Enter e Monitor.Exit, ovvero la parola chiave SyncLock. SyncLock definisce un blocco (simile a Using) che fa le veci delle chiamate a Monitor.Enter e Monito.Exit, e inoltre concepisce anche il caso in cui si generi un'eccezione. Infatti, se per qualche ragione l'incremento dovesse generare un'eccezione (ad esempio di overflow) il lock non verrebbe mai rilasciato nella versione del codice precedente, il che comporterebbe che tutti i thread in attesa per la disponibilità di objMonitor rimarrebbero inesorabilmente bloccati per sempre. Dovremmo dunque scrivere:


Public Sub Somma()
    For CSum As Integer = 1 To intSomma
        Monitor.Enter(objMonitor)
        Try
            intTotale += 1
        Finally
            Monitor.Exit(objMonitor)
        End Try
    Next CSum
End Sub

Che è del tutto equivalente alla seguente sintassi:


Public Sub Somma()
    For CSum As Integer = 1 To intSomma
        SyncLock objMonitor
            intTotale += 1
        End SyncLock
    Next CSum
End Sub

 

<< INDIETRO by VeNoM00