Sei sulla pagina 1di 10

Tutto su CLR: 9 algoritmi e strutture di dati paralleli riutilizzabili -- MSDN Magazi...

Pagina 1 di 10
Scegliere la lingua:

Italiano

MSDN Home > MSDN Magazine > May 2007 > Tutto su CLR: 9 algoritmi e strutture di dati paralleli riutilizzabili

9 algoritmi e strutture di dati paralleli riutilizzabili Joe Duffy Get the sample code for this article. articolo non incentrato tanto sul funzionamento di una delle funzionalit di CLR (Common Language Runtime), quanto su come utilizzare in Q uesto modo efficiente le funzionalit disponibili. La selezione delle strutture di dati e degli algoritmi appropriati , ovviamente, una delle decisioni pi comuni ma pi importanti che un programmatore deve prendere. La scelta sbagliata pu essere un fattore determinante per il successo o il fallimento del processo di programmazione o, nella maggior parte dei casi, pu avere un impatto diretto sul livello di prestazioni. Poich lo scopo della programmazione parallela in genere garantire il miglioramento delle prestazioni e dal momento che la programmazione parallela in genere pi complessa rispetto alla programmazione seriale, le scelte operate diventano ancora pi determinanti per il successo del programmatore. In questo articolo verranno esaminati nove algoritmi e strutture di dati riutilizzabili comuni a molti programmi paralleli e facilmente adattabili al software .NET. Ogni esempio accompagnato da codice pienamente funzionante, anche se non completamente protetto, testato e ottimizzato. L'elenco non affatto completo ma contiene alcuni dei modelli pi comuni. Come si vedr in seguito, molti degli esempi sono reciprocamente dipendenti. Prima di procedere, opportuno fare una considerazione preliminare. Microsoft .NET Framework dispone di diverse primitive di concorrenza. In questo articolo verr illustrato come creare primitive personalizzate, tenendo presente che quelle esistenti sono comunque applicabili alla maggior parte delle situazioni. Lo scopo dimostrare che talvolta vale la pena prendere in considerazione le alternative disponibili. Inoltre, la possibilit di vedere queste tecniche in azione consentir di approfondire i concetti generali alla base della programmazione parallela. Questa rubrica presuppone, tuttavia, una conoscenza di base delle primitive esistenti. Per una panoramica completa, vedere l'articolo di Vance Morrison "Tutto quello che uno sviluppatore deve sapere sulle applicazioni multithreading" nel numero di agosto 2005 di MSDN Magazine. Fatta questa premessa, si pu passare a esaminare le tecniche.

Countdown latch
I semafori sono per molte ragioni una delle strutture di dati pi note nella programmazione concorrente, anche perch i semafori vantano una lunga tradizione in informatica che risale alla progettazione dei sistemi operativi negli anni sessanta. Un semaforo semplicemente una struttura di dati che contiene un campo di conteggio che supporta due tipi di operazioni: put e take (definite in genere P e V, rispettivamente). Un'operazione put consente di incrementare il conteggio del semaforo di un'unit, mentre l'operazione take consente di decrementarlo di un'unit. Quando il conteggio del semaforo diventa zero, eventuali tentativi successivi di decremento verranno bloccati (messi in attesa) fino a quando un'altra operazione put concorrente non rende il conteggio diverso da zero. Entrambe le operazioni sono atomiche e indipendenti dalla concorrenza, in modo da garantire che operazioni put e take concorrenti vengano serializzate l'una rispetto all'altra. Windows dispone del supporto kernel e Win32 di alto livello per gli oggetti semaforo (vedere CreateSemaphore e le API correlate) che in .NET Framework vengono esposti tramite la classe System.Threading.Semaphore. Le aree critiche, supportate da Mutex e Monitor, sono spesso caratterizzate come un semaforo speciale con un conteggio che alterna 0 e 1, ovvero un semaforo binario. Molto utile pu risultare inoltre il cosiddetto "semaforo inverso". possibile infatti che talvolta sia necessario utilizzare una struttura di dati in grado di attendere che il relativo conteggio raggiunga zero. Il parallelismo fork/join, dove un singolo thread "principale" controlla l'esecuzione di n thread "secondari" e ne attende il completamento, un concetto piuttosto comune nella programmazione parallela dei dati e, per questo tipo di situazioni, un semaforo inverso pu risultare molto utile. Nei casi in cui non si desidera attivare i thread per modificare il conteggio, possibile utilizzare una struttura, definita "latch" per conteggio alla rovescia (countdown latch), a indicare che il conteggio viene decrementato e che, una volta impostato sullo stato segnalato, il latch rimane in tale stato (una propriet spesso associata ai latch). Purtroppo, questa struttura di dati non supportata n in Windows n in .NET Framework. La creazione di una struttura di questo tipo tuttavia un'operazione molto semplice. Per creare un countdown latch, sufficiente inizializzare il relativo contatore su n e configurare ciascun task secondario in modo che lo decrementi atomicamente di un'unit al termine dell'esecuzione, ad esempio racchiudendo l'operazione di decremento con un blocco o eseguendo una chiamata a Interlocked.Decrement. Pertanto, senza utilizzare un'operazione take, un thread pu decrementare il contatore e attendere che si azzeri; quando attivato, sar in grado di rilevare che n segnali sono stati registrati con il latch. Anzich attendere che la condizione si verifichi, come in while (count != 0), consigliabile fare in modo che il thread in attesa si blocchi, con la necessit di utilizzare un evento. Nella Figura 1 viene illustrato un esempio di un tipo semplice di CountdownLatch. Si tratta di un tipo molto semplice, ma il relativo utilizzo pu rivelarsi particolarmente complesso. Alcuni esempi di utilizzo di questa struttura di dati verranno illustrati pi avanti nell'articolo. Tenere presente cha l'implementazione di base illustrata in questo articolo consente di introdurre diversi miglioramenti, tra cui: aggiunta di un certo grado di attesa (spin-waiting) prima di richiamare WaitOne sull'evento, allocando l'evento anzich eseguire questa operazione nel costruttore (nel caso in cui l'attesa sia sufficiente per evitare il blocco, come dimostrato da ThinEvent pi avanti in questo articolo), aggiunta di funzionalit di reimpostazione e utilizzo di un metodo Dispose in modo che l'oggetto evento interno possa essere chiuso quando non pi necessario. Questi sono tutti esercizi che il lettore pu eseguire.

