O objetivo final dos pools de threads e do Fork / Join é o mesmo: ambos desejam utilizar a energia da CPU disponível da melhor maneira possível para obter o máximo rendimento. O rendimento máximo significa que o maior número possível de tarefas deve ser concluído em um longo período de tempo. O que é necessário para fazer isso? (Para o seguinte, assumiremos que não há escassez de tarefas de cálculo: sempre há o suficiente para uma utilização de 100% da CPU. Além disso, eu uso "CPU" de forma equivalente para núcleos ou núcleos virtuais em caso de hiperencadeamento).
- Pelo menos, é necessário haver tantos threads em execução quanto CPUs disponíveis, porque a execução de menos threads deixará o núcleo sem uso.
- No máximo, deve haver tantos threads em execução quanto CPUs disponíveis, porque a execução de mais threads criará carga adicional para o Agendador que atribui CPUs aos diferentes segmentos, o que faz com que algum tempo de CPU vá para o agendador em vez de nossa tarefa computacional.
Assim, descobrimos que, para obter o rendimento máximo, precisamos ter exatamente o mesmo número de threads que as CPUs. No exemplo de desfoque do Oracle, você pode pegar um pool de threads de tamanho fixo com o número de threads igual ao número de CPUs disponíveis ou usar um pool de threads. Não fará diferença, você está certo!
Então, quando você terá problemas com um pool de threads? Ou seja, se um encadeamento for bloqueado , porque o encadeamento aguarda a conclusão de outra tarefa. Suponha o seguinte exemplo:
class AbcAlgorithm implements Runnable {
public void run() {
Future<StepAResult> aFuture = threadPool.submit(new ATask());
StepBResult bResult = stepB();
StepAResult aResult = aFuture.get();
stepC(aResult, bResult);
}
}
O que vemos aqui é um algoritmo que consiste em três etapas A, B e C. A e B podem ser executadas independentemente uma da outra, mas a etapa C precisa do resultado da etapa A e B. O que esse algoritmo faz é enviar a tarefa A para o conjunto de threads e execute a tarefa b diretamente. Depois disso, o encadeamento aguardará a conclusão da tarefa A e continuará com a etapa C. Se A e B forem concluídos ao mesmo tempo, tudo estará bem. Mas e se A demorar mais que B? Isso pode ser porque a natureza da tarefa A determina, mas também pode ser o caso, porque não há encadeamento para a tarefa A disponível no início e a tarefa A precisa esperar. (Se houver apenas uma única CPU disponível e, portanto, o seu conjunto de encadeamentos tiver apenas um único encadeamento, isso poderá causar um impasse, mas por enquanto isso está além do ponto). O ponto é que o thread que acabou de executar a tarefa Bbloqueia o segmento inteiro . Como temos o mesmo número de threads que as CPUs e um thread está bloqueado, isso significa que uma CPU está ociosa .
O fork / join resolve este problema: Na estrutura do fork / join, você escreveria o mesmo algoritmo da seguinte maneira:
class AbcAlgorithm implements Runnable {
public void run() {
ATask aTask = new ATask());
aTask.fork();
StepBResult bResult = stepB();
StepAResult aResult = aTask.join();
stepC(aResult, bResult);
}
}
Parece o mesmo, não é? No entanto, a pista é que aTask.join
não irá bloquear . Em vez disso, é aqui que o roubo de trabalho entra em ação : o thread procurará outras tarefas que foram bifurcadas no passado e continuará com elas. Primeiro, ele verifica se as tarefas que se bifurcaram começaram o processamento. Portanto, se A ainda não tiver sido iniciado por outro encadeamento, ele fará A a seguir, caso contrário, verificará a fila de outros encadeamentos e roubará seu trabalho. Depois que essa outra tarefa de outro encadeamento for concluída, ele verificará se A está concluído agora. Se for o algoritmo acima, pode chamar stepC
. Caso contrário, ele procurará mais uma tarefa a ser roubada. Assim, os pools de junção / forquilha podem atingir 100% de utilização da CPU, mesmo diante de ações de bloqueio .
No entanto, existe uma armadilha: o roubo de trabalho só é possível para a join
chamada de ForkJoinTask
s. Isso não pode ser feito para ações de bloqueio externas, como aguardar outro encadeamento ou aguardar uma ação de E / S. Então, o que é isso, esperar a conclusão da E / S é uma tarefa comum? Nesse caso, se pudermos adicionar um encadeamento adicional ao pool de Bifurcação / Junção que será interrompido novamente assim que a ação de bloqueio for concluída, será a segunda melhor coisa a fazer. E o ForkJoinPool
pode realmente fazer exatamente isso se estivermos usando ManagedBlocker
s.
Fibonacci
No JavaDoc for RecursiveTask, há um exemplo para calcular números de Fibonacci usando Fork / Join. Para uma solução recursiva clássica, consulte:
public static int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
Como é explicado nos JavaDocs, essa é uma maneira bastante simples de calcular números de fibonacci, pois esse algoritmo tem complexidade O (2 ^ n), enquanto maneiras mais simples são possíveis. No entanto, este algoritmo é muito simples e fácil de entender, por isso o mantemos. Vamos supor que queremos acelerar isso com o Fork / Join. Uma implementação ingênua ficaria assim:
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();
}
}
As etapas em que esta tarefa está dividida são muito curtas e, portanto, terão um desempenho horrível, mas você pode ver como a estrutura geralmente funciona muito bem: As duas ordens de soma podem ser calculadas independentemente, mas precisamos das duas para criar a versão final. resultado. Então metade é feita em outro segmento. Divirta-se fazendo o mesmo com conjuntos de encadeamentos sem obter um impasse (possível, mas não tão simples).
Apenas para completar: se você realmente deseja calcular os números de Fibonacci usando esta abordagem recursiva, aqui está uma versão otimizada:
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);
}
}
}
Isso mantém as subtarefas muito menores, porque elas são divididas apenas quando n > 10 && getSurplusQueuedTaskCount() < 2
verdadeiras, o que significa que há significativamente mais de 100 chamadas de método para fazer ( n > 10
) e não há muitas tarefas manuais aguardando ( getSurplusQueuedTaskCount() < 2
).
No meu computador (4 núcleos (8 ao contar o Hyper-threading), a CPU Intel (R) Core i7-2720QM a 2.20 GHz) fib(50)
leva 64 segundos com a abordagem clássica e apenas 18 segundos com a abordagem Fork / Join, que é um ganho bastante perceptível, embora não tanto quanto teoricamente possível.
Resumo
- Sim, no seu exemplo, o Fork / Join não tem vantagem sobre os pools de threads clássicos.
- Forquilha / junção pode melhorar drasticamente o desempenho quando o bloqueio está envolvido
- Forquilha / junção contorna alguns problemas de conflito