Introduzione al Parallel Computing con .NET Framework 4.0 - Seconda parte
a cura di Alessandro Del Sole (requisiti: conoscenza del multi-threading in .NET)

Riassunto della puntata precedente
Nel precedente articolo abbiamo introdotto la Task Parallel Library, un nuovo insieme di API disponibile in .NET Framework 4.0. Abbiamo visto come, attraverso la generazione di istanze della classe System.Threading.Tasks.Task o l'invocazione del metodo Parallel.Invoke, sia possibile eseguire più attività contemporaneamente, scalandole su tutti i processori disponibili sulla macchina, sfruttando appieno le risorse hardware.

In questo articolo completiamo l'overview della Task Parallel Library, introducendo i cicli paralleli e la strumentazione di debug.
Prima di andare avanti nella lettura, è importante che leggiate il precedente articolo e che aggiorniate Visual Studio 2010 alla versione finale (RTM) disponibile, come Trial, al seguente indirizzo.
Il codice sorgente a corredo è disponibile in area Download di Visual Basic Tips & Tricks.

Cicli paralleli: Parallel.For e Parallel.ForEach
Nella programmazione classica i cicli For e For..Each rappresentano attività che, in tanti casi, impegnano da un lato molte risorse, e molto tempo di esecuzione dall'altro, poiché non sfruttano tutta la potenza dell'hardware. Fortunatamente la Task Parallel Library risolve questo problema, con implementazioni parallele di entrambi i cicli.
Iniziamo con For. I cicli For paralleli si scrivono in modo sostanzialmente analogo a quelli classici, mentre le differenze sono a livello di esecuzione. Capiremo meglio con un po' di codice, che poi commenteremo.
Creiamo un nuovo progetto di tipo Console e supponiamo di avere il seguente metodo, che restituisce l'identificativo del thread corrente:

  Function GetThreadId() As String
    Return " Thread ID = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString
  End Function

Supponiamo, poi, di avere un metodo che svolge un'elaborazione intensiva:

  'Simula un lavoro intensivo
  Sub SimulateProcessing()
    Thread.SpinWait(80000000)
  End Sub

Così come in generale per lo sviluppo parallelo, anche i cicli Parallel.For portano beneficio qualora si lavori con elaborazioni decisamente intensive, per questo facciamo tale simulazione. Una semplice iterazione, a esempio, potrebbe non essere idonea per un ciclo parallelo.
Ora, consideriamo il seguente, classico, ciclo For:

  'Ciclo classico
  Private Sub SomeMethod1()
    For i = 0 To 16
      Console.WriteLine(i.ToString + GetThreadId())
      SimulateProcessing()
    Next
  End Sub

Si tratta di un banale ciclo che esegue un conteggio progressivo da 0 a 16, visualizzando sullo schermo il valore del contatore seguito dall'identificativo del thread corrente. All'interno del ciclo viene poi richiamato il metodo che simula il lavoro intensivo.
A scopo di test, invocheremo tale ciclo nel metodo Main di un'applicazione Console, utilizzando un oggetto StopWatch per misurarne la performance:

  Sub Main()
    Dim sw As New Stopwatch
    sw.Start()
    SomeMethod1()
    sw.Stop()
    Console.WriteLine("Tempo trascorso: " & sw.ElapsedMilliseconds.ToString)
    Console.ReadLine()
  End Sub

Avviando l'applicazione potremo notare innanzitutto l'utilizzo delle risorse di sistema:

La figura mostra come le risorse hardware della macchina siano sfruttate in modo limitato, atteso che l'utilizzo di CPU è solo del 25% (il risultato varierà a seconda del vostro hardware, io uso un Intel quad-core con 8 gigabyte di RAM). Alla fine del lavoro, il risultato che otterremo è simile al seguente:

Circa 9 secondi e mezzo per eseguire il lavoro intensivo, che, come si può osservare, viene concentrato su un unico thread. Passiamo ora a riscrivere il ciclo secondo lo stile parallelo. Il metodo assumerà la seguente forma:

  'Ciclo parallelo
  Private Sub SomeMethod1()
    Parallel.For(0, 16, Sub(i)
                          Console.WriteLine(i.ToString + GetThreadId())
                          SimulateProcessing()
                        End Sub)
  End Sub

Si utilizza il metodo condiviso For della classe Parallel, il cui primo argomento è costituito dal valore iniziale del ciclo, il secondo da quello finale e il terzo da un delegate che esegue il lavoro effettivo. Teoricamente avremmo potuto richiamare un delegate dichiarato altrove nel codice (es. AddressOf NomeDelegateCheFaQualcosa), ma volevo illustrarvi l'utilizzo di un'altra novità di Visual Basic 2010, ossia le statement lambda. Un metodo anonimo, quindi, che rappresenta un delegate generato al volo e che non restituisce valori, che incapsula al suo interno le stesse operazioni fatte dal ciclo For classico.
Se ora eseguiamo nuovamente l'applicazione, le risorse della nostra architettura multi-core vengono sfruttate appieno (si noti l'utilizzo di CPU al 100%):

E il risultato che otterremo sarà simile al seguente:

A livello di performance, il tempo impiegato è di circa 2 secondi e mezzo sulla mia macchina. Quindi, circa 13 secondi in meno rispetto al ciclo For classico, sempre tenendo conto del tipo di simulazione che abbiamo fatto. Un'altra cosa molto importante da notare sono i thread utilizzati. L'utilizzo della classe Parallel, infatti, ha fatto sì che il runtime della TPL abbia autonomamente aperto e gestito diversi thread su cui scalare l'esecuzione dell'elaborazione, senza che noi ci siamo dovuti preoccupare di farlo manualmente. Giova ribadire che le operazioni parallele offrono benefici allorquando si ha a che fare con elaborazioni complesse e lunghe, non tanto su cicli di breve durata e di scarsa entità.

Introduciamo ora i cicli For..Each, che possono essere eseguiti attraverso il metodo condiviso Parallel.ForEach. Del precedente codice riprenderemo i due metodi SimulateProcessing e GetThreadId. Ipotizziamo poi di voler ciclare, nel modo classico, l'elenco dei file della cartella Immagini e di eseguire su di essi un lavoro intensivo:

  'Ciclo classico
  Private Sub SomeMethod2()
    Dim allFiles = IO.Directory.GetFiles("C:\users\alessandro\pictures").ToList
    For Each f In allFiles
      Console.WriteLine(f + GetThreadId())
      SimulateProcessing()
    Next
  End Sub

Questo metodo viene richiamato nella Sub Main, dove non abbiamo che da sostituire la terza riga con:

    SomeMethod2()

Se avviamo l'applicazione, noteremo che, come di consueto, le risorse di sistema non vengono poi sfruttate molto:

Al termine dell'operazione, il risultato sarà simile al seguente:

Circa 43 secondi, quindi, per iterare i 77 file presenti nella mia cartella. Ora riscriviamo il ciclo in questo modo:

   'Ciclo parallelo
  Private Sub SomeMethod2()
    Dim allFiles = IO.Directory.GetFiles("C:\users\alessandro\pictures").ToList
    Parallel.ForEach(Of String)(allFiles, Sub(f)
                                            Console.WriteLine(f + GetThreadId())
                                            SimulateProcessing()
                                          End Sub)
  End Sub

Possiamo notare come il primo argomento del metodo Parallel.ForEach sia la collezione da ciclare e che il secondo argomento è un delegate (in questo caso creato al volo tramite statement lambda) che mostra un semplice messaggio e invoca la simulazione di lavoro intensivo. Molto semplice e intuitivo. Se avviamo ora l'applicazione, l'utilizzo delle risorse di sistema è massimo:

E al termine il risultato è il seguente:

Quasi 12 secondi, circa 31 secondi in meno rispetto al For Each classico. Anche in questo caso dobbiamo notare come, nel modo classico, il tutto vada a gravare su un unico thread, mentre nel caso "parallelizzato" .NET Framework ha aperto più thread contemporaneamente, sui quali viene scalato il lavoro. Il metodo Parallel.ForEach espone altri overload, dei quali ci sarà modo di parlare in altre occasioni. Per il momento, questo post può servire come spunto alla curiosità verso questo nuovo framework.

Eseguire il debug di task
Visual Studio 2010 offre due interessanti strumenti per favorire il debug di istanze della classe Task e di cicli paralleli. Si tratta di due finestre, Parallel Tasks e Parallel Stacks, attivabili tramite menu Debug -> Windows. Se ad esempio provassimo a mettere un punto di interruzione sulla riga di codice che invoca il metodo SimulateProcessing, all'interno del metodo SomeMethod2 precedente, otterremmo il seguente risultato (Per permettere una visione comoda senza fare ingrandimenti, ho spezzato la figura in due parti):


 

La finestra Parallel Tasks ci permette di vedere quali task sono in esecuzione, qual è la posizione e il task corrispondente (ossia l'espressione lambda invocata), il thread proprietario del task e l'application domain.

La finestra Parallel Stacks, invece, ci aiuta a capire la gerarchia delle chiamate ai vari task nell'ambito dei singoli thread:

Ulteriori informazioni si possono ottenere passando il puntatore del mouse sopra ciascun elemento nella finestra, mentre facendo clic destro si avrà accesso ad una serie di comandi utili per lavorare sul codice relativo al thread selezionato.

Conclusioni
Con questo secondo articolo concludiamo l'introduzione al Parallel Computing con .NET Framework 4.0. C'è molto altro da dire, poiché la Task Parallel Library è un framework davvero molto ampio, potente e versatile, che offre molte classi ma che pone anche determinate problematiche. Vi rimando alla documentazione suggerita nella prima parte per gli approfondimenti di questa materia. Ad ogni buon conto ora avete una conoscenza sicuramente migliore di questo approccio di programmazione e avete le basi per successivi approfondimenti futuri.
Per informazioni potete contattarmi al mio indirizzo di posta elettronica o visitare il mio blog.