Attesa riutilizzabile
Sebbene il blocco sia generalmente preferibile all'attesa attiva (busy waiting), si verificano alcune situazioni in cui preferibile entrare in un loop di attesa prima di tornare a uno stato di vera e propria attesa. La ragione dell'utilit di questa tecnica sottile e molti utenti inizialmente evitano l'attesa perch sembra implicare una notevole perdita di tempo; se una commutazione di contesto (che si verifica ogni volta che un thread attende su un evento kernel) richiede l'esecuzione di diverse migliaia di cicli (che chiameremo c) in Windows e se la condizione di cui il thread in attesa si verifica in un tempo inferiore a 2c cicli (1c per l'attesa e 1c per l'attivazione), l'attesa potrebbe ridurre l'overhead e la latenza causati dall'attesa, con conseguente ottimizzazione della velocit effettiva e della scalabilit globali dell'algoritmo in uso. Dopo aver deciso di utilizzare un'attesa attiva, necessario muoversi con cautela. L'utilizzo di questa tecnica prevede l'esecuzione di diverse operazioni: occorre chiamare Thread.SpinWait all'interno del loop di attesa per migliorare la disponibilit dell'hardware per altri thread hardware su computer Intel Hyper-threading; necessario chiamare ogni tanto Thread.Sleep con l'argomento 1 anzich 0 per evitare l'inversione delle priorit; utilizzare un piccolo intervallo di back-off per introdurre la sequenza casuale, migliorare la localit (presumendo che il chiamante esegua continuamente una nuova lettura dello stato condiviso) ed eventualmente evitare il blocco in attesa (livelock); ovviamente, cedere sempre la precedenza su un computer a singola PCU (in quanto l'attesa in tali ambienti si rivela estremamente dispendioso). La classe SpinWait viene definita come tipo valore in modo che l'allocazione non sia dispendiosa (vedere la Figura 2 ). A questo punto, possibile

http://msdn.microsoft.com/msdnmag/issues/07/05/CLRInsideOut/default.aspx?print=t... 11/05/2007 PDF created with pdfFactory trial version www.pdffactory.com

Tutto su CLR: 9 algoritmi e strutture di dati paralleli riutilizzabili -- MSDN Magazi... Pagina 2 di 10
utilizzare questo algoritmo per evitare il blocco nell'algoritmo CountdownLatch illustrato in precedenza:

private const int s_spinCount = 4000; public void Wait() { SpinWait s = new SpinWait(); while (m_remain > 0) { if (s.Spin() >= s_spinCount) m_event.WaitOne(); } }
La scelta della frequenza di cessione della precedenza e il conteggio del numero di loop di attesa arbitraria. Analogamente al numero di loop di attesa delle sezioni critiche di Win32 , questi numeri devono essere scelti sulla base di test e sperimentazioni e la risposta corretta tende a differire da un sistema all'altro. Nella documentazione di MSDN, ad esempio, si consiglia un numero pari a 4.000 per le sezioni critiche sulla base dell'esperienza dei team responsabili per il kernel di Microsoft Media Center e Windows, ma questo numero potrebbe variare. Il numero perfetto dipende da molti fattori, tra cui il numero di thread in attesa di un evento in un determinato momento, la frequenza con cui gli eventi si verificano e cos via. Nella maggior parte dei casi, opportuno eliminare le cessioni esplicite e attendere un evento, come illustrato nell'esempio relativo al latch. possibile anche scegliere un conteggio che si regola in modo dinamico: ad esempio, iniziare con un numero medio di attese e ogni volta che l'attesa ha esito negativo, incrementare il numero. Quando il numero raggiunge il valore massimo prestabilito, interrompere l'attesa e chiamare immediatamente WaitOne. La logica viene illustrata di seguito: si disposti a raggiungere solo il numero massimo prestabilito di cicli, senza superare tale numero. Se si rileva che il numero massimo prestabilito non sia sufficiente a impedire la commutazione di contesto, l'esecuzione immediata della commutazione di contesto comporter un minor utilizzo delle risorse. Con il passare del tempo, si spera che il numero di attese raggiunga un valore stabile.

Barriere
Una barriera, nota anche come punto rendezvous, una primitiva di concorrenza che consente il coordinamento reciproco dei thread, eliminando la necessit di un altro thread "principale" che gestisce ogni tipo di operazione. Ciascun thread, una volta raggiunta la barriera, trasmette segnali e si mette in attesa in modo atomico. Tutti i thread possono procedere solo quando tutti gli n hanno raggiunto la barriera. Questa tecnica pu essere utilizzata per gli algoritmi cooperativi, come quelli comunemente utilizzati nei domini scientifici, matematici e grafici. L'utilizzo di barriere appropriato in molti calcoli. Vengono infatti utilizzate anche dal Garbage Collector di CLR. Le barriere suddividono semplicemente un calcolo pi grande in fasi cooperative pi piccole, ad esempio:

