Curiosità sui Thread

di Cristian Civera, in .NET,

In questi giorni grazie ad un esigenza di Rob e Paolo, ho voluto approfondire un aspetto di cui ero già a conoscienza, ma del quale non avevo mai fatto test.
Come sapete, un campo dichiarato in una classe può teoricamente subire contemporaneamente l'accesso da più thread e spesso per rendere la classe thread-safe si ricorrono a tecniche di sincronizzazione per evitare che più thread cambino contemporanemante il campo. Non è detto infatti che una singola istruzione di assegnazione sia una singola istruzione di CPU e questo può portare ad inaspettati risultati. Questo problema si fa inoltre più frequente se si dichiara static (o shared in VB) il campo, condividendo il valore del membro per l'intero AppDomain.

A volte però, si vuole rendere l'istanza del campo accessible da chiunque purché dal medesimo thread, togliendo quindi il problema dell'accesso concorrente. L'attributo ThreadStatic permette di marcare un campo e di avere questo comportamento:

[ThreadStatic] 
private static object threadState;

Questa tecnica viene sfruttata per esempio dall'oggetto Transaction (System.Transactions) per mantenere l'oggetto rappresentante la transazione corrente cosicchè qualsiasi sotto chiamata possa accedere alla transazione corrente semplicemente chiamando Transaction.Current, il tutto isolato per thread (la transazione infatti non è visibile in altri thread, ma ne viene creata un'altra). Lo stesso comportamento, seppure meno comodo, si può ottenere usando Thread.AllocateNamedDataSlot, Thread.SetData e Thread.GetData che permettono di allocare per nome uno spazio, di impostarlo e di leggerlo, dove inserire un riferimento ad un oggetto.

La problematica di questo approccio è che nel caso in cui da un thread ne avviamo un altro e si vuole portare con se questi valori "speciali", occorre passarseli tramite argomenti o classi d'appoggio per poi essere copiati sul nuovo thread. Inoltre nel caso del ThreadPool, quell'insieme di thread già pronti all'uso, utilizzati quando si chiama la Begin*** di un delegate o si usa ThreadPool.QueueUserWorkItem, il campo statico risulta visibile alle operazioni che verranno prese in carico dal medesimo thread e mai rilasciato (si pensi al Dispose dell'oggetto) se non sovrascritto o impostato a null (il ThreadPool nel .NET 2.0 è composta da 25 Thread per CPU e di 500 IO Thread per CPU, in ASP.NET 2.0 sono 100/100, nel.NET 3.5 sono diventanti 250/500, mentre in ASP.NET 3.5 nella modalità AutoConfig sono rimasti invariati).

Per risolvere questo problema viene in aiuto la classe CallContext (System.Runtime.Remoting.Messaging) con i metodi LogicalGetData e LogicalSetData per leggere e impostare in base ad una chiave il riferimento ad un oggetto. Il comportamento è simile a ThreadStatic, ma questi valori sono relativi al contesto in cui sono stati impostati. Per capire meglio ho creato questa classe:

public class MyObject : Component 
{ 
private readonly string _name; 
public MyObject(string name) 
{ 
    _name = name; 
} 
protected override void Dispose(bool disposing) 
{ 
    base.Dispose(disposing); 
    Console.WriteLine("Disposed {0}", _name); 
} 
public override string ToString() 
{ 
    return _name; 
} 
}

E' una semplice classe che implementa IDisposable così da sapere quando l'oggetto, identificato con un nome, viene distrutto. Poi ho creato una ConsoleApplication che esegue su un thread del pool il controllo di un campo marcato con ThreadStatic, lo valorizza se è nullo e ne stampa il valore. La stessa operazione viene effettuata usando CallContext. Inoltre, attendo la fine dell'esecuzione per assicurarmi che le altre successive operazioni vengono effettuate nuovamente sul medesimo thread ed utilizzo GC.Collect per assicurarmi che gli oggetti, non più in uso, vengano distrutti (mi raccomando, in vere applicazioni non si deve chiamare se non in rare eccezioni):

