Con l'avvento delle CPU multicore negli ultimi anni, la programmazione parallela è il modo per sfruttare appieno i nuovi cavalli di lavoro di elaborazione. Programmazione parallela si riferisce all'esecuzione simultanea di processi a causa della disponibilità di più core di elaborazione. Questo, in sostanza, porta a un enorme aumento delle prestazioni e dell'efficienza dei programmi rispetto all'esecuzione lineare single core o persino al multithreading. Il framework Fork/Join fa parte dell'API di concorrenza Java. Questo framework consente ai programmatori di parallelizzare gli algoritmi. Questo articolo esplora il concetto di programmazione parallela con l'aiuto di Fork/Join Framework disponibile in Java.
Una panoramica
La programmazione parallela ha una connotazione molto più ampia ed è indubbiamente una vasta area da elaborare in poche righe. Il nocciolo della questione è abbastanza semplice, ma operativamente molto più difficile da raggiungere. In parole povere, la programmazione parallela significa scrivere programmi che utilizzano più di un processore per completare un'attività, tutto qui! Indovina un po; suona familiare, vero? Fa quasi rima con l'idea di multithreading. Ma si noti che ci sono alcune importanti distinzioni tra di loro. In superficie sono gli stessi, ma la corrente sotterranea è assolutamente diversa. In effetti, il multithreading è stato introdotto per fornire una sorta di illusione di elaborazione parallela senza alcuna vera esecuzione parallela. Quello che fa davvero il multithreading è che ruba il tempo di inattività della CPU e lo usa a proprio vantaggio.
In breve, il multithreading è una raccolta di unità logiche discrete di attività che vengono eseguite per acquisire la loro quota di tempo della CPU mentre un altro thread potrebbe essere temporaneamente in attesa, ad esempio, dell'input dell'utente. Il tempo di inattività della CPU è condiviso in modo ottimale tra i thread concorrenti. Se c'è solo una CPU, è tempo condiviso. Se sono presenti più core della CPU, anche questi sono sempre condivisi. Pertanto, un programma multithread ottimale spreme le prestazioni della CPU grazie al meccanismo intelligente della condivisione del tempo. In sostanza, è sempre un thread che utilizza una CPU mentre un altro thread è in attesa. Ciò accade in un modo sottile che l'utente ha la sensazione di un'elaborazione parallela in cui, in realtà, l'elaborazione sta effettivamente avvenendo in rapida successione. Il più grande vantaggio del multithreading è che è una tecnica per ottenere il massimo dalle risorse di elaborazione. Ora, questa idea è abbastanza utile e può essere utilizzata in qualsiasi set di ambienti, sia che abbia una singola CPU o più CPU. L'idea è la stessa.
La programmazione parallela, d'altra parte, significa che ci sono più CPU dedicate che vengono sfruttate in parallelo dal programmatore. Questo tipo di programmazione è ottimizzato per un ambiente CPU multicore. La maggior parte delle macchine odierne utilizza CPU multicore. Pertanto, la programmazione parallela è abbastanza rilevante al giorno d'oggi. Anche la macchina più economica è montata con CPU multicore. Guarda i dispositivi portatili; anche loro sono multicore. Anche se tutto sembra fantastico con le CPU multicore, ecco anche un altro lato della storia. Più core della CPU significano elaborazione più veloce o efficiente? Non sempre! L'avida filosofia del "più siamo meglio è" non si applica all'informatica, né alla vita. Ma sono lì, in modo non trascurabile:dual, quad, octa e così via. Sono lì principalmente perché li vogliamo e non perché ne abbiamo bisogno, almeno nella maggior parte dei casi. In realtà, è relativamente difficile tenere occupata anche una singola CPU nell'elaborazione quotidiana. Tuttavia, i multicore hanno i loro usi in circostanze speciali, come nei server, nei giochi e così via, o nella risoluzione di problemi di grandi dimensioni. Il problema di avere più CPU è che richiede memoria che deve corrispondere alla velocità con la potenza di elaborazione, insieme a canali dati velocissimi e altri accessori. In breve, più core della CPU nell'elaborazione quotidiana forniscono un miglioramento delle prestazioni che non può superare la quantità di risorse necessarie per utilizzarlo. Di conseguenza, otteniamo una macchina costosa sottoutilizzata, forse pensata solo per essere messa in mostra.
Programmazione parallela
A differenza del multithreading, in cui ogni attività è un'unità logica discreta di un'attività più ampia, le attività di programmazione parallela sono indipendenti e il loro ordine di esecuzione non ha importanza. Gli incarichi sono definiti in base alla funzione che svolgono o ai dati utilizzati nel trattamento; questo è chiamato parallelismo funzionale o parallelismo dei dati , rispettivamente. Nel parallelismo funzionale, ogni processore lavora sulla sua sezione del problema mentre nel parallelismo dei dati, il processore lavora sulla sua sezione dei dati. La programmazione parallela è adatta per una base di problemi più ampia che non si adatta a una singola architettura della CPU, oppure potrebbe essere il problema così grande da non poter essere risolto in una stima ragionevole del tempo. Di conseguenza, le attività, se distribuite tra i processori, possono ottenere il risultato in modo relativamente veloce.
Il framework fork/join
Il framework Fork/Join è definito in java.util.concurrent pacchetto. Include diverse classi e interfacce che supportano la programmazione parallela. Ciò che fa principalmente è semplificare il processo di creazione di più thread, i loro usi e automatizzare il meccanismo di allocazione dei processi tra più processori. La notevole differenza tra multithreading e programmazione parallela con questo framework è molto simile a quanto accennato in precedenza. Qui, la parte di elaborazione è ottimizzata per utilizzare più processori a differenza del multithreading, dove il tempo di inattività della singola CPU è ottimizzato sulla base del tempo condiviso. Il vantaggio aggiuntivo di questo framework consiste nell'utilizzare il multithreading in un ambiente di esecuzione parallelo. Nessun danno lì.
Ci sono quattro classi principali in questo framework:
- ForkJoinTask
: Questa è una classe astratta che definisce un'attività. In genere, un'attività viene creata con l'aiuto di fork() metodo definito in questa classe. Questa attività è quasi simile a un normale thread creato con il Thread classe, ma è più leggero di esso. Il meccanismo che applica è che consente la gestione di un gran numero di attività con l'aiuto di un numero ridotto di thread effettivi che si uniscono a ForkJoinPool . Il fork() abilita l'esecuzione asincrona dell'attività di richiamo. Il join() il metodo consente di attendere fino alla fine dell'attività su cui è stato chiamato. C'è un altro metodo, chiamato invoke() , che combina il fork e unisciti operazioni in un'unica chiamata. - ForkJoinPool: Questa classe fornisce un pool comune per gestire l'esecuzione di ForkJoinTask compiti. Fondamentalmente fornisce il punto di ingresso per gli invii da non ForkJoinTask clienti, nonché operazioni di gestione e monitoraggio.
- Azione Ricorsiva: Questa è anche un'estensione astratta di ForkJoinTask classe. In genere, estendiamo questa classe per creare un'attività che non restituisce un risultato o presenta un vuoto tipo di ritorno. Il calcolo() il metodo definito in questa classe viene sovrascritto per includere il codice computazionale dell'attività.
- Compito ricorsivo
: Questa è un'altra estensione astratta di ForkJoinTask classe. Estendiamo questa classe per creare un'attività che restituisce un risultato. E, simile a ResursiveAction, include anche un calcolo astratto protetto() metodo. Questo metodo viene ignorato per includere la parte di calcolo dell'attività.
La strategia del framework fork/join
Questo framework utilizza un divide et impera ricorsivo strategia per implementare l'elaborazione parallela. Fondamentalmente divide un'attività in sottoattività più piccole; quindi, ogni sottoattività è ulteriormente suddivisa in sottoattività. Questo processo viene applicato in modo ricorsivo a ciascuna attività finché non è sufficientemente piccola da essere gestita in sequenza. Supponiamo di dover incrementare i valori di un array di N numeri. Questo è il compito. Ora possiamo dividere l'array per due creando due sottoattività. Dividi di nuovo ciascuno di essi in altre due attività secondarie e così via. In questo modo, possiamo applicare un divide et impera strategia in modo ricorsivo fino a quando i compiti non vengono individuati in un problema unitario. Questo problema dell'unità può quindi essere eseguito in parallelo dai processori core multipli disponibili. In un ambiente non parallelo, quello che dovevamo fare era scorrere l'intero array ed eseguire l'elaborazione in sequenza. Questo è chiaramente un approccio inefficiente in vista dell'elaborazione parallela. Ma la vera domanda è se ogni problema può essere diviso e conquistato ? Assolutamente no! Tuttavia, ci sono problemi che spesso coinvolgono una sorta di matrice, raccolta, raggruppamento di dati che si adatta particolarmente a questo approccio. A proposito, ci sono problemi che potrebbero non utilizzare la raccolta di dati ma possono essere ottimizzati per utilizzare la strategia per la programmazione parallela. Il tipo di problemi di calcolo adatti per l'elaborazione parallela o la discussione sull'algoritmo parallelo non rientra nell'ambito di questo articolo. Vediamo un rapido esempio sull'applicazione del Framework Fork/Join.
Un rapido esempio
Questo è un esempio molto semplice per darti un'idea su come implementare il parallelismo in Java con il Framework Fork/Join.
package org.mano.example; import java.util.concurrent.RecursiveAction; public class CustomRecursiveAction extends RecursiveAction { final int THRESHOLD = 2; double [] numbers; int indexStart, indexLast; CustomRecursiveAction(double [] n, int s, int l) { numbers = n; indexStart = s; indexLast = l; } @Override protected void compute() { if ((indexLast - indexStart) > THRESHOLD) for (int i = indexStart; i < indexLast; i++) numbers [i] = numbers [i] + Math.random(); else invokeAll (new CustomRecursiveAction(numbers, indexStart, (indexStart - indexLast) / 2), new CustomRecursiveAction(numbers, (indexStart - indexLast) / 2, indexLast)); } } package org.mano.example; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; public class Main { public static void main(String[] args) { final int SIZE = 10; ForkJoinPool pool = new ForkJoinPool(); double na[] = new double [SIZE]; System.out.println("initialized random values :"); for (int i = 0; i < na.length; i++) { na[i] = (double) i + Math.random(); System.out.format("%.4f ", na[i]); } System.out.println(); CustomRecursiveAction task = new CustomRecursiveAction(na, 0, na.length); pool.invoke(task); System.out.println("Changed values :"); for (inti = 0; i < 10; i++) System.out.format("%.4f ", na[i]); System.out.println(); } }
Conclusione
Questa è una descrizione concisa della programmazione parallela e di come è supportata in Java. È assodato che avere N core non farà tutto N volte più veloce. Solo una sezione delle applicazioni Java utilizza efficacemente questa funzione. Il codice di programmazione parallela è un frame difficile. Inoltre, programmi paralleli efficaci devono considerare questioni come il bilanciamento del carico, la comunicazione tra attività parallele e simili. Ci sono alcuni algoritmi che si adattano meglio all'esecuzione parallela, ma molti no. In ogni caso, l'API Java non è priva del suo supporto. Possiamo sempre armeggiare con le API per scoprire cosa si adatta meglio. Buona codifica 🙂