Oppure

Loading

I thread sono le vere unità dinamiche di esecuzione: il computer assegna, infatti, il tempo macchina (noto anche con il nome di tempo di CPU o timeslice) a ogni singolo thread per volta anzichè a un intero processo. Ognuno di essi è in grado di eseguire un codice proprio indipendentemente dagli altri, la cui esecuzione appare all'occhio dell'utente simultanea. La macchina, infatti, passa così velocemente da un thread all'altro che i sensi umani non riescono a distinguerli. Il particolare tipo di meccanismo usato su Windows è detto multitasking preemptive, che consente la sospensione di un thread in qualsiasi momento: in versioni precedenti del sistema operativo, era invece necessario richiederne esplicitamente la chiusura (e ciò può ben far intuire come il crash di un solo thread causasse la sospesione dell'intero sistema).
Ciascun thread conserva una propria autonomia, proprie variabili, propri gestori d'eccezioni, eccetera... È possibile anche assegnarvi una diversa priorita', a seconda di quanto sia importante il compito che esso svolge: thread con priorità più alta godranno di un timeslice maggiore e quindi di maggior tempo e spazio per completare le proprie operazioni. Inoltre, dato che tutti i thread consumano memoria e richiedono un certo tempo di CPU, maggiore è la quantità di thread aperti, maggiore sarà l'utilizzo di memoria e il tempo impiegato. Per questo motivo, prima di progettare un'applicazione che implementi questa caratteristica sarebbe opportuno valutare se non ci siano altre possibilità o alternative meno complesse. Di solito, il multitasking viene impiegato in operazioni che richiedono un lungo periodo di esecuzione e che impiegano risorse complesse come file o connessioni. Poichè le risorse possono essere condivise tra più thread, è necessario monitorarne l'uso e controllare che non ci siano due o più tentativi di accesso simultanei, il che potrebbe condurre a un loop e di conseguenza a un crash dell'applicazione. Ma ora veniamo alla pratica.

Uso dei Thread

Tutti i metodi e i tipi utilizzati nel multithreading vengono raggruppati sono un unico namespace di nome System.Threading. L'operazione più rudimentale che si possa eseguire è Start, che fa partire un nuovo thread con un certo metodo. Il costruttore accetta un delegate di tipo ThreadStart senza parametri: questo delegate punta al metodo che dovrà essere eseguito dal thread. Un thread termina quando ha finito il proprio compito, quando viene richiamato il metodo Stop oppure quando viene abortito da se stesso o da un'altra parte del programma con Abort. Altra procedura molto comune è Sleep(X), che attende X millisecondi prima di eseguire altro. Ecco un esempio:

Module Module1
    'Il metodo da far eseguire al Thread:
    Sub WriteNumbers()
        'Scrive 100 volte il numero 0 sullo schermo
        For I As Byte = 1 To 100
            Console.Write("0")
            'Aspetta 0.1 secondi prima di continuare. La classe
            'Thread espone anche metodi statici come questo, che
            'vengono eseguiti dal thread chiamante, in questo 
            'caso quello che eseguirà questa procedura
            Threading.Thread.Sleep(100)
        Next
    End Sub

    Sub Main()
        'Un nuovo thread
        Dim T As New Threading.Thread(AddressOf WriteNumbers)

        'Fa partire il thread
        'Una volta avviato, il programma passa alle istruzioni 
        'successive, poichè, come già detto, il thread è in grado
        'di gestirsi da solo
        T.Start()

        'A prova di ciò, esegue questa routine nel thread
        'principale, ossia in Sub Main:
        For I As Byte = 1 To 100
            Console.Write("1")
            Threading.Thread.Sleep(100)
        Next
        
        'Curiosità -
        'Se a Thread.Sleep viene passato il valore 0, il thread
        'associato cederà il proprio tempo di CPU al
        'thread successivo, mentre il valore -1 indica di attendere
        'all'infinito (o almento finchè non verrà abortito)

        'Cosa appare alla fine?
        Console.ReadKey()
    End Sub
End Module 