class Program 
{ 
[ThreadStatic] 
private static object threadState; 
static void Main(string[] args) 
{ 
    // Metodo da invocare su un Thread del pool 
    WaitCallback wc = DoWork; 
    for (int x = 0; x < 3; x++) 
    { 
        // Invoco e attendo che finisca 
        wc.BeginInvoke(null, EndWork, wc).AsyncWaitHandle.WaitOne(); 
        // Forzo la liberazione degli oggetti 
        GC.Collect(); 
        GC.WaitForPendingFinalizers(); 
    } 
    // Avvio l'operazione su un nuovo Thread 
    Thread t = new Thread(DoWork); 
    t.Start(); 
    t.Join(); 
    // Forzo la liberazione degli oggetti 
    GC.Collect(); 
    GC.WaitForPendingFinalizers(); 
    Console.Read(); 
} 
private static void EndWork(IAsyncResult ar) 
{ 
    ((WaitCallback)ar.AsyncState).EndInvoke(ar); 
} 
const string DataKey = "test"; 
static void DoWork(object state) 
{ 
    Console.WriteLine("-----------------"); 
    Console.WriteLine("Thread {0}", Thread.CurrentThread.ManagedThreadId); 
    object t = CallContext.LogicalGetData(DataKey); 
    if (t == null) 
    { 
        t = new MyObject("LogicalData"); 
        Console.WriteLine("LogicalData set"); 
        CallContext.LogicalSetData(DataKey, t); 
    } 
    else 
        Console.WriteLine("LogicalData: {0}", t); 
    t = threadState; 
    if (t == null) 
    { 
        t = new MyObject("DataThread"); 
        threadState = t; 
        Console.WriteLine("DataThread set"); 
    } 
    else 
        Console.WriteLine("DataThread: {0}", t); 
    } 
}

Se lo si esegue, il risultato è il seguente:

----------------- 
Thread 6 
LogicalData set 
DataThread set 
----------------- 
Thread 6 
LogicalData set 
DataThread: DataThread 
Disposed LogicalData 
----------------- 
Thread 6 
LogicalData set 
DataThread: DataThread 
Disposed LogicalData 
Disposed LogicalData 
----------------- 
Thread 11 
LogicalData set 
DataThread set 
Disposed DataThread

Come si vede, alla prima esecuzione di DoWork, entrambi i modi di memorizzare MyObject impostano il valore. Alla seconda esecuzione CallContext è di nuovo vuoto e va rivalorizzato, mentre threadState contiene il valore impostato sulla prima esecuzione. Lo stesso avviene anche alla terza esecuzione. E' interessante notare che l'oggetto impostato con CallContext viene effettivamente rilasciato e quindi ucciso dal GC solo all'avviarsi della nuova operazione sul medesimo thread (infatti il primo GC.Collect non ha sortito nessun effetto). Poiché i thread del pool non muoiono mai, DataThread non viene mai eliminato, ad eccezione di quello impostato nel Thread creato manualmente (l'esecuzione è finita e quindi il thread è inutilizzabile).
Tutto questo meccanismo è gestito dalla classe ExecutionContext (System.Threading) che è in grado di catturare, mettere a disposizione o di togliere il contesto e tutti valori ad esso associati. Quando si chiama ThreadPool.QueueUserWorkItem o il Begin*** di un metodo, viene chiamato ExecutionContext.Capture per catturare il contesto e tenerselo pronto quando il thread prende in consegna il delegate da eseguire, ripristinando quindi il contesto chiamando il metodo Run. Se avete mai dovuto creare un vostro ThreadPool, tipo questo, avrete sicuramente avuto a che fare con questa classe. Il bello di questo meccanismo è che se usiamo CallContext per mantenere dei valori ed invochiamo delegate asincroni o creiamo thread, automaticamente il contesto viene passato sull'altro thread condividendolo per la durata dell'operazione. Se nell'esempio precedente quindi si imposta prima di qualsiasi accodamento, l'istruzione CallContext.LogicalSetData(DataKey, new MyObject("test")); si ottiene a console:

Thread 6 
LogicalData: test 
DataThread set 
----------------- 
Thread 6 
LogicalData: test 
DataThread: DataThread 
----------------- 
Thread 6 
LogicalData: test 
DataThread: DataThread 
----------------- 
Thread 11 
LogicalData: test 
DataThread set 
Disposed DataThread

Si noti che LogicalData su tutti i thread è già impostato.

Un'ultimo aspetto riguarda ASP.NET che utilizza anch'esso CallContext.HostContext ogni qualvolta si interroga HttpContext.Current, ma se si usano pagine asincrone con task ecc, i metodi asincroni non dispongono del contesto web e HttpContext.Current ritorna null. Diversamente usando CallContext.LogicalSetData si ha a disposizione l'oggetto anche sui task asincroni ed è quindi l'ideale se dovete mantenere DataContext (LINQ) o Session (NHibernate).

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese