L'obiettivo finale dei pool di thread e Fork / Join sono simili: entrambi vogliono utilizzare la potenza della CPU disponibile il meglio che possono per la massima produttività. Il throughput massimo significa che il maggior numero possibile di attività deve essere completato in un lungo periodo di tempo. Cosa è necessario per farlo? (Per quanto segue supponiamo che non vi siano carenze di attività di calcolo: c'è sempre abbastanza da fare per un utilizzo della CPU al 100%. Inoltre uso "CPU" in modo equivalente per core o core virtuali in caso di hyper-threading).
- Almeno ci devono essere tanti thread in esecuzione quante sono le CPU disponibili, perché l'esecuzione di meno thread lascerà inutilizzato un core.
- Al massimo ci devono essere tanti thread in esecuzione quante sono le CPU disponibili, poiché l'esecuzione di più thread creerà un carico aggiuntivo per lo Scheduler che assegna CPU ai diversi thread, facendo sì che un certo tempo CPU passi allo scheduler piuttosto che alla nostra attività di calcolo.
Quindi abbiamo capito che per ottenere il massimo throughput dobbiamo avere lo stesso numero esatto di thread rispetto alle CPU. Nell'esempio di sfocatura di Oracle puoi sia prendere un pool di thread di dimensioni fisse con un numero di thread uguale al numero di CPU disponibili sia utilizzare un pool di thread. Non farà differenza, hai ragione!
Quindi, quando avrai problemi con un pool di thread? Cioè se un thread si blocca , perché il thread è in attesa che venga completata un'altra attività. Supponiamo il seguente esempio:
class AbcAlgorithm implements Runnable {
public void run() {
Future<StepAResult> aFuture = threadPool.submit(new ATask());
StepBResult bResult = stepB();
StepAResult aResult = aFuture.get();
stepC(aResult, bResult);
}
}
Quello che vediamo qui è un algoritmo che consiste in tre fasi A, B e C. A e B possono essere eseguite indipendentemente l'una dall'altra, ma la fase C richiede il risultato della fase A AND B. Ciò che questo algoritmo fa è inviare l'attività A a il threadpool ed eseguire l'attività b direttamente. Dopodiché il thread attenderà che anche l'attività A venga eseguita e continuerà con il passaggio C. Se A e B sono completati contemporaneamente, allora tutto va bene. E se A impiegasse più tempo di B? Ciò può essere dovuto al fatto che la natura dell'attività A lo impone, ma può anche essere il caso perché non è disponibile un thread per l'attività A all'inizio e l'attività A deve attendere. (Se è disponibile una sola CPU e quindi il tuo threadpool ha solo un singolo thread, ciò causerà persino un deadlock, ma per ora questo è oltre il punto). Il punto è che il thread che ha appena eseguito l'attività Bblocca l'intero thread . Dato che abbiamo lo stesso numero di thread delle CPU e un thread è bloccato, ciò significa che una CPU è inattiva .
Fork / Join risolve questo problema: nel framework fork / join dovresti scrivere lo stesso algoritmo come segue:
class AbcAlgorithm implements Runnable {
public void run() {
ATask aTask = new ATask());
aTask.fork();
StepBResult bResult = stepB();
StepAResult aResult = aTask.join();
stepC(aResult, bResult);
}
}
Sembra lo stesso, no? Tuttavia, l'indizio è che aTask.join
non si bloccherà . Invece qui è dove entra in gioco il furto di lavoro: il thread cercherà altri compiti che sono stati biforcati in passato e continuerà con quelli. Innanzitutto controlla se le attività che ha biforcato stesso hanno iniziato l'elaborazione. Quindi se A non è stato ancora avviato da un altro thread, farà A in un secondo momento, altrimenti controllerà la coda di altri thread e ruberà il loro lavoro. Una volta completata questa altra attività di un altro thread controllerà se A è stato completato ora. Se è l'algoritmo sopra può chiamare stepC
. Altrimenti cercherà un altro compito da rubare. In questo modo i pool fork / join possono ottenere il 100% di utilizzo della CPU, anche a fronte di azioni di blocco .
Tuttavia c'è una trappola: rubare il lavoro è possibile solo per il join
richiamo di ForkJoinTask
s. Non può essere eseguito per azioni di blocco esterne come l'attesa di un altro thread o l'attesa di un'azione I / O. Quindi che dire, aspettare il completamento dell'I / O è un'attività comune? In questo caso, se potessimo aggiungere un thread aggiuntivo al pool Fork / Join che verrà nuovamente arrestato non appena l'azione di blocco sarà completata, sarà la seconda cosa migliore da fare. E in ForkJoinPool
realtà possiamo fare proprio questo se stiamo usando ManagedBlocker
s.
Fibonacci
In JavaDoc per RecursiveTask è un esempio per il calcolo dei numeri di Fibonacci utilizzando Fork / Join. Per una classica soluzione ricorsiva vedere:
public static int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
Come spiegato in JavaDocs, questo è un modo piuttosto discarica di calcolare i numeri di fibonacci, poiché questo algoritmo ha una complessità O (2 ^ n) mentre sono possibili modi più semplici. Tuttavia questo algoritmo è molto semplice e facile da capire, quindi ci atteniamo ad esso. Supponiamo di voler accelerare questo con Fork / Join. Un'implementazione ingenua sarebbe simile a questa:
class Fibonacci extends RecursiveTask<Long> {
private final long n;
Fibonacci(long n) {
this.n = n;
}
public Long compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}
I passaggi in cui è suddivisa questa attività sono troppo brevi e quindi questo si comporterà in modo orribile, ma puoi vedere come il framework generalmente funziona molto bene: i due riepiloghi possono essere calcolati in modo indipendente, ma quindi abbiamo bisogno di entrambi per costruire il finale risultato. Quindi la metà viene eseguita in un altro thread. Divertiti a fare lo stesso con i pool di thread senza ottenere un deadlock (possibile, ma non altrettanto semplice).
Solo per completezza: se desideri davvero calcolare i numeri di Fibonacci utilizzando questo approccio ricorsivo, ecco una versione ottimizzata:
class FibonacciBigSubtasks extends RecursiveTask<Long> {
private final long n;
FibonacciBigSubtasks(long n) {
this.n = n;
}
public Long compute() {
return fib(n);
}
private long fib(long n) {
if (n <= 1) {
return 1;
}
if (n > 10 && getSurplusQueuedTaskCount() < 2) {
final FibonacciBigSubtasks f1 = new FibonacciBigSubtasks(n - 1);
final FibonacciBigSubtasks f2 = new FibonacciBigSubtasks(n - 2);
f1.fork();
return f2.compute() + f1.join();
} else {
return fib(n - 1) + fib(n - 2);
}
}
}
Questo mantiene le attività secondarie molto più piccole perché vengono divise solo quando n > 10 && getSurplusQueuedTaskCount() < 2
è vero, il che significa che ci sono significativamente più di 100 chiamate di metodo da fare ( n > 10
) e non ci sono già molte attività man in attesa ( getSurplusQueuedTaskCount() < 2
).
Sul mio computer (4 core (8 quando si conteggia Hyper-threading), CPU Intel (R) Core (TM) i7-2720QM a 2,20 GHz) ci fib(50)
vogliono 64 secondi con l'approccio classico e solo 18 secondi con l'approccio Fork / Join che è un guadagno notevole, anche se teoricamente non è possibile.
Sommario
- Sì, nel tuo esempio Fork / Join non ha alcun vantaggio rispetto ai pool di thread classici.
- Fork / Join può migliorare drasticamente le prestazioni in caso di blocco
- Fork / Join elude alcuni problemi di deadlock