Com o advento de CPUs multicore nos últimos anos, a programação paralela é a maneira de aproveitar ao máximo os novos cavalos de trabalho de processamento. Programação paralela refere-se à execução simultânea de processos devido à disponibilidade de vários núcleos de processamento. Isso, em essência, leva a um tremendo aumento no desempenho e na eficiência dos programas, em contraste com a execução linear de núcleo único ou mesmo multithreading. A estrutura Fork/Join faz parte da API de simultaneidade Java. Essa estrutura permite que os programadores paralelizem algoritmos. Este artigo explora o conceito de programação paralela com a ajuda do Fork/Join Framework disponível em Java.
Uma visão geral
A programação paralela tem uma conotação muito mais ampla e sem dúvida é uma área vasta para elaborar em poucas linhas. O cerne da questão é bastante simples, mas operacionalmente muito mais difícil de alcançar. Em termos simples, programação paralela significa escrever programas que usam mais de um processador para completar uma tarefa, isso é tudo! Adivinha; soa familiar, não é? Quase rima com a ideia de multithreading. Mas, note que existem algumas distinções importantes entre eles. Na superfície, eles são os mesmos, mas a subcorrente é absolutamente diferente. Na verdade, o multithreading foi introduzido para fornecer uma espécie de ilusão de processamento paralelo sem nenhuma execução paralela real. O que o multithreading realmente faz é roubar o tempo ocioso da CPU e usá-lo a seu favor.
Em suma, multithreading é uma coleção de unidades lógicas discretas de tarefas que são executadas para obter sua parte do tempo de CPU enquanto outro thread pode estar temporariamente aguardando, digamos, alguma entrada do usuário. O tempo de CPU ocioso é compartilhado de maneira ideal entre os threads concorrentes. Se houver apenas uma CPU, é tempo compartilhado. Se houver vários núcleos de CPU, eles também serão compartilhados o tempo todo. Portanto, um programa multithread ideal reduz o desempenho da CPU pelo mecanismo inteligente de compartilhamento de tempo. Em essência, é sempre um thread usando uma CPU enquanto outro thread está esperando. Isso acontece de uma maneira sutil que o usuário tem uma sensação de processamento paralelo onde, na realidade, o processamento está acontecendo em rápida sucessão. A maior vantagem do multithreading é que é uma técnica para obter o máximo dos recursos de processamento. Agora, essa ideia é bastante útil e pode ser usada em qualquer conjunto de ambientes, seja com uma única CPU ou várias CPUs. A ideia é a mesma.
A programação paralela, por outro lado, significa que existem várias CPUs dedicadas que são aproveitadas em paralelo pelo programador. Este tipo de programação é otimizado para um ambiente de CPU multicore. A maioria das máquinas atuais usa CPUs multicore. Portanto, a programação paralela é bastante relevante nos dias de hoje. Mesmo a máquina mais barata é montada com CPUs multicore. Olhe para os dispositivos portáteis; mesmo eles são multicore. Embora tudo pareça ótimo com CPUs multicore, aqui está outro lado da história também. Mais núcleos de CPU significam computação mais rápida ou eficiente? Nem sempre! A filosofia gananciosa de “quanto mais, melhor” não se aplica à computação, nem à vida. Mas eles estão lá, inegavelmente — dual, quad, octa e assim por diante. Eles estão lá principalmente porque os queremos e não porque precisamos deles, pelo menos na maioria dos casos. Na realidade, é relativamente difícil manter até mesmo uma única CPU ocupada na computação diária. No entanto, os multicores têm seus usos em circunstâncias especiais, como em servidores, jogos e assim por diante, ou na solução de grandes problemas. O problema de ter várias CPUs é que requer memória que deve combinar a velocidade com o poder de processamento, junto com canais de dados extremamente rápidos e outros acessórios. Em resumo, vários núcleos de CPU na computação diária fornecem uma melhoria de desempenho que não pode superar a quantidade de recursos necessários para usá-la. Consequentemente, obtemos uma máquina cara subutilizada, talvez destinada apenas a ser exibida.
Programação paralela
Ao contrário do multithreading, onde cada tarefa é uma unidade lógica discreta de uma tarefa maior, as tarefas de programação paralela são independentes e sua ordem de execução não importa. As tarefas são definidas de acordo com a função que desempenham ou dados utilizados no processamento; isso é chamado de paralelismo funcional ou paralelismo de dados , respectivamente. No paralelismo funcional, cada processador trabalha em sua seção do problema, enquanto no paralelismo de dados, o processador trabalha em sua seção dos dados. A programação paralela é adequada para uma base de problemas maior que não se encaixa em uma única arquitetura de CPU, ou pode ser que o problema seja tão grande que não possa ser resolvido em uma estimativa razoável de tempo. Como resultado, as tarefas, quando distribuídas entre os processadores, podem obter o resultado de forma relativamente rápida.
A estrutura de bifurcação/junção
O Fork/Join Framework é definido no java.util.concurrent pacote. Inclui várias classes e interfaces que suportam programação paralela. O que ele faz principalmente é simplificar o processo de criação de vários threads, seus usos e automatizar o mecanismo de alocação de processos entre vários processadores. A notável diferença entre multithreading e programação paralela com este framework é muito semelhante ao que mencionamos anteriormente. Aqui, a parte de processamento é otimizada para usar vários processadores ao contrário do multithreading, onde o tempo ocioso de uma única CPU é otimizado com base no tempo compartilhado. A vantagem adicional desse framework é usar multithreading em um ambiente de execução paralela. Nenhum dano lá.
Existem quatro classes principais neste framework:
- ForkJoinTask
: Esta é uma classe abstrata que define uma tarefa. Normalmente, uma tarefa é criada com a ajuda do fork() método definido nesta classe. Esta tarefa é quase semelhante a um thread normal criado com o Thread classe, mas é mais leve do que ela. O mecanismo que ele aplica é que permite o gerenciamento de um grande número de tarefas com a ajuda de um pequeno número de threads reais que se juntam ao ForkJoinPool . O fork() O método permite a execução assíncrona da tarefa de chamada. A junção() O método permite esperar até que a tarefa na qual é chamado seja finalmente finalizada. Existe um outro método, chamado invoke() , que combina o fork e participe operações em uma única chamada. - ForkJoinPool: Esta classe fornece um pool comum para gerenciar a execução de ForkJoinTask tarefas. Ele basicamente fornece o ponto de entrada para envios de não ForkJoinTask clientes, bem como as operações de gerenciamento e monitoramento.
- Ação recursiva: Esta também é uma extensão abstrata do ForkJoinTask aula. Normalmente, estendemos essa classe para criar uma tarefa que não retorne um resultado ou tenha um void tipo de retorno. O computar() O método definido nesta classe é substituído para incluir o código computacional da tarefa.
- Tarefa Recursiva
: Esta é outra extensão abstrata do ForkJoinTask aula. Estendemos essa classe para criar uma tarefa que retorne um resultado. E, semelhante ao ResursiveAction, também inclui um computação abstrata protegida() método. Este método é substituído para incluir a parte de computação da tarefa.
Estratégia da Estrutura Fork/Join
Este framework emprega um método recursivo dividir-e-conquistar estratégia para implementar o processamento paralelo. Basicamente divide uma tarefa em subtarefas menores; então, cada subtarefa é dividida em sub-subtarefas. Esse processo é aplicado recursivamente em cada tarefa até que seja pequeno o suficiente para ser tratado sequencialmente. Suponha que devemos incrementar os valores de um array de N números. Esta é a tarefa. Agora, podemos dividir o array por dois criando duas subtarefas. Divida cada uma delas novamente em mais duas subtarefas e assim por diante. Dessa forma, podemos aplicar um método dividir para conquistar estratégia recursivamente até que as tarefas sejam isoladas em um problema de unidade. Este problema de unidade pode ser executado em paralelo pelos vários processadores de núcleo disponíveis. Em um ambiente não paralelo, o que tínhamos que fazer era percorrer todo o array e fazer o processamento em sequência. Esta é claramente uma abordagem ineficiente em vista do processamento paralelo. Mas, a verdadeira questão é que cada problema pode ser dividido e superado ? Definitivamente não! Mas, existem problemas que muitas vezes envolvem algum tipo de matriz, coleção, agrupamento de dados que se adequa particularmente a essa abordagem. A propósito, existem problemas que podem não usar coleta de dados, mas podem ser otimizados para usar a estratégia de programação paralela. Que tipo de problemas computacionais são adequados para processamento paralelo ou discussão sobre algoritmo paralelo está fora do escopo deste artigo. Vamos ver um exemplo rápido sobre a aplicação do Fork/Join Framework.
Um exemplo rápido
Este é um exemplo muito simples para lhe dar uma ideia de como implementar o paralelismo em Java com o Fork/Join Framework.
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(); } }
Conclusão
Esta é uma descrição sucinta da programação paralela e como ela é suportada em Java. É um fato bem estabelecido que ter N núcleos não vai fazer tudo N vezes mais rápido. Apenas uma seção de aplicativos Java usa efetivamente esse recurso. O código de programação paralela é um quadro difícil. Além disso, programas paralelos eficazes devem considerar questões como balanceamento de carga, comunicação entre tarefas paralelas e similares. Existem alguns algoritmos que melhor se adequam à execução paralela, mas muitos não. De qualquer forma, a API Java não tem falta de suporte. Podemos sempre mexer nas APIs para descobrir o que melhor se adapta. Boa codificação 🙂