Sullo schermo appare una sequenza grosso modo regolare di 0 e 1: questi numeri vengono alternati quasi perfettamente, ma ci sono delle ripetizioni ogni tanto. Questo mostra come il thread principale che esegue il ciclo degli 1 sia indipendente da quello secondario che fa correre il ciclo degli 0 (e viceversa); il timeslice di ognuno viene alternato così che eseguano operazioni quasi contemporanee, ma leggermente sfasate. I metodi del tipo di Start, ossia che portano a termine una routine in un thread separato, vengono detti asincroni: un esempio è il metodo WebClient.DownloadFileAsync (scarica un file da internet), che si è già analizzato.
Ora sarebbe quanto meno utile poter usare i meccanismi imparati in modo un pò più versatile: bisogna trovare il modo di passare degli argomenti a una procedura delegate del costruttore, poichè così facendo si acquisisce più multiformità e il codice è meno rigido. Per nostra fortuna, il costruttore supporta un overload in cui l'unico parametro deve essere un delegate la cui signature accetta un argomento di tipo object. Mediante il tipo Object, infatti, è possibile trasmettere quasliasi tipo di dato. Non è da considerare limitante il fatto dell'avere operazioni di boxing/unboxing: primo perchè non c'è altro modo, secondo perchè, definendo nuove classi, è possibile passare dati anche complessi attraverso un solo parametro. Ecco un esempio:

Module Module2
    'Il metodo da far eseguire al Thread:
    Sub WriteNumbers(ByVal Data As Object)
        'Scrive Times volte il numero Number sullo schermo
        For I As Byte = 1 To Data.Times
            Console.Write(Data.Number)
            Threading.Thread.Sleep(100)
        Next
    End Sub

    Sub Main()
        'Un nuovo thread
        Dim T As New Threading.Thread(AddressOf WriteNumbers)

        'Fa partire il thread
        'Questo overload si Start accetta un parametro Object, che,
        'prima dell'avvio verrà passato come argomento
        'della procedura delegate dichiarata nel costruttore, 
        'in questo caso WriteNumbers
        T.Start(New ThreadData(8, 120))

        For I As Byte = 1 To 100
            Console.Write("1")
            Threading.Thread.Sleep(100)
        Next

        Console.ReadKey()
    End Sub
End Module 

È da ricordare che l'applicazione termina solo quando vengono portati a termine tutti i suoi thread.
Un'altra funzionalità dei thread è, come già accennato in precedenza, la procedura Abort. Essa è speciale in quanto costituisce un modo "sicuro" (per quanto possa essere sicuro terminare brutalmente un thread) per mettere fine all'esecuzione di un thread. Tuttavia presenta alcune particolarità: non ferma immediatamente il codice in eseuzione, ma attende finchè non si sia raggiunto un safe point, un punto sicuro nel quale possa essere lanciata senza problemi un'operazione di Garbage Collection (un esempio di safe point è lo statement Return o End all'interno di un metodo). Ogniqualvolta viene invocato Abort, il thread da abortire lancia un'eccezione speciale, di tipo ThreadAbortException, che non può essere intercettata dall'applicazione; nonostante ciò, se tale thread è stato fatto partire all'intero di un blocco Try, il codice associato alla calusola Finally verrà comunque eseguito. In occasioni eccezionali, esso potrebbe anche intervenire per evitare la propria "morte".
Altri metodi meno usati sono:

  • BeginCriticalRegion : notifica al gestore di thread che il codice sta per introdursi in una operazione di vitale importanza per il programma, nella quale un Abort o anche una semplice eccezione potrebbero compromettere tutta l'applicazione. In questi casi l'abort viene posticipato come sopra descritto
  • AllocateDataSlot : alloca una slot di memoria per tutti i thread in modo da poter passare facilmente dati tra un thread e l'altro. Restituisce un oggetto LocalDataStoreSlot che contiene le informazioni necessarie a richiamare le informazioni salvate, pur non esponendo alcun membro
  • AllocateNamedDataSlot : come sopra, ma assegna allo slot anche un nome
  • CurrentCulture : la cultura del thread
  • CurrentThread : il thread che è correntemente in esecuzione
  • EndCriticalRegion : notifica al gestore di thread che la zona a rischio elevato è terminata
  • FreeNamedDataSlot : libera la memoria associata a uno slot nominale, il cui nome viene passato come primo argomento del metodo
  • GetData(D) : ottiene i dati associati allo slot di memoria D
  • GetDomain : restituisce un oggetto AppDomain che rappresenta il dominio applicativo nel quale viene eseguito il thread
  • GetDomainID : restituisce l'ID dell'AppDomain in cui viene eseguito il thread
  • GetNamedDataSlot(N) : passando il nome N, restituisce l'oggetto LocalDataStoreSlot associato
  • IsAlive : determina se il thread è in esecuzione
  • IsBackground : determina se il thread è in background, oppure lo imposta come tale. Si dicono "in background" thread con bassa priorita'
  • Join : blocca il thread chiamante fino a quanto non termina il thread dal quale è stata invocata la procedura. Bisogna fare attenzione a distinguere bene i ruoli che intercorrono in questo meccanismo, poichè se un thread richiama Join su se stesso, l'applicazione andrà in loop. Per questo motivo sono assolutamente da evitare istruzioni come queste:
    Thread.Join()
    'Oppure
    Thread.CurrentThread.Join 
    Mentre codici simili a questo sono del tutto corretti:
    Dim T As New Thread(AddressOf Something)
    T.Start()
    '...
    'Il thread chiamante (ossia quello principale) attende che
    'T termini. T è il bersaglio della chiamata
    T.Join() 
    I due overload del metodo prevedono la possibilità di specificare un timeout superato il quale non è più necessario attendere oltre: il primo accetta un parametro di tipo Int32 che rappresenta il numero di millisecondi di attesa massimo; il secondo richiede solo un parametro di tipo TimeSpan
  • ManagedThreadID : restituisce un identificativo univoco per il thread managed
  • Name : il nome (opzionale) del thread
  • Priority : proprietà enumarata che determina quale sia la priorità del thread. Può assumere cinque valori: Normal, BelowNormal (inferiore alla norma), AboveNormal (superiore alla norma), Highest (massimo) e Lowest (minimo)
  • ResetAbort : annulla l'Abort di un thread
  • SetData(D, O) : imposta il contenuto dello slot di memoria D sull'oggetto O
  • Sleep : già analizzato
  • SpinWait(N) : simile a Sleep, solo che N indica il numero di iterazioni di attesa prima di continuare. Ogni volta che il thread prosegue le sue operazioni dopo aver ricevuto il timeslice opportuno dal gestore dei thread, l'indice di iterazioni aumenta di 1
  • ThreadState : definisce lo stato del thread. La proprietà enumerata può assumere questi valori: Aborted (abortito), AbortRequested (è in corso la ricezione della richiesta di aborto), Background (come IsBackground), Running (come IsAlive), Stopped (interrotto: un thread in questo stato non può mai riprendere), StopRequested (si sta per interrompere il thread), Suspended (sospeso), SuspendRequested (si sta per sospedere il thread), Unstarted (non ancora avviato) e WaitSleepJoin (in attesa a causa del metodo Join o Wait)

 

