versione italiana versione italiana
english version english version

Thread .NET e Windows

In .NET (o per meglio dire nel CLR) esiste un’astrazione del concetto di thread rispetto all’implementazione dei thread nel sistema operativo sottostante. Questo articolo definisce il rapporto che c’è coi thread del sistema operativo, illustrando come sia possibile, in casi molto particolari, prendere il controllo completo di un thread fisico da .NET. Anche se i concetti generali sono rapportabili allo standard CLI, gli esempi specifici sono relativi alla piattaforma Windows, e in particolare all’implementazione Microsoft di .NET Framework.

Thread logici e thread fisici

I thread controllabili nel mondo managed sono rappresentati dalla classe System.Threading.Thread, che rappresenta un thread logico, mentre il thread a livello di sistema operativo prende il nome di thread fisico.

Un thread logico esiste prima e dopo del thread fisico, ma ha bisogno del thread fisico per l’esecuzione del codice. Il thread fisico, invece, esiste solo per eseguire del codice: in assenza di codice da eseguire, il thread fisico termina la sua esistenza (cosa che avviene, per esempio, quando si esce dalla funzione di ingresso di un thread).

Nelle implementazioni attuali (.NET 1.0 e . NET 1.1), esiste un thread fisico per ogni thread logico in esecuzione. In pratica, la chiamata del metodo Start di un thread logico provoca la creazione di un thread fisico nel sistema operativo; la chiusura del thread fisico, invece, non definisce l’eliminazione del thread logico, che resta in vita (come oggetto) fino a quando non ci sono più riferimenti validi a esso e fino a quando non interviene un’operazione del Garbage Collector.

In realtà, qualcosa di simile succede anche a livello di sistema operativo: quando un thread termina, la struttura dati nel kernel che rappresenta le informazioni del thread (codice di uscita, stack, registri, …) rimane allocata fino a che esistono handle validi al thread, al fine di consentire la sincronizzazione rispetto alla fine del thread da parte di altre porzioni di un programma, che possono anche voler conoscere il codice di uscita. Mettiamo però da parte questi dettagli e, nel contesto di questo articolo, consideriamo il thread fisico esistente solo fino a che nel thread vi sono ancora linee di codice da eseguire.

L’attuale corrispondenza tra thread fisici e thread logici potrebbe creare false aspettative. Per controllare i thread fisici esiste una classe apposita, System.Diagnostics.ProcessThread, con cui si possono monitorare i tempi di esecuzione di un thread, l’affinità di processore, lo stato di esecuzione, e così via. Può essere istintivo pensare che esista un qualche sistema per passare da un thread logico a un thread fisico e viceversa; in altre parole, data un’istanza della classe Thread, ci si potrebbe aspettare di avere un metodo della classe stessa che consenta di ottenere un riferimento a un’istanza di ProcessThread. Ovviamente, non è così: per mantenere la libertà di implementazione in versioni future, non esiste alcuna correlazione definita tra thread logici e fisici.

In futuro .NET potrà avere un thread logico eseguito su thread fisici differenti (in momenti diversi), così come potranno esserci diversi thread logici eseguiti su uno stesso thread fisico; un’altra possibilità è quella di implementare meccanismi di schedulazione del tempo sui thread logici appoggiandosi all’uso di fiber (vedi funzioni Win32 CreateFiber e SwitchToFiber).

Controllo di un thread fisico

Per comprendere quali siano le possibilità di manipolazione e controllo dei thread fisici da parte di un’applicazione managed è necessario stabilire le motivazioni di tale necessità.

La classe Thread fornisce il controllo sulla maggior parte delle caratteristiche che si possono voler controllare in un thread: priorità, stato, apartment (per usare componenti COM). Esistono però altri aspetti di un thread che sono accessibili solo attraverso ProcessThread: tempo di esecuzione, affinità di processore, priorità corrente, e altri ancora. Per risolvere questo tipo di problematiche è necessario individuare un’istanza di ProcessThread corrispondente al thread desiderato.

