[Translation] Quando usar fluxos paralelos

Fonte
Autores: Doug Lea em colaboração com Brian Goetz, Paul Sandoz, Alexey Shipilev, Heinz Kabutz, Joe Bowbeer, ...

A estrutura java.util.streams contém operações orientadas a dados em coleções e outras fontes de dados. A maioria dos métodos de fluxo executa a mesma operação em cada elemento. Usando o método de coleta parallelStream() , se você tiver vários núcleos, poderá transformar os dados em paralelos . Mas quando vale a pena fazer?


Considere usar S.parallelStream().operation(F) vez de S.stream().operation(F) , desde que as operações sejam independentes uma da outra e sejam computacionalmente caras ou aplicadas a um grande número de elementos efetivamente divididos estruturas de dados (divisíveis) ou ambas. Mais precisamente:


  • F : uma função para trabalhar com um único elemento, geralmente um lambda, é independente, ou seja, a operação em qualquer um dos elementos é independente e não afeta operações em outros elementos (para obter recomendações sobre o uso de funções sem estado sem interferência, consulte a documentação do pacote de fluxo ).
  • S : A coleção original é efetivamente dividida. Além das coleções, existem outras adequadas para paralelização, fontes de dados de streaming, por exemplo, java.util.SplittableRandom (para a paralelização da qual você pode usar o método stream.parallel() ). Mas a maioria das fontes com E / S no núcleo é projetada principalmente para operação seqüencial.
  • O tempo total de execução no modo seqüencial excede o limite mínimo permitido. Hoje, para a maioria das plataformas, o limite é aproximadamente igual (dentro de 10) a 100 microssegundos. Medidas precisas, neste caso, não são necessárias. Para fins práticos, basta multiplicar N (o número de elementos) por Q (o tempo de operação de um F ), e Q pode ser estimado aproximadamente pelo número de operações ou pelo número de linhas de código. Depois disso, é necessário verificar se N * Q é pelo menos menor que 10000 (se você é tímido, adicione um ou dois zeros). Portanto, se F é uma função pequena como x -> x + 1 , a execução paralela fará sentido quando N >= 10000 . Por outro lado, se F é um cálculo pesado, semelhante a encontrar a melhor jogada seguinte em um jogo de xadrez, o valor de Q tão grande que N pode ser negligenciado, mas até que a coleção esteja completamente dividida.

A estrutura de processamento de streaming não insistirá (e não poderá) em nenhuma das opções acima. Se os cálculos são interdependentes, sua execução paralela não faz sentido ou será prejudicial e levará a erros. Outros critérios derivados das questões de engenharia e compensações acima incluem:


  • Start-up
    A aparência de núcleos adicionais nos processadores, na maioria dos casos, foi acompanhada pela adição de um mecanismo de gerenciamento de energia, que pode causar uma desaceleração no lançamento dos núcleos, às vezes com sobreposições adicionais da JVM, sistema operacional e hypervisor. Nesse caso, o limite no qual o modo paralelo faz sentido corresponde aproximadamente ao tempo necessário para iniciar o processamento das subtarefas com um número suficiente de núcleos. Depois disso, a computação paralela pode ser mais eficiente em termos de energia do que seqüencial (dependendo dos detalhes dos processadores e sistemas. Por exemplo, consulte o artigo ).
  • Detalhamento (Granularidade)
    Raramente faz sentido dividir pequenos cálculos. A estrutura geralmente divide a tarefa para que as partes individuais possam trabalhar em todos os núcleos de sistema disponíveis. Se, após o início, praticamente não houver trabalho para cada núcleo, os esforços (geralmente seqüenciais) para organizar a computação paralela serão desperdiçados. Dado que, na prática, o número de núcleos varia de 2 a 256 limites, também evita o efeito indesejável da divisão excessiva da tarefa.
  • Divisibilidade
    As coleções divididas com mais eficiência incluem ArrayList e {Concurrent}HashMap , bem como matrizes regulares ( T[] , que são divididas em partes usando os métodos estáticos java.util.Arrays ). Os divisores menos eficientes são LinkedList , BlockingQueue e a maioria das fontes baseadas em E / S. O restante está em algum lugar no meio (as estruturas de dados que suportam acesso aleatório e / ou pesquisa eficiente geralmente são divididas com eficiência). Se a divisão dos dados demorar mais que o processamento, o esforço será em vão. Se Q for grande o suficiente, você poderá obter um aumento devido à paralelização, mesmo para o LinkedList , mas esse é um caso bastante raro. Além disso, algumas fontes não podem ser divididas em um único elemento e, portanto, pode haver uma restrição no grau de decomposição do problema.

A obtenção das características exatas desses efeitos pode ser difícil (embora, se você tentar, isso possa ser feito usando ferramentas como JMH ). Mas o efeito cumulativo é bastante fácil de notar. Sentir você mesmo - faça um experimento. Por exemplo, em uma máquina de teste de 32 núcleos, quando você executa pequenas funções, como max() ou sum() , acima do ArrayList ponto de equilíbrio é de aproximadamente 10.000. Para mais elementos, é observada uma aceleração de até 20 vezes. O horário de funcionamento de coleções com menos de 10.000 itens não é muito menor que para 10.000 e, portanto, mais lento que o processamento seqüencial. O pior resultado ocorre com menos de 100 elementos - nesse caso, os threads envolvidos param sem fazer nada útil, porque os cálculos são concluídos antes de começarem. Por outro lado, quando as operações em elementos são demoradas, ao usar coleções eficientemente e completamente divisíveis, como ArrayList , os benefícios são imediatamente visíveis.


Parafraseando tudo isso, o uso de parallel() no caso de uma quantidade excessivamente pequena de computação pode custar cerca de 100 microssegundos, e o uso de outra forma deve economizar pelo menos esse tempo (ou talvez horas para tarefas muito grandes). O custo e os benefícios específicos variarão ao longo do tempo para diferentes plataformas e também, dependendo do contexto. Por exemplo, executar pequenos cálculos em paralelo dentro de um ciclo seqüencial aumenta o efeito de altos e baixos (os microtestes de desempenho nos quais isso ocorre podem não refletir a situação real).


Perguntas e Respostas


  • Por que a JVM não consegue entender quando executar operações em paralelo?

Ela pode tentar, mas muitas vezes a decisão está errada. A busca por paralelismo multinúcleo totalmente automático não levou a uma solução universal nos últimos trinta anos e, portanto, a estrutura utiliza uma abordagem mais confiável, exigindo que o usuário escolha apenas sim ou não . Essa escolha é baseada em problemas de engenharia que são constantemente encontrados na programação seqüencial, que provavelmente nunca desaparecerão completamente. Por exemplo, você pode encontrar uma desaceleração de cem vezes ao procurar o valor máximo em uma coleção que contém um único elemento em comparação usando esse valor diretamente (sem uma coleção). Às vezes, a JVM pode otimizar esses casos para você. Mas isso raramente acontece em casos sequenciais, e nunca no modo paralelo. Por outro lado, podemos esperar que, à medida que se desenvolvam, as ferramentas ajudem os usuários a tomar melhores decisões.


  • E se, para tomar uma boa decisão, eu não tiver conhecimento suficiente sobre os parâmetros ( F , N , Q , S )?

Isso também é semelhante aos problemas encontrados na programação seqüencial. Por exemplo, o S.contains(x) da classe Collection normalmente é executado rapidamente se S for um HashSet , lento se LinkedList e médio em outros casos. Geralmente, para o autor de um componente que usa a coleção, a melhor maneira de sair dessa situação é encapsulá-lo e publicar apenas uma operação específica nele. Em seguida, os usuários serão isolados da necessidade de escolher. O mesmo se aplica às operações paralelas. Por exemplo, um componente com uma coleção de preços interna pode determinar um método que verifique seu tamanho até o limite, o que fará sentido até que a computação bit a bit seja muito cara. Um exemplo:


 public long getMaxPrice() { return priceStream().max(); } private Stream priceStream() { return (prices.size() < MIN_PAR) ? prices.stream() : prices.parallelStream(); } 

Essa idéia pode ser estendida a outras considerações sobre quando e como usar a simultaneidade.


  • E se minha função provavelmente fizer operações de E / S ou sincronizadas?

