Introduzione a Parallel LINQ con Visual Basic
a cura di Alessandro Del Sole (requisiti: conoscenze intermedie di LINQ)Premessa
Parallel LINQ (detto anche PLINQ) è una nuova implementazione di LINQ che si inquadra nel più ampio discorso del Parallel Computing, ossia quelle speciali forme tecnologiche volte a sfruttare le architetture multi-processore o i processori multi-core che oggi hanno ampia diffusione nel mondo dei personal computer. Per farvi un banale esempio, tutti i notebook che vengono venduti oggi ad uso domestico montano architetture almeno dual-core. Tipicamente, però, quasi mai sfruttiamo queste risorse hardware appieno e, conseguentemente, non sempre riusciamo a sfruttarle ai fini del miglioramento delle performance.Pensiamo, ad esempio, ai cicli For..Each o alle operazioni asincrone eseguite tramite multi-threading che non sempre sono ottimizzati per lavorare sfruttando tutte le risorse a disposizione. Vista la sua importanza, il Parallel Computingsarà sicuramente oggetto di articoli successivi e, sebbene PLINQ sia solitamente illustrato come ultima parte delle trattazioni di questo genere, preferisco partire da qui perché offre un modo 'visivo' di capire alcuni concetti.
Cosa occorre
Parallel Computing è una tecnologia utilizzabile sia su VS 2008, installando delle apposite estensioni che dovrete scaricare prima di proseguire, sia sulla Beta 1 di Visual Studio 2010. Esiste infatti un nuovo framework chiamato Parallel Task Libraryche farà parte integrante di .NET Framework 4.0 e che si occupa in modo moderno di gestire, in linea più generale, il discorso del multi-threading e delle operazioni asincrone sfruttando proprio le architetture multi-core al fine di garantire migliori performance e notevole velocità in più. Non è superfluo evidenziare che, allo stato attuale, la libreria è in Beta 1 e probabilmente ci saranno altre novità nella Beta 2; in ogni caso questa introduzione può ritenersi abbastanza 'stabile' ed è importante per iniziare a conoscere qualcosa che avremo a disposizione con la prossima release di .NET Framework. E’ altrettanto importante sottolineare che le estensioni per Visual Studio 2008 sono già obsolete rispetto alla Beta 1 di Visual Studio 2010, pertanto non implementano alcune funzionalità mentre altre vengono espresse attraverso codice leggermente diverso. Il consiglio personale è quello di valutare il codice con Visual Studio 2010 Beta 1, poiché la futura release di questo ambiente è l’effettivo destinatario del parallel computing, inoltre ci sono differenze sintattiche tra le estensioni per Visual Studio 2008 e la Beta 1 di .NET Framework 4. Ad ogni buon conto, il codice sorgente è disponibile in Area Download di VB T&T, sia per Visual Basic 2010 Beta 1 che per Visual Basic 2008.Un esempio 'empirico'
Pensate a quando andate a fare la spesa con un vostro familiare. Queste due persone rappresentano un’architettura dual-core. Tornate dalla spesa con quattro sacchetti della spesa e dovete fare diversi piani di scale per portarle in casa. Se solo uno dei due familiari si occuperà di questa operazione, dovrà portare in casa due sacchetti alla volta, quindi fare due viaggi, magari al secondo viaggio sarà più stanco per via delle scale e avrà impiegato il doppio del tempo. Se invece anche l’altro familiare porta due sacchetti della spesa, si farà un solo viaggio, non si sovraccaricherà una singola persona, ci si impiegherà la metà del tempo, le risorse vengono sfruttate al 100%. Il Parallel Computing fa questo: 'scala' l’esecuzione di compiti su tutti i processori disponibili mentre Parallel LINQ 'scala' l’esecuzione di query sfruttando le architetture multi- core.IMPORTANTE: tipicamente potremo apprezzare PLINQ in operazioni di calcolo piuttosto 'intensive'. Ad esempio, rendering grafico molto complesso, query con Join su centinaia di record, cicli lunghi e complessi. In operazioni di elaborazione molto ridotte, non è detto che il parallel computing sia sempre migliore rispetto alle consuete pratiche. Per tale motivo nella demo seguente simuleremo un’attività intensiva ricorrendo al threading.
Cosa c’è alla base
Alla base di PLINQ c’è una specifica estensione della classe Enumerable, che espone un metodo extension chiamato AsParallel che consente l’esecuzione di query che sfruttano il parallelismo. In realtà esiste anche una nuova classe chiamata ParallelEnumerable, che però non tratterò in questo articolo. Si tratta di una classe utile, indubbiamente, ma che elabora i blocchi di dati in modo diverso e che, quindi, in un articolo introduttivo come questo, è meno indicata, tenuto conto della familiarità che potete già avere con LINQclassico.Il codice
Per la demo di questo articolo ho utilizzato la Beta 1 di Visual Studio 2010, ma siete liberi di provare con VS 2008. In questo secondo caso, dovete aggiungere un riferimento all’assembly System.Threading.dll che implementa le nuove funzionalità. Creiamo ora un semplice progetto per la Console in Visual Basic. Il nostro obiettivo è quello di ottenere l’elenco dei numeri dispari nell'insieme di numeri compresi tra 0 e 1000. Abbiamo innanzitutto bisogno di un metodo che verifichi se un numero è dispari o meno:Module Module1 'Determina se è un numero dispari Private Function IsOdd(ByVal number As Integer) As Boolean 'Simulo un lavoro intensivo Thread.SpinWait(1000000) Return (number Mod 2) <> 0 End FunctionLa cosa da notare nel metodo è la simulazione del lavoro 'intensivo' che ci permetterà di apprezzare PLINQ. Utilizziamo il nuovo metodo SpinWait che forzerà il thread ad attendere il numero di iterazioni specificato. Ora considerate la seguente query:
Sub Main() Dim range = Enumerable.Range(0, 1000) Dim query = From num In range _ Where IsOdd(num) _ Select numDefiniamo un insieme di numeri compresi tra 0 e 1000 tramite il metodo Enumerable.Range. La query, semplicissima, fa una selezione di tutti i numeri dispari. Ora immaginiamo di voler calcolare il tempo impiegato per eseguire tale operazione:
'Un contatore Dim sw As Stopwatch = Stopwatch.StartNew 'La query LINQ viene effettivamente eseguita qui (metodo Count) Console.WriteLine("Elementi rilevati: " + query.Count.ToString) sw.Stop() Console.WriteLine(sw.ElapsedMilliseconds.ToString) Console.ReadLine() End SubPossiamo utilizzare un oggetto StopWatch che funge da contatore, coi suoi metodi StartNew e Stop per avviare e terminare il contatore stesso. Il posizionamento del contatore non è casuale, nel senso che, come sappiamo, la query LINQ viene eseguita effettivamente solo quando invochiamo esplicitamente qualcosa su di essa, nel nostro caso il metodo Count.
Prima di avviare l’applicazione, lanciamo il Task Manager di Windows e impostiamolo come Always on Top (menu Opzioni) e attiviamo la scheda Prestazioni(Performance). Così potremo monitorare l’utilizzo di CPU.
Esecuzione del test con LINQ classico
A questo punto avviamo l’applicazione premendo F5. Durante l’esecuzione, noterete che l’utilizzo della CPU è abbastanza limitato:![]()
Questo chiaramente dipende da quante applicazioni avete in esecuzione, nel mio caso con 3 applicazioni aperte (Visual Studio, Word, Internet Explorer) l’utilizzo di CPU varia tra il 50% e il 60% durante l’elaborazione. Al termine, otterremo il seguente risultato:
![]()
Abbiamo impiegato quindi circa 18 secondi per eseguire una query 'intensiva'. Vediamo adesso come Parallel LINQ può migliorare il tutto.
Esecuzione del test con Parallel LINQ
Il bello di Parallel LINQ è che modifica praticamente di nulla il nostro modo di scrivere le query. Infatti, per utilizzare PLINQ, è sufficiente invocare il metodo AsParallel nella query come segue:Dim query = From num In range.AsParallel _ Where IsOdd(num) _ Select numAsParallel restituisce una ParallelQuery(Of T). Se ora avviamo l’applicazione, potremo osservare come entrambe le CPU della mia macchina vengano utilizzate, testimoniato anche dal 100% di utilizzo:
![]()
Al termine, l’applicazione mostra il seguente risultato:
![]()
Circa 10 secondi, ossia 8 secondi in meno rispetto alla query LINQ classica. Non male vero? :-) In sostanza l’utilizzo di AsParallel ha fatto sì che l’elaborazione dei dati venisse scalata su tutti i processori disponibili, migliorando le performance. Come detto, PLINQ non andrebbe usato ciecamente. Quantomeno, è errato pensare che ogni cosa 'parallelizzata' sia migliore di una che non lo è. Dipende sempre dal contesto in cui si opera.
Forzare il parallelismo
PLINQ è in grado di capire, a seconda della forma ("shape") della query, in quale fase della query stessa debba applicare o meno l'algoritmo di parallelizzazione al fine di ottenere un concreto vantaggio. Gli operatori utilizzati influenzano tale decisione da parte di PLINQ, oppure, secondo la documentazione, se una query ha un singolo delegate che fa un lavoro minimo non verrà parallelizzata. Tuttavia si può forzare l'esecuzione parallela della query utilizzando il metodo extension chiamato WithExecutionMode da richiamare dopo AsParallel, a cui si passa l'argomento ParallelExecutionMode.ForceParallelism(ParallelExecutionMode è un'enumerazione il cui altro membro è Default, quindi non necessario). Si utilizza in questo modo (la banale query, ovviamente, serve solo come esempio d'uso del metodo):'Forzare il parallelismo: Dim query = From num In range.AsParallel.WithExecutionMode(ParallelExecutionMode.ForceParallelism)Purtroppo le estensioni per Visual Studio 2008 non supportano la forzatura del parallelismo (cioè questo esempio lo verificate solo in VS2010).
Limitare il numero di task
Sempre con riferimento a Parallel LINQ, è possibile (ma non indispensabile) specificare un numero prefissato di task contemporanei che il run-time utilizzerà per parallelizzare la query. Si utilizza, al riguardo, il metodo extension WithDegreeOfParallelism il cui argomento è il numero di task e che si richiama dopo AsParallel. Un piccolo esempio che limita a 3 il numero di task contemporanei:'Limitare il numero di task (VS2010) Dim query = From num In range.AsParallel.WithDegreeOfParallelism(3)Normalmente il run-time fa da solo la scelta migliore, in ogni caso sappiate che esiste la possibilità. Vi ricordo anche che il parallel computing secondo .NET 4.0 si basa sui task e non sui thread, come meglio specificato nella documentazione ufficiale. Nel caso in cui utilizziate le estensioni per Visual Studio 2008, il metodo WithDegreeOfParallelism non esiste, mentre è possibile passare il numero di task direttamente ad AsParallel:
'Limitare il numero di task (VS2008 con estensioni) Dim query = From num In range.AsParallel(3)Ordinare il risultato delle query
Una delle situazioni a cui bisogna fare attenzione quando si lavora con Parallel LINQ, è che i risultati delle query confluiscono in sequenze i cui elementi sono disposti "in ordine sparso". Ciò è del tutto normale, poiché lo stesso compito è suddiviso su più thread che vengono eseguiti contemporaneamente, quindi non è possibile prevedere a priori un ordine sequenziale. Certamente ciò può costituire un problema nel momento in cui si ha necessità di scorrere una sequenza nell'ordine prefissato che ci si attenderebbe, ma fortunatamente la soluzione c'è. Riprendiamo la query parallelizzata e sopra esposta, che vi riporto per comodità:Dim query = From num In range.AsParallel _ Where IsOdd(num) _ Select numOra, se andassimo ad iterare il risultato ottenuto con un banalissimo ciclo di questo tipo:
For Each n In query Console.WriteLine(n) Nextscopriremmo che i numeri ottenuti non sono in ordine sequenziale dal più piccolo al più grande come potremmo attenderci, bensì in ordine sparso, dovuto all'esecuzione di thread multipli. Per ovviare al problema, è sufficiente utilizzare il metodo extension AsOrdered che permette di trattare l'origine dati come se fosse una sequenza ordinata e che funziona esclusivamente dopo l'invocazione di AsParallel. Ciò premesso, è sufficiente sostituire il codice della clausola From con il seguente:
'Per ottenere un risultato ordinato: Dim query = From num In range.AsParallel.AsOrdered _ Where IsOdd(num) _ Select numPer far sì che il risultato della query sia una sequenza di numeri ordinata dal più piccolo al più grande. Provare per credere :-)
Gestione di eccezioni
Può capitare che nel corso dell’esecuzione di query PLINQ si verifichino delle eccezioni. Il problema notevole è capire dove l’eccezione si è verificata, poiché l’esecuzione su thread multipli rende il compito difficile; non di meno, tale scenario implica la possibilità di più eccezioni contemporanee. In questo scenario si utilizza una System.AggregateException, una classe in grado di rappresentare una o più eccezioni verificatesi. Tale classe espone due proprietà interessanti: InnerExceptions che è una collezione delle eccezioni verificatesi ed InnerException che rappresenta l’eccezione effettivamente intercettata in quel momento. Il seguente codice mostra un esempio:Dim range = Enumerable.Range(0, 1000) Try Dim query = From num In range.AsParallel.AsOrdered _ Where IsOdd(num) _ Select num Dim sw As Stopwatch = Stopwatch.StartNew 'La query LINQ viene effettivamente eseguita qui (metodo Count) Console.WriteLine("Elementi rilevati: " + query.Count.ToString) sw.Stop() Console.WriteLine(sw.ElapsedMilliseconds.ToString) Console.ReadLine() Catch ex As AggregateException Console.WriteLine(ex.InnerException.Message) End TryConclusioni
Forse il Parallel Computing può sembrare qualcosa di ancora lontano dal nostro utilizzo quotidiano, in ogni caso l’evoluzione tecnologica ci porterà ben presto a considerare tali tecniche per poter avvantaggiarci realmente dall’avere a disposizione architetture multi-core.
Molto altro ci sarebbe da dire su questo argomento ma è altrettanto opportuno attendere gli sviluppi proposti con la Beta 2 di Visual Studio 2010. Ulteriori informazioni possono essere reperite sul blog del Team di Parallel FX (in particolare, trovate un elenco di novità introdotte nella Beta 1 rispetto alle estensioni) e nella libreria MSDN, ma come detto torneremo sull’argomento Parallel Computing e PLINQ.
Per commenti e quant’altro di vostro interesse, potete contattarmi al mio indirizzo visitare il mio blog.