Un’istanza della classe Process espone, con la proprietà Threads, un elenco dei thread fisici del processo. In pratica, con Process.GetCurrentProcess().Threads si accede a una collection di thread del processo corrente. Ogni thread è identificato da un ID (ProcessThread.Id). Anche se nell’implementazione attuale ogni thread logico ha un suo thread fisico, in generale non è possibile stabilire questa relazione; dato però un thread in esecuzione, esso può richiedere l’ID del thread corrente (AppDomain.GetCurrentThreadId) e con quest’informazione può mappare il ProcessThread corrispondente (ricercandolo nella collection Threads).

Ovviamente è bene non affidarsi troppo a questo meccanismo, per i possibili cambiamenti che in futuro potrebbero invalidare la relazione 1:1 oggi esistente tra thread logici e fisici.

Esiste però almeno un’altra motivazione per richiedere un controllo sui thread fisici da parte del codice managed. Ogni thread ha un suo stack, che viene definito normalmente con una dimensione pari alla dimensione definita alla creazione del processo. Per default questo valore è di 1Mb, ma non significa che viene allocato 1Mb per ogni thread in un processo: si tratta di memoria virtuale che viene solo riservata, in realtà la memoria fisica effettivamente allocata è in blocchi di 4K, secondo l’effettivo consumo. Perché quindi preoccuparsi?

Nel caso in cui un processo abbia molti thread, potrebbe manifestarsi una situazione problematica. Un processo ha uno spazio di indirizzamento virtuale di 2Gb; anche ammettendo che tutta questa memoria sia disponibile per lo stack (e non può essere), questo limita il numero di thread in un processo a meno di 2.000. Ora, non dovrebbe essere così frequente avere un’applicazione con migliaia di thread, e in un caso reale prenderei seriamente in considerazione l’idea di reingegnerizzare qualcosa, magari usando i thread pool, per avere meno thread da gestire… ma se tutti questi thread sono in realtà sospesi per la maggior parte del tempo (immaginiamo di avere un thread per ogni client connesso, con un traffico molto basso tra client e server), l’applicazione potrebbe funzionare (c’è CPU e c’è memoria fisica) ma si trova a combattere col limite della memoria virtuale disponibile per un processo.

A questo punto viene da dire: perché non richiedere meno di 1Mb per ogni thread? I miei thread non hanno bisogno di tutto quello stack… Bene, qui emerge il problema con .NET: non è possibile richiedere questo servizio con la classe Thread, e nemmeno è possibile modificare lo stack di un thread già creato operando con la classe ProcessThread. La soluzione è quella di bypassare .NET nella creazione del thread fisico e provvedere direttamente chiamando l’API Win32 CreateThread! Non stiamo parlando di una cosa da fare tutti i giorni… ma più di una curiosità architetturale e di un modo per aggirare un problema come quello appena descritto, qualora dovesse verificarsi e non ci sia tempo di sistemare meglio il codice.

Chiamando CreateThread attraverso P/Invoke è possibile specificare, come punto di ingresso del thread, un metodo managed attraverso un delegate. Questa possibilità, ampiamente supportata nei casi di callback in qualche modo “sincrone”, è altrettanto funzionale anche per le callback “asincrone”, come quella che di fatto avviene creando un thread in questa maniera. Ovviamente la situazione che si viene a creare è quella di avere un nuovo thread fisico senza un corrispondente thread logico.

Per risolvere il problema dello stack è poi necessario specificare una dimensione della memoria riservata allo stack attraverso il parametro dwStackSize, fornendo contemporaneamente il valore STACK_SIZE_PARAM_IS_A_RESERVATION al parametro dwCreationFlags. Questa modalità è però supportata solo su Windows XP e Windows 2003: sugli altri sistemi operativi ogni thread ha una memoria riservata definita dal parametro STACKSIZE del linker, lasciando la possibilità di riservare più memoria di quella di default per un singolo thread, ma non di meno.