const int P = ...; Barrier barrier = new Barrier(P); Data[] partitions = new Data[P]; // Running on P separate threads in parallel: public void Body(int myIndex) { FillMyPartition(partitions[myIndex]); barrier.Await(); ReadOtherPartition(partitions[P myIndex - 1]); barrier.Await(); // ... }
Risulter immediatamente chiaro che in questa situazione possibile utilizzare un countdown latch. Invece di chiamare Await, ciascun thread esegue una chiamata a Signal seguita immediatamente da una chiamata a Wait; tutti i thread verranno rilasciati una volta raggiunta la barriera. Esiste tuttavia un problema: il latch sopra illustrato non supporta il riutilizzo dello stesso oggetto, mentre una propriet utile dovrebbe fornire il supporto per tutte le barriere. L'esempio precedente richiede questo tipo di supporto. A tal fine, possibile utilizzare oggetti barriera separati, ma questo potrebbe essere dispendioso; non necessario utilizzare pi di una barriera, poich tutti i thread si trovano in una sola fase per volta. Per risolvere il problema, possibile iniziare con lo stesso algoritmo countdown latch di base per gestire il decremento del contatore, la segnalazione dell'evento, l'attesa e cos via, estendendolo fino a introdurre il supporto per il riutilizzo. A tal fine, necessario utilizzare la cosiddetta barriera di tipo sense-reversing, che richiede l'alternanza tra fasi "pari" e "dispari". Per le fasi alternative verr utilizzato un evento separato. Nella Figura 3 viene illustrata un'implementazione di questa struttura di dati Barrier. Il motivo della necessit di utilizzare due eventi sottile. Un approccio potrebbe consistere nell'eseguire una chiamata a Set seguita immediatamente da una chiamata a Reset in Await, ma questo metodo pericoloso e pu causare situazioni di deadlock per due motivi. Primo, un altro thread potrebbe aver decrementato m_count, ma potrebbe non avere ancora raggiunto la chiamata a WaitOne sull'evento quando il thread chiama Set e immediatamente dopo Reset in rapida successione. Secondo, sebbene il thread di attesa potrebbe aver raggiunto la chiamata a WaitOne, attese di avviso, come quelle utilizzate sempre in CLR, possono interrompere l'attesa, rimuovendo temporaneamente il thread dalla coda di attesa in modo che possa eseguire una chiamata asincrona di procedura (APC). Il thread di attesa non vedr mai l'evento in uno stato set. Entrambi gli approcci possono impedire la generazione di eventi e causare possibili deadlock. possibile evitare una situazione di questo tipo utilizzando eventi separati per le fasi pari e dispari. possibile aggiungere l'attesa alla barriera, come con CountdownLatch. Ma questo tentativo pu generare qualche problema: in genere, il thread entra in un loop di attesa fino a quando non rileva un m_count pari a 0. Con l'implementazione sopra riportata, tuttavia, m_count non raggiunger mai 0 fino a quando non viene reimpostato dall'ultimo thread su m_originalCount. L'approccio piuttosto ingenuo all'attesa determinerebbe una situazione in cui uno o pi thread entra costantemente in un loop di attesa e tutti gli altri thread restano bloccati in modo permanente nella fase successiva. La soluzione semplice. Si resta in attesa del cambio di direzione, come illustrato nella Figura 4 . Poich tutti i thread devono lasciare il metodo Await della fase precedente per consentire il completamento della fase successiva, tutti i thread rileveranno il cambiamento di direzione o si metteranno in attesa dell'evento e verranno attivati in questo modo.

Coda di blocco
Nelle architetture con memoria condivisa, l'unico punto di sincronizzazione tra due o pi attivit in genere una struttura di dati centrale di insiemi condivisi. In genere, uno o pi task sono responsabili della generazione di "elementi di lavoro" che devono essere utilizzati da altri task, una situazione definita relazione producer/consumer. La sincronizzazione per tali strutture di dati in genere molto semplice e pu essere eseguita utilizzando Monitor o ReaderWriterLock, ma il coordinamento dei vai task quando il buffer diventa pieno piuttosto difficoltoso. Il problema si risolve in genere con una coda di blocco. Esistono, in effetti, delle piccole varianti per il blocco delle code, dalla pi semplice (il blocco del consumer avviene solo quando la coda vuota) alla pi complessa (ciascun producer "abbinato" a un solo consumer; vale a dire, il producer resta bloccato finch un consumer arriva a elaborare l'elemento in coda e, allo stesso modo, il consumer resta bloccato finch un producer consegna un elemento). L'ordinamento FIFO (First In First Out)

http://msdn.microsoft.com/msdnmag/issues/07/05/CLRInsideOut/default.aspx?print=t... 11/05/2007 PDF created with pdfFactory trial version www.pdffactory.com

Tutto su CLR: 9 algoritmi e strutture di dati paralleli riutilizzabili -- MSDN Magazi... Pagina 3 di 10
comune, ma non sempre necessario. Inoltre possibile limitare i buffer, come si vedr in seguito. In questa sezione verr analizzata solo la variante di abbinamento, poich il buffer limitato illustrato di seguito comprende il comportamento pi semplice del blocco in caso di coda vuota. Per implementare questo comportamento, sufficiente eseguire il wrapping di una semplice Queue<T> con la sincronizzazione all'inizio. Che tipo di sincronizzazione? Quando inserisce un elemento in coda, un thread resta in attesa di un consumer per annullare l'accodamento dell'elemento prima della restituzione. Quando un thread annulla l'accodamento di un elemento, se trova il buffer vuoto, deve restare in attesa dell'arrivo di un nuovo elemento. Dopo l'annullamento dell'accodamento, ovvio che il consumer deve segnalare al producer l'acquisizione dell'elemento (vedere la Figura5) . Si noti la chiamata di Pulse e quindi di Wait nel metodo Enqueue e, allo stesso modo, di Wait e quindi di Pulse in Dequeue. A causa della modalit di implementazione del monitor (gli eventi interni sono impostati prima del rilascio del monitor), possibile che si verifichi un ping-pong nella pianificazione del thread. possibile creare, invece, un meccanismo di notifica pi sofisticato, utilizzando magari gli eventi Win32. Tuttavia, un simile utilizzo degli eventi Win32 pu introdurre una certa quantit di overhead, in particolare costi di allocazione e transizione del kernel quando utilizzati, quindi potrebbe essere opportuno considerare delle alternative. Si potrebbe raggrupparli, come con ReaderWriterLock di CLR, oppure allocarli, come con il tipo ThinEvent (illustrato pi avanti). Questa implementazione presenta inoltre lo svantaggio di allocare un oggetto per ogni elemento nuovo; un approccio alternativo potrebbe essere il raggruppamento anche di questi oggetti, ma in tal modo si aggiungerebbero delle complessit.

Buffer limitato
possibile che si presenti un problema di consumo delle risorse in alcuni tipi di code. Se con le attivit del producer si creano elementi a una velocit maggiore delle possibilit di elaborazione del consumer, il sistema potrebbe perdere il controllo sull'utilizzo della memoria. Immaginare, ad esempio, un sistema in cui un singolo producer accoda 50 elementi/secondo che il consumer utilizza a una velocit di soli 10 elementi/secondo. Prima di tutto, il sistema sar squilibrato e non scaler in modo corretto in una configurazione di producer/consumer 1-a-1. Dopo un solo minuto, nel buffer si accumuleranno 2.400 elementi. Se gli elementi utilizzano, ad esempio, 10 KB ciascuno, si arriva a 24 MB di memoria solo per il buffer. Dopo un'ora, la quota giunger a oltre 1 GB. Una possibile soluzione la ponderazione del numero di thread di producer rispetto ai thread di consumer che, in questo caso, significa un rapporto di un producer per cinque consumer. Ma le velocit di arrivo degli elementi sono spesso volatili e provocano squilibri periodici con conseguenti problemi potenzialmente drammatici. Un semplice rapporto prefissato tra le parti non risolverebbe il problema. Su un server, in cui i programmi sono spesso a esecuzione prolungata e devono garantire buoni tempi di attivit, l'utilizzo illimitato della memoria pu provocare una devastazione assoluta, portando a una situazione in cui il processo del server deve essere riavviato con regolarit. Un buffer limitato consente di definire un limite per le dimensioni che il buffer pu raggiungere prima che venga forzato il blocco del producer. Il blocco del producer offre al consumer l'opportunit di "allinearsi" (consentendo al rispettivo thread di ricevere un intervallo di tempo di pianificazione) eliminando allo stesso tempo il problema di occupazione della memoria. L'approccio proposto, ancora una volta, consiste nell'eseguire semplicemente il wrapping di una Queue<T>, aggiungendo due condizioni di attesa e due condizioni di notifica evento: un producer attende quando la coda piena (fino a che diventa non piena) e un consumer attende quando la coda vuota (fino a che diventa non vuota); un producer segnala ai consumer in attesa che stato prodotto un elemento e un consumer segnala a un producer che ha acquisito un elemento, come illustrato nella Figura 6 . Si adottato un approccio un po' ingenuo. Ma si ottimizzano le chiamate a PulseAll, poich sono tutt'altro che economiche, gestendo due contatori, m_consumersWaiting e m_producersWaiting, e inviando segnalazioni solo quando il valore rispettivo diverso da zero. Esistono ulteriori possibilit di miglioramento. La condivisione, ad esempio, di un solo evento come questo potrebbe attivare troppi thread: Se un consumer riduce le dimensioni della coda a 0 ed esistono sia consumer che producer in attesa, necessario attivare soltanto i producer (almeno in un primo momento). Con questa implementazione si servono tutti gli oggetti waiter in ordine FIFO. Prima che venga eseguito un producer potrebbe quindi essere necessario attivare i consumer, solo perch scoprano che la coda vuota e tornino in attesa. Per fortuna la presenza contemporanea di producer e consumer in attesa un evento abbastanza raro, ma pu accadere con regolarit in caso di limiti di dimensioni ridotte.

Thin Event
Gli eventi Win32 hanno un grande vantaggio rispetto a Monitor.Wait, Pulse e PulseAll: sono "persistenti". Significa che, una volta che un evento stato segnalato, qualunque attesa successiva si sbloccher immediatamente, anche se l'attesa dei thread non era ancora iniziata al momento del segnale. Senza questa funzionalit, sarebbe necessario scrivere spesso del codice non efficace nei punti in cui si verifica l'attesa e la segnalazione all'interno di una regione critica (non efficace perch Windows Scheduler incrementa sempre la priorit di un thread attivato, producendo un cambio di contesto solo per rimettere immediatamente in attesa il thread attivato per l'area critica) oppure impiegare del codice complesso, soggetto a race condition. Invece di questi due approcci, possibile utilizzare un "thin event", una struttura di dati riutilizzabile che resta brevemente in attesa prima del blocco, alloca un evento Win32 solo quando necessario e garantisce un comportamento simile al ripristino manuale dell'evento. In altre parole, incapsula il codice complesso e soggetto a race condition in modo che non sia necessario disperderlo nell'intera base di codice. L'esempio poggia su alcune garanzie di modello di memoria descritte nell'articolo di Vance Morrison e deve essere utilizzato estrema cautela (vedere la Figura 7). In sostanza si effettua il mirroring dello stato dell'evento in una variabile m_state, dove il valore 0 significa "non impostata" e 1 significa "impostata". L'attesa di un evento impostato ora davvero economica; se m_state 1 in ingresso nella routine Wait, la restituzione immediata, senza alcuna necessit di transizioni di kernel. Il problema sorge quando un thread resta in attesa prima che l'evento venga impostato. Il primo thread in attesa deve allocare un nuovo oggetto evento ed eseguirne confronto e commutazione nel campo m_eventObj; se l'esito CAS negativo, significa che un altro oggetto waiter ha inizializzato l'evento, dunque possibile riutilizzarlo; in caso contrario, necessario verificare di nuovo che m_state non sia cambiato dall'ultimo utilizzo. Altrimenti, m_state potrebbe essere 1 e m_eventObj potrebbero non essere segnalato, conducendo a un deadlock quando si chiama WaitOne. Il thread che chiama Set deve prima impostare m_state, quindi, se rileva un m_eventObj non nullo, chiamare Set su questo. Sono necessarie due barriere di memoria: la seconda lettura di m_state non deve essere spostata prima, cosa che si garantisce con l'uso di Interlocked.CompareExchange per impostare m_eventObj. La lettura di m_eventObj in Set non deve essere spostata prima della scrittura in m_eventObj (una trasformazione legale un po' sorprendente su alcuni processori Intel e AMD, e il modello di memoria CLR 2.0, senza la chiamata esplicita a Thread.MemoryBarrier). Non in genere consigliabile eseguire il ripristino dell'evento in parallelo, dunque necessaria un'ulteriore sincronizzazione dal chiamante. possibile utilizzare facilmente questo approccio altrove, come in CountdownLatch e negli esempi di coda precedenti, in genere con un notevole miglioramento delle prestazioni, in particolare se si utilizzano le attese in modo intelligente. L'implementazione illustrata sopra relativamente complessa. tuttavia possibile implementare entrambi i tipi di ripristino automatico e manuale utilizzando un singolo flag e dei monitor, semplificando di molto l'esempio (ma non sempre con gli stessi livelli di efficacia).

Stack LIFO dei componenti senza blocchi


La creazione di una raccolta a prova di thread utilizzando i blocchi molto semplice, anche quando si limitano o bloccano elementi complessi (come visto in precedenza). Quando tutta la coordinazione viene eseguita in base a una semplice struttura dello stack di dati di tipo LIFO (Last In First Out), tuttavia, l'utilizzo di un blocco potrebbe risultare pi costoso del necessario. Un'area critica di un thread, il periodo durante il quale viene mantenuto un blocco, ha un inizio e una fine e la durata pu estendersi per un tempo pari a un numero significativo di istruzioni. Il mantenimento del blocco impedisce ad altri thread di leggere e scrivere simultaneamente. In tal modo si ottiene una serializzazione, che ci che si desidera, ma che va anche oltre le effettive esigenze: in pratica non si fa altro che eseguire il push o il pop degli elementi di uno stack ed entrambe le operazioni possono essere eseguite con normali letture e una singola scrittura di confronto e commutazione. possibile sfruttare questa opportunit per creare uno stack pi scalabile e meno condizionato dai blocchi, che non obbliga i thread ad attendere inutilmente. L'algoritmo funziona come segue. Si utilizza un elenco collegato per rappresentare lo stack, la cui testa rappresenta l'inizio dello stack, memorizzato nel campo m_head. Quando si esegue il push di un nuovo elemento nello stack, si crea un nuovo nodo con il valore di cui si intende eseguire il push nello stack, si legge il campo m_head in locale, lo si memorizza nel campo m_next del nuovo nodo e si esegue un Interlocked.CompareExchange

http://msdn.microsoft.com/msdnmag/issues/07/05/CLRInsideOut/default.aspx?print=t... 11/05/2007 PDF created with pdfFactory trial version www.pdffactory.com

Tutto su CLR: 9 algoritmi e strutture di dati paralleli riutilizzabili -- MSDN Magazi... Pagina 4 di 10
atomico per sostituire la testa corrente dello stack. Se la testa viene modificata in un qualunque punto di questa sequenza (poich la lettura stata eseguita prima), CompareExchange non riuscir e il thread dovr entrare in loop ed eseguire di nuovo l'intera sequenza. Le operazioni di pop sono semplici allo stesso modo. Si legge m_head e si tenta di commutarlo con il riferimento m_next della copia locale; se l'operazione non riesce, si continua a provare, come si vede nella Figura 8 . Win32 offre una struttura di dati analoga, denominata SList, che viene creata utilizzando un algoritmo simile. Si noti che questa una forma di controllo della concorrenza ottimistica: invece di impedire agli altri thread di accedere ai dati, si procede con la speranza di "vincere" la corsa. Se la speranza non si concretizza, possibile che si verifichino problemi di attivit come un livelock. Questa scelta di progettazione implica inoltre l'impossibilit di ottenere una pianificazione FIFO attendibile. Tutti i thread nel sistema avanzeranno in modo probabilistico. Infatti, il sistema ha nel complesso un avanzamento deterministico, poich la mancata esecuzione di un thread significa sempre che almeno un altro thread avanzato (un requisito per definire il progetto "senza blocchi"). A volte utile utilizzare un backoff esponenziale quando un CompareExchange non consente di evitare conflitti di memoria su m_head. Si , inoltre, assunto un approccio piuttosto ingenuo per i casi in cui lo stack risulta vuoto. L'attesa del push di un elemento nuovo viene impostata come eterna. semplice riscrivere Pop in un metodo TryPop senza attesa e solo un po' pi complesso utilizzare degli eventi per l'attesa. Sono entrambe funzionalit importanti, lasciate come esercitazione per i lettori pi motivati. Si verifica un'allocazione di oggetto per ogni Push, evitando le preoccupazioni per i cosiddetti problemi di ABA. L'ABA si verifica a causa del riutilizzo interno di nodi gi esclusi dall''elenco. Gli sviluppatori a volte tenteranno di raggruppare i nodi per ridurre il numero di allocazioni di oggetti, ma questa un'operazione problematica: il risultato pu essere un'operazione atomica che riesce per errore anche se sono state eseguite numerose scritture in m_head (ad esempio: il nodo A viene letto dal thread 1, poi rimosso dal thread 2 e collocato nel pool; viene eseguito il push di B come nuova testa dal thread 2, quindi A viene restituito dal pool al thread 2 e ne viene eseguito il push; il thread 1 riesce quindi a eseguire CompareExchange, anche adesso la testa di A diversa da quella letta in precedenza). Un problema simile si verifica quando si tenta di scrivere questo algoritmo in C/C++ nativo; poich l'allocatore di memoria pu riutilizzare gli indirizzi non appena questi vengono liberati, possibile che venga eseguito il pop di un nodo, che il nodo venga liberato e quindi l'indirizzo corrispondente distribuito a una nuova allocazione di nodo, causando lo stesso problema. In questo articolo non si approfondir oltre il discorso su ABA, gi illustrato nel dettaglio altrove. Infine, possibile scrivere in una coda FIFO utilizzando le stesse tecniche senza blocchi. Questa possibilit attraente perch i thread in push e pop contemporaneamente non sono necessariamente in conflitto, al contrario del LockFreeStack illustrato sopra, in cui pusher e popper sono sempre in competizione per lo stesso campo m_head. L'algoritmo piuttosto complesso, tuttavia, quindi se si desidera approfondire, consultare il documento di Maged M. Michael e Michael L. Scott del 1996, "Algoritmi semplici, veloci e pratici per code contemporanee con e senza blocchi".

Partizionamento del loop (loop tiling)


Per loop tiling si intende la pratica di partizionare l'intervallo di input o i dati per un loop e assegnare ciascuna partizione a un thread diverso, allo scopo di ottenere la contemporaneit. Questa la tecnica principale per conseguire il parallelismo con alcuni modelli di programmazione, come OpenMP (vedere l'articolo di Kang Su Gatlin su MSDN Magazine ) e viene spesso definita loop forall parallelo, secondo la terminologia FORTRAN. Non importa se l'intervallo solo una serie di indici:

for (int i = 0; i < c; i++) { ... }


o un intervallo di dati:

foreach (T e in list) { ... }


possibile progettare delle tecniche per ottenere un partizionamento del loop. Esistono molte tecniche di partizionamento specifiche delle strutture di dati che possibile applicare, sicuramente troppe per una trattazione esaustiva in questo articolo. Si concentrer l'attenzione su una tecnica comune, che consiste nell'assegnare intervalli disgiunti di elementi dalla matrice a ciascuna partizione. sufficiente calcolare uno stride, che corrisponde approssimativamente al numero di elementi diviso per il numero di partizioni, e utilizzare il risultato per calcolare gli intervalli contigui (vedere la Figura 9). In tal modo si ottiene una buona localizzazione spaziale quando l'input una matrice di tipi di valore, sebbene altri approcci siano altrettanto validi, utili e, a volte, necessari. Qui sono disponibili due versioni pubbliche di ForAll: una che accetta un intervallo di numeri e l'altra che accetta un IList<T>, come un loop foreach di C#. Entrambi prevedono l'inoltro allo stesso overload dell'helper, che chiama un'azione passando l'elemento dall'elenco all'indice dato oppure passa l'indice vero e proprio. possibile utilizzare il primo overload in cui si collocherebbe in genere un normale loop for. Ad esempio, questo codice:

for (int i = 0; i < 10; i++) { S; }


diventa:

Parallel.ForAll(0, 10, delegate(int i){ S; }, Environment.ProcessorCount);


possibile utilizzare il secondo nel punto in cui in genere si collocherebbe un loop foreach di C#, in modo che

List<T> list = ...; foreach (T e in list) { S; }


diventi:

Parallel.ForAll(list, delegate(T e) { S; }, Environment.ProcessorCount);


necessario prestare particolare attenzione che nessuna delle istruzioni in S scriva nella memoria condivisa, altrimenti si dovr aggiungere la sincronizzazione corretta per le versioni parallele. ovviamente possibile scrivere delle versioni per l'utilizzo di eventuali IEnumerable<T>, partizionare lo spazio di iterazione in modo diverso e cos via (tutti questi argomenti sono stati omessi da questo articolo per motivi di spazio). Nell'esempio il thread di chiamata "sprecato" per la durata di n sottoattivit. Un approccio migliore sarebbe utilizzare il thread di chiamata per eseguire una delle attivit ed eseguire, quindi, al termine un ricongiungimento con gli altri. L'estensione del metodo ForAll per eseguire questa operazione semplice.

Riduzioni parallele

http://msdn.microsoft.com/msdnmag/issues/07/05/CLRInsideOut/default.aspx?print=t... 11/05/2007 PDF created with pdfFactory trial version www.pdffactory.com

Tutto su CLR: 9 algoritmi e strutture di dati paralleli riutilizzabili -- MSDN Magazi... Pagina 5 di 10
C' una categoria di operazioni che pu essere eseguita utilizzando una riduzione (anche nota come una fold o aggregazione) con cui molti valori vengono combinati in qualche modo per produrre un unico output. Una riduzione generale funziona come segue. Si prende un operatore binario, vale a dire una funzione con due argomenti, e lo si calcola su un vettore o su una serie di elementi di dimensione n, da sinistra a destra. Per j = da 0 a n - 1, si chiama l'operatore binario, passando come input all'iterazione jth l'output della chiamata dell'operatore sull'elemento j - 1 come primo argomento e l'elemento jth come secondo argomento. Uno valore di inizializzazione speciale viene utilizzato per il primo argomento all'elemento 0, poich non esistono valori precedenti da utilizzare. Viene quindi utilizzato un selettore di risultato finale (e facoltativo) per convertire un valore intermedio in un risultato finale. Di seguito ne riportato un esempio. Se l'operatore binario + e l'input un vettore di 5 elementi, {1, 2, 3, 4, 5}, il calcolo espanso sar ((((1 + 2) + 3) + 4) + 5). Se si converte questa espansione nella forma di chiamata della funzione, si ottiene quanto segue (presupponendo un valore di inizializzazione 0): +(+(+(+(+(0, 1), 2), 3), 4), 5). In altre parole, si calcola solo la somma di tutti i numeri dell'input. Questa viene definita riduzione della somma. Una semplice traduzione di questo algoritmo generalizzato in un algoritmo seriale potrebbe essere il seguente:

delegate T Func<T>(T arg0, T arg1); T Reduce<T>(T[] input, T seed, Func<T> r) { T result = seed; foreach (T e in input) result = r(result, e); return result; }
Quando lo si chiama, ora sufficiente sommare una serie di numeri come la seguente (in C# 3.0):

int[] nums = ... some set of numbers ...; int sum = Reduce(nums, 0, (x,y) => x + y;);
tutto molto astratto ma, oltre alla somma, possibile esprimere molte operazioni come una riduzione, come si vede nella Figura 10 . Data la matrice di numeri precedente, possibile utilizzare la routine di riduzione per trovare il minimo e il massimo della matrice:

int min int max

= Reduce(nums, int.MaxValue, (x,y) => x < y ? x : y;); = Reduce(nums, int.MinValue, (x,y) => x > y ? x : y;);

(Count viene omesso perch i risultati parziali devono essere sommati, richiedendo due operatori binari separati, e anche Average viene omesso perch richiede alcuni passaggi aggiuntivi). possibile utilizzare una tecnica simile a quella illustrata per partizionare il loop e suddividere i dati di input ed eseguire la riduzione in parallelo. Ciascuna partizione calcoler il proprio valore intermedio e tutti i valori verranno quindi combinati in un valore solo finale, utilizzando lo stesso operatore impiegato per calcolare i valori intermedi. Perch questa operazione possibile? Perch tutte le operazioni menzionate in precedenza sono associative; applicando l'aritmetica delle elementari, un operatore associativo binario + significa semplicemente che (a + b) + c = a + (b + c); ovvero, l'ordine degli addendi non conta per la correttezza del calcolo. Si consideri, ad esempio, la riduzione della somma. Se si partizionano i dati di input { 1, 2, 3, 4 } in due sezioni, {1, 2} e {3, 4}, quindi, poich + associativo, quando si aggiungono i risultati delle addizioni indipendenti, il risultato non cambia: (1 + 2) + (3 + 4) = ((((1 + 2) + 3) + 4). Infatti, qualunque partizionamento disgiunto dell'input produce risultati corretti. Nella Figura 11 viene illustrato un metodo Reduce generalizzato che assume il valore di inizializzazione e l'operatore binario come argomenti, utilizzando un approccio di partizionamento stride come descritto in precedenza. Il thread principale acquisisce una serie di thread di lavoro p, ciascuno dei quali calcola un risultato intermedio eseguendo una riduzione sulla propria partizione di dati e collocando il valore nel rispettivo slot dedicato nella matrice di valori intermedi. Il thread principale resta in attesa che tutti i thread secondari abbiano terminato le operazioni (utilizzando il CountdownLatch precedente) ed esegue una riduzione finale utilizzando i risultati parziali di ciascun thread secondario come input. In letteratura le riduzioni di strutture sono abbastanza comuni, dove ciascun nodo della struttura esegue una riduzione parziale su un certo numero di risultati intermedi. Mentre questo conduce teoricamente a un algoritmo pi scalabile, con il numero di thread che si in grado di gestire e i costi di sincronizzazione associati su Windows, la realt che la riduzione seriale, come illustrato, funziona meglio per i valori comuni di p e gli operatori binari. Esistono molte opportunit di ottimizzazione qui, come il riutilizzo del thread di chiamata per una delle partizioni, tuttavia l'esempio basta a illustrare il punto abbastanza bene. Per supportare le operazioni come il calcolo della media, dove l'operazione di riduzione intermedia diversa per le fasi di riduzione intermedia e finale e dove necessaria una routine di selezione del risultato finale, si rende necessaria un'API un po' diversa. Questo un esercizio piuttosto semplice.

Conclusioni
In questo articolo si sono illustrati alcuni algoritmi e alcune strutture di dati parallele di basso livello che possono facilitare la scrittura di codice gestito che sfrutti le architetture a pi processori e pi core. Come in tutto il lavoro di programmazione, le astrazioni tendono ad ammassarsi a strati, con le parti critiche per le prestazioni relegate in genere ai livelli pi bassi. possibile affermare che molte delle tecniche illustrate in questo articolo sono a basso livello e possono servire da fondamenta per astrazioni di livello pi alto e per il codice parallelo specifico delle applicazioni. Sebbene la scelta delle strutture di dati e degli algoritmi di base corretti rappresenti solo un passaggio di un processo pi lungo, si spera che il lettore abbia acquisito conoscenze pi approfondite delle tecniche di programmazione parallela, utilizzabili per lavorare con successo in questo nuovo orizzonte della programmazione. Per inviare domande e commenti, l'indirizzo clrinout@microsoft.com.
Joe Duffy si occupa di modelli e infrastrutture di programmazione parallela presso Microsoft e scrive periodicamente sul suo blog all'indirizzo www.bluebytesoftware.com/blog. Alcuni esempi di codice in questo articolo sono basati sul suo prossimo libro, Concurrent Programming on Windows , che verr pubblicato da Addison Wesley nel 2007.

Dal May 2007 numero di MSDN Magazine.

Figure 1 CountdownLatch

public class CountdownLatch {

http://msdn.microsoft.com/msdnmag/issues/07/05/CLRInsideOut/default.aspx?print=t... 11/05/2007 PDF created with pdfFactory trial version www.pdffactory.com

Tutto su CLR: 9 algoritmi e strutture di dati paralleli riutilizzabili -- MSDN Magazi... Pagina 6 di 10
private int m_remain; private EventWaitHandle m_event; public CountdownLatch(int count) { m_remain = count; m_event = new ManualResetEvent(false); } public void Signal() { // The last thread to signal also sets the event. if (Interlocked.Decrement(ref m_remain) == 0) m_event.Set(); } public void Wait() { m_event.WaitOne(); } }

Figure 2 SpinWait

public struct SpinWait { private int m_count; private static readonly bool s_isSingleProc = (Environment.ProcessorCount == 1); private const int s_yieldFrequency = 4000; private const int s_yieldOneFrequency = 3*s_yieldFrequency; public int Spin() { int oldCount = m_count; // On a single-CPU machine, we ensure our counter is always // a multiple of s_yieldFrequency, so we yield every time. // Else, we just increment by one. m_count += (s_isSingleProc ? s_yieldFrequency : 1); // If not a multiple of s_yieldFrequency spin (w/ backoff). int countModFrequency = m_count % s_yieldFrequency; if (countModFrequency > 0) Thread.SpinWait((int)(1 + (countModFrequency * 0.05f))); else Thread.Sleep(m_count <= s_yieldOneFrequency ? 0 : 1); return oldCount; } private void Yield() { Thread.Sleep(m_count < s_yieldOneFrequency ? 0 : 1); } }

Figure 3 Barriera

using System; using System.Threading; public class Barrier { private volatile int m_count; private int m_originalCount; private EventWaitHandle m_oddEvent; private EventWaitHandle m_evenEvent; private volatile bool m_sense = false; // false==even, true==odd. public Barrier(int count) { m_count = count; m_originalCount = count; m_oddEvent = new ManualResetEvent(false); m_evenEvent = new ManualResetEvent(false); } public void Await() { bool sense = m_sense; // The last thread to signal also sets the event. if (m_count == 1 || Interlocked.Decrement(ref m_count) == 0) { m_count = m_originalCount; m_sense = !sense; // Reverse the sense. if (sense == true) { // odd m_evenEvent.Reset(); m_oddEvent.Set(); } else { // even m_oddEvent.Reset(); m_evenEvent.Set(); } } else { if (sense == true) m_oddEvent.WaitOne(); else m_evenEvent.WaitOne();

http://msdn.microsoft.com/msdnmag/issues/07/05/CLRInsideOut/default.aspx?print=t... 11/05/2007 PDF created with pdfFactory trial version www.pdffactory.com

Tutto su CLR: 9 algoritmi e strutture di dati paralleli riutilizzabili -- MSDN Magazi... Pagina 7 di 10
} } }

Figure 4 Attesa

public void Await() { bool sense = m_sense; // The last thread to signal also sets the event. if (m_count == 1 || Interlocked.Decrement(ref m_count) == 0) { m_count = m_originalCount; m_sense = !sense; // Reverse the sense. if (sense == true) { // odd m_evenEvent.Set(); m_oddEvent.Reset(); } else { // even m_oddEvent.Set(); m_evenEvent.Reset(); } } else { SpinWait s = new SpinWait(); while (sense == m_sense) { if (s.Spin() >= s_spinCount) { if (sense == true) m_oddEvent.WaitOne(); else m_evenEvent.WaitOne(); } } } }

Figure 5 Coda di blocco

class Cell<T> { internal T m_obj; internal Cell(T obj) { m_obj = obj; } } public class BlockingQueue<T> { private Queue<Cell<T>> m_queue = new Queue<Cell<T>>(); public void Enqueue(T obj) { Cell<T> c = new Cell<T>(obj); lock (m_queue) { m_queue.Enqueue(c); Monitor.Pulse(m_queue); Monitor.Wait(m_queue); } } public T Dequeue() { Cell<T> c; lock (m_queue) { while (m_queue.Count == 0) Monitor.Wait(m_queue); c = m_queue.Dequeue(); Monitor.Pulse(m_queue); } return c.m_obj; } }

Figure 6 Buffer limitato

public class BoundedBuffer<T> { private Queue<T> m_queue = new Queue<T>(); private int m_consumersWaiting; private int m_producersWaiting; private const int s_maxBufferSize = 128; public void Enqueue(T obj) { lock (m_queue) { while (m_queue.Count == (s_maxBufferSize - 1)) { m_producersWaiting++; Monitor.Wait(m_queue); m_producersWaiting--; } m_queue.Enqueue(obj); if (m_consumersWaiting > 0) Monitor.PulseAll(m_queue); } } public T Dequeue() { T e; lock (m_queue) {

http://msdn.microsoft.com/msdnmag/issues/07/05/CLRInsideOut/default.aspx?print=t... 11/05/2007 PDF created with pdfFactory trial version www.pdffactory.com

Tutto su CLR: 9 algoritmi e strutture di dati paralleli riutilizzabili -- MSDN Magazi... Pagina 8 di 10
while (m_queue.Count == 0) { m_consumersWaiting++; Monitor.Wait(m_queue); m_consumersWaiting--; } e = m_queue.Dequeue(); if (m_producersWaiting > 0) Monitor.PulseAll(m_queue); } return e; } }

Figure 7 Thin Event

public struct ThinEvent { private int m_state; // 0 means unset, 1 means set. private EventWaitHandle m_eventObj; private const int s_spinCount = 4000; public void Set() { m_state = 1; Thread.MemoryBarrier(); // required. if (m_eventObj != null) m_eventObj.Set(); } public void Reset() { m_state = 0; if (m_eventObj != null) m_eventObj.Reset(); } public void Wait() { SpinWait s = new SpinWait(); while (m_state == 0) { if (s.Spin() >= s_spinCount) { if (m_eventObj == null) { ManualResetEvent newEvent = new ManualResetEvent(m_state == 1); if (Interlocked.CompareExchange<EventWaitHandle>( ref m_eventObj, newEvent, null) == null) { // If someone set the flag before seeing the new // event obj, we must ensure its been set. if (m_state == 1) m_eventObj.Set(); } else { // Lost the race w/ another thread. Just use // its event. newEvent.Close(); } } m_eventObj.WaitOne(); } } } }

Figure 8 Stack dei componenti senza blocchi

public class LockFreeStack<T> { private volatile StackNode<T> m_head; public void Push(T item) { StackNode<T> node = new StackNode<T>(item); StackNode<T> head; do { head = m_head; node.m_next = head; } while (m_head != head || Interlocked.CompareExchange( ref m_head, node, head) != head); } public T Pop() { StackNode<T> head; SpinWait s = new SpinWait(); while (true) { StackNode<T> next; do { head = m_head; if (head == null) goto emptySpin; next = head.m_next; } while (m_head != head || Interlocked.CompareExchange( ref m_head, next, head) != head); break; emptySpin:

http://msdn.microsoft.com/msdnmag/issues/07/05/CLRInsideOut/default.aspx?print=t... 11/05/2007 PDF created with pdfFactory trial version www.pdffactory.com

Tutto su CLR: 9 algoritmi e strutture di dati paralleli riutilizzabili -- MSDN Magazi... Pagina 9 di 10
s.Spin(); } return head.m_value; } } class StackNode<T> { internal T m_value; internal StackNode<T> m_next; internal StackNode(T val) { m_value = val; } }

Figure 9 Partizionamento del loop (loop tiling)

public static void ForAll(int from, int to, Action<int> a, int p) { ForAll<int>(null, from, to, null, a, p); } public static void ForAll<T>(IList<T> data, Action<T> a, int p) { ForAll<T>(data, 0, data.Count, a, null, p); } private static void ForAll<T>(IList<T> data, int from, int to, Action<T> a0, Action<int> a1, int p) { int size = from - to; int stride = (size + p - 1) / p; CountdownLatch latch = new CountdownLatch(p); for (int i = 0; i < p; i++) { int idx = i; ThreadPool.QueueUserWorkItem(delegate { int end = Math.Min(size, stride * (idx + 1)); for (int j = stride * idx; j < end; j++) { if (data != null) a0(data[j]); else a1(j); } latch.Signal(); }); } latch.Wait(); }

Figure 10 Operazioni espresse come riduzioni Valore di inizializzazione Count Sum Min Max 0 0 NaN NaN Operatore binario (a, b) => a + 1 (a, b) => a + b (a, b) => a < b ? a : b (a, b) => a > b ? a : b Selettore di risultati N/D N/D N/D N/D

Average { 0, 0 }

(a, b) => new { a[0] + b, a[1] + 1 } (a) => a[0] / a[1]

Figure 11 Un metodo Reduce

public delegate T Func<T>(T arg0, T arg1); public static T Reduce<T>(IList<T> data, int p, T seed, Func<T> r){ T[] partial = new T[p]; int stride = (data.Count + p - 1) / p; CountdownLatch latch = new CountdownLatch(p); for (int i = 0; i < p; i++) { int idx = i; ThreadPool.QueueUserWorkItem(delegate { // Do the ith intermediate reduction in parallel. partial[idx] = seed; int end = Math.Min(data.Count, stride * (idx + 1)); for (int j = stride * idx; j < end; j++) partial[idx] = r(partial[idx], data[j]); latch.Signal(); }); } latch.Wait(); // Do the final reduction on the master thread. T final = seed; for (int i = 0; i < p; i++) final = r(final, partial[i]); return final;

http://msdn.microsoft.com/msdnmag/issues/07/05/CLRInsideOut/default.aspx?print=t... 11/05/2007 PDF created with pdfFactory trial version www.pdffactory.com

Tutto su CLR: 9 algoritmi e strutture di dati paralleli riutilizzabili -- MSDN Maga... Pagina 10 di 10
}

2007 Microsoft Corporation e CMP Media, LLC. Tutti i diritti riservati. vietata la riproduzione completa o parziale senza autorizzazione.

Articoli correlati da MSDN Magazine


l l l l l l l l

Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework by Jeffrey Richter Wicked Code: Scalable Apps with Asynchronous Programming in ASP.NET by Jeff Prosise CLR Inside Out: New Library Classes in Orcas by Mike Downen, Inbar Gazit, and Justin Van Patten .NET Tools: Ten Must-Have Tools Every Developer Should Download Now by James Avery ASP.NET 2.0: Enforce Web Standards For Better Accessibility by Ben Waldron Extreme ASP.NET: Web Deployment Projects by Fritz Onion Cutting Edge: Perspectives on ASP.NET AJAX by Dino Esposito Digital Media: Add Video To Controls And 3D Surfaces With WPF by Lee Brimelow

http://msdn.microsoft.com/msdnmag/issues/07/05/CLRInsideOut/default.aspx?print=t... 11/05/2007 PDF created with pdfFactory trial version www.pdffactory.com