O Fork/Join Framework
Objective
O Fork/Join Framework (java.util.concurrent, adicionado no JDK 7) é o motor do Java para execução verdadeiramente paralela em múltiplos núcleos de CPU, construído em torno de uma estratégia recursiva de divisão-e-conquista: uma task se divide em subtasks menores, essas subtasks rodam concorrentemente (e se dividem ainda mais se ainda estiverem grandes demais), e os resultados se combinam de volta. É um problema diferente da multithreading clássica — uma Thread em uma única CPU deixa uma task rodar enquanto outra espera por I/O ou entrada do usuário, compartilhando um núcleo ao longo do tempo; Fork/Join, em vez disso, precisa de dois ou mais núcleos realmente disponíveis para valer a pena, porque todo o seu propósito é rodar pedaços da mesma computação simultaneamente. Um ForkJoinPool gerencia um pequeno número de worker threads que executam um número potencialmente grande de ForkJoinTasks leves, usando work-stealing para manter todo núcleo ocupado.
Use Cases
- Transformar, ordenar ou buscar em um grande array ou coleção em memória dividindo-o em metades (ou pedaços menores) e processando cada pedaço em seu próprio núcleo.
- Qualquer algoritmo recursivo de divisão-e-conquista — merge sort, multiplicação de matrizes, travessia de árvore/grafo — onde os subproblemas são naturalmente independentes.
- O próprio mecanismo sobre o qual
parallelStream()é construído: streams paralelos submetem seu trabalho aoForkJoinPoolcomum em vez de implementar seu próprio gerenciamento de threads. - Disparar trabalho CPU-bound de forma assíncrona com
execute()quando a thread chamadora tem seu próprio trabalho para continuar fazendo, em vez de bloquear eminvoke(). - Trabalho que precisa de cancelamento ou checagem de status de conclusão (
cancel(),isCompletedAbnormally()) além do que umRunnable/Callablecomum submetido a umExecutorServiceexpõe.
Deep Dive
Divisão-e-conquista e work-stealing
O padrão é: continue dividindo uma task ao meio até que o pedaço restante seja pequeno o suficiente para simplesmente ser computado diretamente (o limiar sequencial), então deixe os pedaços rodarem concorrentemente e combine seus resultados. É por isso que as duas classes que você de fato estende se chamam RecursiveAction e RecursiveTask<V> — recursão é o mecanismo, não apenas uma escolha de estilo.
ForkJoinPool executa isso de forma eficiente através de work-stealing: toda worker thread mantém sua própria fila de tasks, e sempre que a fila de uma thread esvazia, ela rouba uma task da fila de outra thread em vez de ficar ociosa. Isso importa mais quando as subtasks têm tamanhos desiguais — um thread pool de tamanho fixo que distribui pedaços de tamanho igual antecipadamente não tem como se rebalancear se um pedaço acaba sendo mais barato que outro, enquanto work-stealing mantém todo núcleo alimentado independentemente disso.
As classes centrais
plaintextForkJoinTask<V> an abstract class that defines a task ForkJoinPool manages the execution of ForkJoinTasks RecursiveAction a subclass of ForkJoinTask<V> for tasks that do not return values RecursiveTask<V> a subclass of ForkJoinTask<V> for tasks that return values
ForkJoinTask<V> representa uma task leve, não uma thread de execução — um ForkJoinPool pode gerenciar muito mais tasks do que threads worker reais que ele possui. Seus métodos centrais:
javafinal ForkJoinTask<V> fork() // schedules this task for async execution; caller keeps running
final V join() // blocks until this task finishes, then returns its result
final V invoke() // fork + join in one call: start the task and wait for it
static void invokeAll(ForkJoinTask<?>... taskList) // run several tasks, wait for all of themVocê estende RecursiveAction quando a task não produz resultado e sobrescreve protected abstract void compute(); você estende RecursiveTask<V> quando ela produz um V e sobrescreve protected abstract V compute(). De qualquer forma, compute() é onde vive a lógica de divisão-e-conquista — e a checagem do limiar.
RecursiveAction: dividir até ficar pequeno, depois só computar
Este exemplo (adaptado do SqrtTransform do livro) transforma cada elemento de um double[] em sua raiz quadrada in place, dividindo o array ao meio em cada nível até que um pedaço fique abaixo de seqThreshold:
javaclass SqrtTransform extends RecursiveAction {
// Threshold is arbitrary here; in real code it's tuned by profiling.
final int seqThreshold = 1000;
double[] data;
int start, end;
SqrtTransform(double[] vals, int s, int e) {
data = vals;
start = s;
end = e;
}
protected void compute() {
if ((end - start) < seqThreshold) {
// Small enough: just do the work sequentially.
for (int i = start; i < end; i++) {
data[i] = Math.sqrt(data[i]);
}
} else {
// Still too big: split in half and run both halves,
// waiting for both to finish before this call returns.
int middle = (start + end) / 2;
invokeAll(new SqrtTransform(data, start, middle),
new SqrtTransform(data, middle, end));
}
}
}
ForkJoinPool fjp = new ForkJoinPool();
double[] nums = new double[100_000];
for (int i = 0; i < nums.length; i++) nums[i] = (double) i;
SqrtTransform task = new SqrtTransform(nums, 0, nums.length);
fjp.invoke(task); // blocks until the whole tree of subtasks completesinvokeAll aqui faz o fork e a espera por ambas as metades em uma única chamada — conveniente para o caso "sem resultado", onde não há nada para agregar.
RecursiveTask: retornando e agregando um resultado
Quando subtasks retornam valores, você tipicamente chama fork() em cada uma explicitamente, depois join() em cada uma para coletar e combinar os resultados você mesmo, em vez de confiar em invokeAll(). Este exemplo soma um double[]:
javaclass Sum extends RecursiveTask<Double> {
final int seqThreshold = 500;
double[] data;
int start, end;
Sum(double[] vals, int s, int e) {
data = vals;
start = s;
end = e;
}
protected Double compute() {
double sum = 0;
if ((end - start) < seqThreshold) {
for (int i = start; i < end; i++) sum += data[i];
} else {
int middle = (start + end) / 2;
Sum subTaskA = new Sum(data, start, middle);
Sum subTaskB = new Sum(data, middle, end);
// Start both subtasks asynchronously...
subTaskA.fork();
subTaskB.fork();
// ...then wait for each and combine their results.
sum = subTaskA.join() + subTaskB.join();
}
return sum;
}
}
ForkJoinPool fjp = new ForkJoinPool();
double[] nums = new double[5000];
for (int i = 0; i < nums.length; i++) nums[i] = (i % 2 == 0) ? i : -i;
Sum task = new Sum(nums, 0, nums.length);
double summation = fjp.invoke(task); // invoke() returns the task's result hereDuas outras formas válidas de rodar o par: subTaskA.fork(); sum = subTaskB.invoke() + subTaskA.join(); (iniciar A assincronamente, rodar B na thread atual), ou até fazer B chamar compute() diretamente em vez de fork()/join() de forma alguma — útil quando B é barato o suficiente para que iniciar agendamento assíncrono para ela não valha a pena.
O common pool: geralmente nenhum pool para construir
Desde o JDK 8, você raramente precisa de new ForkJoinPool() você mesmo. ForkJoinPool.commonPool() retorna um pool estático e compartilhado que fica automaticamente disponível, e chamar fork(), invoke(), ou invokeAll() em uma task fora do contexto computacional de qualquer pool automaticamente a roteia através do common pool:
javaSqrtTransform task = new SqrtTransform(nums, 0, nums.length);
task.invoke(); // no ForkJoinPool variable needed — runs on the common poolEsse é também o mecanismo do qual parallelStream() depende: um pipeline de stream paralelo submete seu trabalho de divisão-e-combinação ao ForkJoinPool.commonPool() em vez de manter seu próprio pool de worker threads.
Execução assíncrona, cancelamento e status de conclusão
invoke() bloqueia a thread chamadora até a task terminar. Para iniciar uma task e deixar a thread chamadora continuar, use ForkJoinPool.execute() em vez disso:
javavoid execute(ForkJoinTask<?> task)
void execute(Runnable task) // bridges traditional Runnable-based code into the poolComo as worker threads de um ForkJoinPool são daemon threads, um programa cuja thread principal sai antes de uma task iniciada com execute() terminar vai encerrar sem que essa task jamais termine.
Uma task em execução pode ser cancelada a partir de código externo (uma task não precisa se cancelar sozinha — ela pode simplesmente retornar):
javaboolean cancel(boolean interruptOK) // true if this call cancelled the task
boolean isCancelled() // true if cancelled before completione seu resultado checado depois com isCompletedNormally() (terminou, sem exceção, não cancelada) ou isCompletedAbnormally() (cancelada ou lançou exceção). Uma task terminada normalmente geralmente não pode rodar de novo, mas reinitialize() reseta seu estado interno para que possa ser resubmetida — embora quaisquer efeitos colaterais que ela já tenha causado em dados compartilhados (como o array que modificou) não sejam desfeitos.
Trade-offs
- O tamanho do limiar é um knob de ajuste real, não uma formalidade. Baixo demais, e o pool gasta mais tempo criando e agendando tasks do que fazendo trabalho de verdade; alto demais, e não há pedaços independentes suficientes para manter todo núcleo ocupado. A regra prática da documentação da API
ForkJoinTaské algo entre 100 e 10.000 passos computacionais por task — mas o valor certo ainda depende de fazer profiling da carga de trabalho real, e errar para cima é mais seguro do que errar para baixo. - Work-stealing compensa mais com subtasks de tamanhos desiguais. Um thread pool de tamanho fixo que distribui pedaços de tamanho igual antecipadamente não tem como se rebalancear quando um pedaço acaba sendo mais barato que outro; os workers ociosos de um
ForkJoinPoolroubam dos ocupados em vez de ficar parados. - Fork/Join precisa de múltiplos núcleos para valer a pena. Em uma máquina com uma única CPU não há execução paralela a ganhar — multithreading tradicional baseada em
Thread(esconder latência de I/O/entrada) é um problema diferente com uma solução diferente, e Fork/Join não a substitui. - Uma
ForkJoinTaskdeve evitar I/O bloqueante e sincronização externa. Métodoscompute()que usam blocossynchronized, esperam por I/O, ou de outra forma bloqueiam seguram uma worker thread que o pool espera que continue ocupada computando, o que anula as premissas de eficiência do pool. - Streams paralelos rodam no common pool compartilhado — a contenção também é compartilhada. Como
parallelStream()submete aoForkJoinPool.commonPool()por padrão, uma task de bloqueio longo ou intensa em I/O colocada dentro de um pipeline de stream paralelo pode faminizar outro código não relacionado em algum lugar da mesma JVM que também depende do common pool para seu próprio trabalho paralelo.