Il codice che segue mostra un esempio di creazione, in codice managed, di un thread fisico che esegue codice managed. Unica condizione per l’esecuzione del codice è che l’assembly abbia i diritti di esecuzione di codice unmanaged (altrimenti non può fare la chiamate a CreateThread).

DIR: ASSEMBLY_VERSION_OVERRIDE     FILE: FOOASM_V2.CS
using System;
using System.Threading;
using System.Runtime.InteropServices;
class DemoPhysicalThread {
uint Run( IntPtr ptr ) {
for (int i = 0; i < 10; i++) {
Console.WriteLine( "i = {0}; Thread ID = {1}",
i, AppDomain.GetCurrentThreadId() );
Thread.Sleep( 0 );
}
return 0;
}
static void Main() {
Console.WriteLine( "Main Thread ID = {0}",
AppDomain.GetCurrentThreadId() );
DemoPhysicalThread  demo = new DemoPhysicalThread();
uint                threadId;
unsafe {
CreateThread( null,
4096, // stack reservation for new thread
new StartRoutine( demo.Run ),
new IntPtr( null ),
                          STACK_SIZE_PARAM_IS_A_RESERVATION,
out threadId );
Console.WriteLine( "Created thread ID = {0}", threadId );
}
demo.Run( new IntPtr( 0 ) );
}
[DllImport( @"kernel32.dll",
EntryPoint="CreateThread",
CallingConvention=CallingConvention.StdCall,
SetLastError=true)]
public static extern unsafe IntPtr
        CreateThread( SECURITY_ATTRIBUTES* lpThreadAttributes,
uint dwStackSize,
                      StartRoutine startAddress,
IntPtr lpParameter,
uint dwCreationFlags,
                      out uint lpThreadId);
public unsafe delegate uint StartRoutine(IntPtr ptr);
const uint STACK_SIZE_PARAM_IS_A_RESERVATION = 0x00010000;
[StructLayout(LayoutKind.Explicit, Size=12)]
public struct SECURITY_ATTRIBUTES {
[FieldOffset(0)]
public uint nLength;
[FieldOffset(4)]
public unsafe void* lpSecurityDescriptor;
[FieldOffset(8)]
public int bInheritHandle;
}
}

Il risultato ottenuto evidenzia come le chiamate eseguite in un thread diverso da quello iniziale avvengano tutte nel thread creato all’interno del Main.

Main Thread ID = 3324
Created thread ID = 3940
i = 0; Thread ID = 3324
i = 0; Thread ID = 3940
i = 1; Thread ID = 3324
i = 1; Thread ID = 3940
i = 2; Thread ID = 3324
i = 2; Thread ID = 3940
i = 3; Thread ID = 3324
i = 3; Thread ID = 3940
i = 4; Thread ID = 3324
i = 4; Thread ID = 3940
i = 5; Thread ID = 3324
i = 5; Thread ID = 3940
i = 6; Thread ID = 3324
i = 6; Thread ID = 3940
i = 7; Thread ID = 3324
i = 7; Thread ID = 3940
i = 8; Thread ID = 3324
i = 8; Thread ID = 3940
i = 9; Thread ID = 3324
i = 9; Thread ID = 3940

Concludendo, l’aspetto più interessante è che in .NET è possibile prendere il controllo dei thread fisici, a patto di fare una chiamata attraverso P/Invoke. Gli scenari in cui questa possibilità risulta utile sono legati più all’interazione con codice multithread esistente (che può richiamare codice .NET senza problemi) che non alla soluzione del problema di saturazione della memoria virtuale a causa dell’elevato numero di thread in un processo.

Segnalo infine un link interessante relativo a thread, fiber e spazio sullo stack: questo post nel blog di Chris Brumme, uno dei progettisti del CLR.