Em um extremo, estão as funções que não atendem aos critérios de independência, incluindo operações sequenciais de E / S, acesso a recursos sincronizados de bloqueio e casos em que um erro em uma subtarefa paralela que executa E / S afeta outros. Sua paralelização não faz muito sentido. Por outro lado, existem cálculos que ocasionalmente executam E / S ou sincronização raramente bloqueada (por exemplo, a maioria dos casos de criação de log e o uso de coleções competitivas como ConcurrentHashMap ). Eles são inofensivos. O que há entre eles requer mais pesquisa. Se cada subtarefa puder ser bloqueada por um tempo considerável aguardando E / S ou acesso, os recursos da CPU ficarão inativos sem a possibilidade de seu uso pelo programa ou JVM. Disso é ruim para todos. Nesses casos, o processamento de streaming paralelo nem sempre é a escolha certa. Mas existem boas alternativas - por exemplo, E / S assíncrona e a abordagem CompletableFuture .


  • E se minha fonte for baseada em E / S?

No momento, usando os geradores JDK Stream / I / O (por exemplo, BufferedReader.lines() ), eles são principalmente adaptados para uso no modo seqüencial, processando elementos um a um quando estiverem disponíveis. O suporte ao processamento em massa de alto desempenho de E / S tamponada é possível, mas, no momento, isso requer o desenvolvimento de geradores especiais Stream s, Spliterator e Collector s. Suporte para alguns casos comuns pode ser adicionado em versões futuras do JDK.


  • E se meu programa for executado em um computador ocupado e todos os kernels estiverem ocupados?

As máquinas geralmente têm um número fixo de núcleos e não podem criar magicamente novos ao executar operações paralelas. No entanto, desde que os critérios para a escolha de um modo paralelo falem claramente, não há o que duvidar. Suas tarefas paralelas competirão pela CPU com outras pessoas e você perceberá menos aceleração. Na maioria dos casos, isso ainda é mais eficaz do que outras alternativas. O mecanismo subjacente foi projetado para que, se não houver núcleos disponíveis, você notará apenas uma ligeira desaceleração em comparação com a versão seqüencial, exceto quando o sistema estiver tão sobrecarregado que passará todo o tempo trocando contextos em vez de fazer algum trabalho real, ou configurado na expectativa de que todo o processamento seja executado sequencialmente. Se você possui esse sistema, talvez o administrador já tenha desativado o uso de multithreading / nuclearity nas configurações da JVM. E se você é o administrador do sistema, faz sentido fazer isso.


  • Todas as operações são paralelas ao usar o modo paralelo?

Sim Pelo menos até certo ponto. Mas vale a pena levar em consideração que a estrutura de fluxo leva em consideração as limitações de fontes e métodos ao escolher como fazer isso. Em geral, quanto menos restrições, maior o potencial de paralelismo. Por outro lado, não há garantia de que a estrutura identifique e aplique todas as oportunidades disponíveis para simultaneidade. Em alguns casos, se você tiver tempo e competência, sua própria solução poderá aproveitar muito melhor as possibilidades de simultaneidade.


  • Que aceleração receberei da concorrência?

Se você seguir essas dicas, geralmente, o suficiente para fazer sentido. A previsibilidade não é um ponto forte do hardware e sistemas modernos e, portanto, não há resposta universal. Localidade do cache, características do GC, compilação JIT, conflitos de acesso à memória, local dos dados, políticas de agendamento do SO e a presença de um hipervisor são alguns dos fatores que têm um impacto significativo. O desempenho do modo seqüencial também está sujeito à influência deles, que, ao usar o paralelismo, é frequentemente amplificada: o problema que causa uma diferença de 10% no caso de execução sequencial pode levar a uma diferença de 10 vezes no processamento paralelo.


A estrutura de fluxo inclui alguns recursos que ajudam a aumentar as chances de aceleração. Por exemplo, o uso de especialização para primitivas, como IntStream , geralmente tem um efeito maior no modo paralelo do que no modo seqüencial. O motivo é que, nesse caso, não apenas o consumo de recursos (e memória) diminui, mas a localidade do cache também melhora. O uso do ConcurrentHashMap vez do HashMap , no caso da operação paralela da operação de collect , reduz os custos internos. Novas dicas e truques aparecerão conforme a experiência adquirida com a estrutura.


  • Tudo isso é muito assustador! Não podemos simplesmente criar regras para usar propriedades da JVM para desativar a simultaneidade?

Não queremos lhe dizer o que fazer. O surgimento de novas maneiras de os programadores fazerem algo errado pode ser assustador. Erros de código, arquitetura e avaliações certamente acontecerão. Décadas atrás, algumas pessoas previram que a concorrência no nível do aplicativo levaria a grandes desastres. Mas isso nunca se tornou realidade.

Source: https://habr.com/ru/post/pt420805/


All Articles