Programmazione parallela e asincrona in Java 8

Il codice parallelo, che è un codice che gira su più di un thread, una volta era l’incubo di molti sviluppatori esperti, ma Java 8 ha apportato molte modifiche che dovrebbero rendere questo trucco per aumentare le prestazioni molto più gestibile.

Flussi paralleli

Prima di Java 8 c’era una grande differenza tra codice parallelo (o simultaneo) e codice sequenziale. Era anche molto difficile eseguire il debug di codice non sequenziale. Semplicemente impostando un punto di interruzione e passando attraverso il flusso come faresti normalmente, rimuoverebbe l’aspetto parallelo, che è un problema se questo è ciò che sta causando il bug.

Fortunatamente, Java 8 ci ha dato stream, la cosa più grande per gli sviluppatori Java dal momento che il bean. Se non sai cosa sono, l’API Stream consente di gestire sequenze di elementi in una questione funzionale. (Controlla il nostro confronto tra stream e LINQ di. NET qui.) Uno dei vantaggi dei flussi che la struttura del codice rimane la stessa: che sia sequenziale o concorrente, rimane altrettanto leggibile.

Per far funzionare il codice in parallelo, è sufficiente utilizzare .parallelStream() invece di .stream(), (o stream.parallel(), se non si è il creatore del flusso).

Ma solo perché è facile, non significa che il codice parallelo sia sempre la scelta migliore. Dovresti sempre considerare se ha senso usare la concorrenza per il tuo pezzo di codice. Il fattore più importante in questa decisione sarà la velocità: usa la concorrenza solo se rende il tuo codice più veloce della sua controparte sequenziale.

La domanda di velocità

Il codice parallelo ottiene il suo vantaggio di velocità dall’utilizzo di più thread anziché quello singolo utilizzato dal codice sequenziale. Decidere quanti thread creare può essere una domanda complicata perché più thread non sempre si traducono in codice più veloce: se si utilizzano troppi thread, le prestazioni del codice potrebbero effettivamente diminuire.

Ci sono un paio di regole che ti diranno quale numero di thread scegliere. Ciò dipende principalmente dal tipo di operazione che si desidera eseguire e dal numero di core disponibili.

Le operazioni intensive di calcolo dovrebbero utilizzare un numero di thread inferiore o uguale al numero di core, mentre le operazioni intensive di IO come la copia di file non hanno alcuna utilità per la CPU e possono quindi utilizzare un numero maggiore di thread. Il codice non sa quale caso è applicabile a meno che tu non gli dica cosa fare. In caso contrario, verrà impostato di default su un numero di thread uguale al numero di core.

Ci sono due casi principali in cui può essere utile eseguire il codice in parallelo anziché in sequenza: attività che richiedono tempo e attività eseguite su grandi raccolte. Java 8 ha portato un nuovo modo di gestire quelle grandi collezioni, vale a dire con i flussi. I flussi hanno un’efficienza integrata dalla pigrizia: usano la valutazione pigra che consente di risparmiare risorse non facendo più del necessario. Questo non è lo stesso del parallelismo, che non si preoccupa delle risorse finché va più veloce. Quindi, per le grandi collezioni, probabilmente non hai bisogno del parallelismo classico.

Andare Async

Lezioni da JavaScript

È raro che uno sviluppatore Java possa dire di aver imparato qualcosa guardando JavaScript, ma quando si tratta di programmazione asincrona, JavaScript in realtà ha capito bene prima. Come un linguaggio fondamentalmente asincrono, JavaScript ha un sacco di esperienza con quanto doloroso può essere quando mal implementato. È iniziato con callback ed è stato successivamente sostituito da promises. Un importante vantaggio delle promesse è che ha due “canali”: uno per i dati e uno per gli errori. Una promessa JavaScript potrebbe essere simile a questa:

func.then(f1).catch(e1).then(f2).catch(e2);

Quindi, quando la funzione originale ha un risultato positivo, viene chiamato f1, ma se è stato generato un errore, verrà chiamato e1. Ciò potrebbe riportarlo alla traccia riuscita (f2) o causare un altro errore (e2). Puoi passare dalla traccia dati alla traccia di errore e viceversa.