Condivisione di dati

Di default, tutti i possibili tipi di variabili vengono condivisi tra i thread in esecuzione. L'unica eccezione a questa regola è costituita dalle variabili locali dinamiche, ossia quelle presenti all'interno di metodi o proprietà (compresa anche Sub Main): questo si verifica sempre, anche nel caso in cui i thread siano in esecuzione all'interno del suddetto metodo.
Per quanto riguarda le variabili Shared, ogni thread condivide la stessa copia del valore associato. Se si volesse fare in modo di rendere tali variabili relative al thread , ossia thread-relative, per cui il loro valore si conservi solo all'intero di ciascuno, si dovrebbe usare l'attributo ThreadStatic:

Module Module3
    Public Class StaticVar
        'La variabile è accessibile da ogni membro e da ogni 
        'istanza della classe, ma assume valori differenti a 
        'seconda che sia eseguita in un thread piuttosto che 
        'in un altro
        'Una dimostrazione? continuate a leggere
        <ThreadStatic()> _
        Public Shared Value As Int32
    End Class

    'Aumenta la variabile
    Sub Test(ByVal Increment As Object)
        'StaticVar.Value è statica, perciò ogni thread la dovrebbe
        'vedere con lo stesso valore, ma in questo caso ciò non 
        'accade a causa dell'attribut ThreadStatic
        For I As Byte = 1 To 10
            Console.WriteLine("Thread {0} -> Value = {1}", _
            Thread.CurrentThread.ManagedThreadId, StaticVar.Value)
            Thread.Sleep(100)
            StaticVar.Value += CInt(Increment)
        Next
    End Sub

    Sub Main()
        Dim T(2) As Thread

        'Inizia 3 thread diversi con un diverso incremento
        For I As Byte = 0 To 2
            T(I) = New Thread(AddressOf Test)
            T(I).Start(I + 1)
        Next

        Console.ReadKey()
    End Sub
End Module 

Nell'output si vedrà che il thread con ID minore visualizzerà tutti i numeri da 0 a 9, quello con ID intermedio solo i pari da 0 a 18, mentre quello con ID massimo solo i multipli di 3 da 0 a 27.
Altra circostanza possibile è quella in cui il computer su cui gira l'applicazione sia multiprocessore. In questo caso, i registri CPU non sono condivisi e ogni registro associato a un diverso processore mantiene una propria autonomia: i valori delle variabili, perciò, se sono modificati da un thread che lavora su un dato processore non vengono letti da uno che sia in esecuzione su un processore differente. Per evitare errori di sorta, il .Net Framework mette a disposizione i metodi VolatileWrite e VolatileRead che permettono di scrivere valori di variabili in un registro condiviso; inoltre MemoryBarrier aggiorna tutti i registri al valore più recente.

A cura di: Il Totem