La versione Java di JavaScript promises si chiama CompletableFuture.

CompletableFuture

CompletableFuture implementa sia l’interfaccia Future che quella CompletionStage. Future esisteva già pre-Java8, ma non era molto adatto agli sviluppatori di per sé. È possibile ottenere il risultato del calcolo asincrono solo utilizzando il metodo .get(), che ha bloccato il resto (rendendo la parte asincrona piuttosto inutile la maggior parte del tempo) ed è necessario implementare manualmente ogni possibile scenario. L’aggiunta dell’interfaccia CompletionStage è stata la svolta che ha reso la programmazione asincrona in Java praticabile.

CompletionStage è una promessa, vale a dire la promessa che il calcolo alla fine sarà fatto. Contiene una serie di metodi che consentono di allegare callback che verranno eseguiti su tale completamento. Ora possiamo gestire il risultato senza bloccare.

Esistono due metodi principali che consentono di avviare la parte asincrona del codice: supplyAsync se si desidera fare qualcosa con il risultato del metodo e runAsync se non lo si fa.

CompletableFuture.runAsync(() → System.out.println("Run async in completable future " + Thread.currentThread()));CompletableFuture.supplyAsync(() → 5);

Callback

Ora puoi aggiungere quei callback per gestire il risultato del tuo supplyAsync.

CompletableFuture.supplyAsync(() → 5).thenApply(i → i * 3).thenAccept(i → System.out.println("The result is " + i).thenRun(() → System.out.println("Finished."));

.thenApply è simile alla funzione .map per i flussi: esegue una trasformazione. Nell’esempio sopra prende il risultato (5) e lo moltiplica per 3. Passerà quindi quel risultato (15) più in basso nel tubo.

.thenAccept esegue un metodo sul risultato senza trasformarlo. Inoltre, non restituirà un risultato. Qui stamperà “Il risultato è 15” sulla console. Può essere paragonato al metodo .foreach per i flussi.

.thenRun non utilizza il risultato dell’operazione asincrona e non restituisce nulla, attende solo di chiamare il suo Runnable fino al completamento del passaggio precedente.

Asyncing Il tuo Async

Tutti i metodi di callback di cui sopra sono disponibili anche in una versione async: thenRunAsync, thenApplyAsync, ecc. Queste versioni possono essere eseguite sul proprio thread e ti danno un controllo extra perché puoi dirgli quale ForkJoinPool usare.

Se non si utilizza la versione asincrona, i callback verranno eseguiti tutti sullo stesso thread.

Quando le cose vanno male

Quando qualcosa va storto, il metodo exceptionally viene utilizzato per gestire l’eccezione. Puoi dargli un metodo che restituisce un valore per tornare sulla traccia dati o generare una (nuova) eccezione.

….exceptionally(ex → new Foo()).thenAccept(this::bar);

Combinare e comporre

È possibile concatenare più CompletableFutures utilizzando il metodo thenCompose. Senza di esso, il risultato sarebbe annidato CompletableFutures. Questo rende thenCompose e thenApply come flatMap e map per i flussi.

CompletableFuture.supplyAsync(() -> "Hello").thenCompose(s -> CompletableFuture.supplyAsync(() -> s + "World"));

Se vuoi combinare il risultato di due CompletableFutures, avrai bisogno di un metodo chiamato convenientemente thenCombine.

future.thenCombine(future2, Integer::sum).thenAccept(value → System.out.println(value));

Come puoi vedere nell’esempio sopra, il risultato del callback in thenCombine può essere gestito come un normale CompletableFuture con tutti i tuoi metodi preferiti CompletionStage.

Conclusione

La programmazione parallela non deve più essere un ostacolo insormontabile nella caccia al codice più veloce. Java 8 rende il processo il più semplice possibile, in modo che qualsiasi pezzo di codice che potrebbe trarne beneficio, possa essere tirato, calci e urla su tutti i thread, nel futuro multi-core che è, in realtà, solo il giorno presente. Con ciò intendo: è facile da fare, quindi provalo e vedi i suoi vantaggi per te stesso.



+