Clojure na prática — parte 3
Concorrência, paralelismo e gerenciamento de estado.
Olá! Seja bem vindo a terceira e última parte desta série de artigos sobre Clojure na prática. Ao término deste artigo você terá aprendido sobre:
- A diferença entre concorrência e paralelismo;
- A diferença entre Threads e Processos;
- Como processar coleções de dados de forma paralela;
- Como a função
pmap
funciona; - Como funcionam os
reducers
; - O que são Futures e Promises;
- Como funciona o gerenciamento de estado em Clojure;
- O que são Atoms, Refs e Agents;
- Como funcionam os Validators e Watchers;
Este artigo assume que você já tenha lido a parte 1 e a parte 2 desta série.
Concorrência, paralelismo e gerenciamento de estado são assuntos complexos dentro de computação, portanto esse artigo será um pouco mais longo do que os anteriores.
Concorrência e Paralelismo
“Concorrência é sobre lidar com várias coisas ao mesmo tempo e paralelismo é sobre fazer várias coisas ao mesmo tempo.” — Rob Pike, Concorrência não é Paralelismo (traduzido para pt-BR)
Concorrência ocorre quando duas ou mais atividades são executadas de forma intercalada em um mesmo intervalo de tempo. Por exemplo, quando iniciamos mais de um processo em uma máquina que possui um único processador single-core, este deverá intercalar entre a execução dos processos iniciados, já que apenas um processo pode estar em execução por vez.
Paralelismo, entretanto, ocorre quando duas atividades ocorrem simultaneamente. Em uma máquina que possui um processador multi-core, é possível que em algum momento mais de um processo esteja sendo executado ao mesmo tempo.
Em meu primeiro artigo sobre Clojure, citei que a linguagem oferece algumas ferramentas para facilitar o desenvolvimento de software concorrente e/ou paralelo. Neste artigo, vamos aprender na prática como algumas delas funcionam.
Mas antes, precisamos entender a diferença entre threads e processos.
Threads vs Processos
Basicamente, um processo é um programa em execução que possui o seu próprio espaço reservado de memória, além de outros conjuntos de recursos gerenciados pelo sistema operacional.
Uma thread é uma “linha” de execução ou uma sequência independente de instruções dentro de um processo. Podemos ter um processo com uma ou N threads em execução, e cada thread compartilha a memória do processo que faz parte com outras threads deste mesmo processo.
Por fazerem parte do mesmo processo, a “comunicação” entre threads se torna mais simples do que entre processos distintos. Além disso, o custo computacional associado à criação de uma thread é bem menor se comparado ao de criação de um novo processo.
Utilizando múltiplas threads podemos ter ganhos de performance consideráveis para realizar computações concorrentes/paralelas, principalmente em processadores multi-core.
Agora que sabemos minimamente o que são processos e threads, vamos à prática :)
Processando dados de forma paralela
pmap
A função pmap
funciona da mesma forma que a função map
padrão, exceto pelo fato de que a função f recebida como parâmentro é aplicada na coleção coll de forma paralela por diferentes threads:
O comportamento que observamos no último exemplo se deve ao fato de que por mais que o processamento realizado pela função f seja executado em paralelo, o resultado de pmap
sempre retornará na mesma ordem da coleção de entrada.
Isso implica no seguinte cenário: se o primeiro valor de uma coleção de entrada demorar 30 minutos para ser calculado e os demais apenas 1 segundo, nada é retornado pela função pmap
até que estes 30 minutos finalizem, ainda que todos os demais valores já tenham sido computados.
Além disso, side effects (como os “prints” executados), podem ocorrer em ordem “aleatória” pelo fato de serem executados em tempos/threads diferentes.
No geral, o uso de pmap
é indicado em casos aonde o tempo de execução da função f é superior a sobrecarga de executar o código em paralelo. Essa sobrecarga é resultante do custo computacional da criação das threads para realizarem o processamento e retornarem os seus resultados.
Reducers
As funções do namespace clojure.core
padrão, map
, filter
, mapcat
e etc, são tipicamente aplicadas de forma lazy e sequencial em uma única thread, gerando valores intermediários durante uma transformação de dados.
Conceitualmente, estas funções poderiam ser executadas de forma paralela sem nenhum problema, já que a função f à ser aplicada avaliará cada elemento por vez de forma independente.
O namespace clojure.core.reducers
fornece versões alternativas destas funções que basicamente combinam uma "coleção redutível" (isto é, uma coleção que sabe reduzir a si mesma), com a função de redução (o processo que precisa ser realizado durante a execução das transformações do dado).
Eu sei, é meio estranho de entender isso quando lemos pela primeira vez.
Por questões de simplicidade, vamos prosseguir com o entendimento de que em comparação com as funções padrões que aplicamos em sequências, os reducers adiam a execução das operações até o momento em que a redução de fato será realizada, evitando a criação de resultados intermediários e também a execução de forma lazy.
A redução ou transformação em si é executada pela função fold
, que é a grande "estrela" desse namespace. Ela pode ser vista como uma versão paralela doreduce
padrão, mas com algumas particularidades bem interessantes:
fold
A função fold
funciona da seguinte maneira:
- A coleção é particionada em grupos de tamanho especificado por parâmetro (caso não seja informado, utiliza o default de 512 elementos por grupo);
- A função de redução é aplicada em cada partição, possivelmente de forma paralela, utilizando threads diferentes;
- Cada partição é combinada aplicando a função de combinação (caso não seja passada por parâmetro, a mesma função de redução é aplicada), utilizando o framework fork/join do Java (ver o gif acima);
- Se a coleção não suporta folding (atualmente, apenas persistent vectors e maps suportam), executa a versão não paralela padrão do
reduce
.
Vale citar que cada thread mantém a sua própria fila individual para executar a tarefa necessária, e que quando uma thread não possui mais nenhuma atividade, ela pode “roubar” as tarefas do final da fila de outras threads que ainda estejam “ocupadas”, o que torna o processamento ainda mais eficiente.
Vamos ver um exemplo prático de utilização da função fold
:
Bem parecido com o que já conhecemos, não?
Tenha em mente que ao usar reducers precisamos sempre pensar no tamanho e tipo do dado que iremos processar, e no número de cores que possuímos. Caso o número de elementos seja menor que o tamanho da partição informada (ou o default de 512 elementos), o processamento será feito de forma sequencial em apenas uma thread.
Deixei alguns links no final do artigo com mais referências sobre reducers.
Futures
As funções que vimos até o momento nos forneceram um meio de executar código concorrente/paralelo sem que ter que lidar com a criação e gerenciamento das threads para realizar um processamento. Porém, existem situações em que se faz necessário ter um controle maior em relação a criação e execução de uma thread.
Utilizando futures conseguimos arbritariamente colocar uma task para ser processada em uma thread separada, enquanto a thread principal continua a sua execução sem ser interrompida. Podemos então assincronamente utilizar o resultado do processamento desta nova thread quando for necessário.
Vamos para um exemplo prático:
Alguns pontos importantes sobre o uso de futures:
- Não levantam exceções até que sejam desreferenciadas;
- Podem ser canceladas utilizando a função
future-cancel
; - Podemos verificar se a thread finalizou a sua execução utilizando a função
future-done?
.
Promises
Promises nos permitem desacoplar a execução de uma tarefa assíncrona com o momento de “devolução” do valor dessa tarefa. Isso nos permite transferir valores de uma thread para outra escolhendo o momento em que esses valores serão entregues.
Quando usamos futures, a execução assíncrona é iniciada no momento em que as mesmas são criadas. Com promises, temos o controle do momento em que a tarefa será iniciada e quando/aonde o resultado dessa tarefa será devolvido:
Gerenciamento de Estado
Uma das facilidades encontradas no desenvolvimento de software concorrente/paralelo em Clojure, se deve ao fato de que as estruturas de dados principais da linguagem são imutáveis, o que permite que estas sejam facilmente compartilhadas entre diferentes threads sem gerar muitos problemas.
Porém, é comum encontrarmos a necessidade de gerenciarmos algum tipo de estado em nossas aplicações, isso é, manipular alguma referência à um ou mais dados que serão consultados e modificados em diversos momentos durante a execução de um software. Em um cenário multithread, gerenciar alterações de estado pode aumentar e muito a complexidade de um código, exigindo que o desenvolvedor crie mecanismos de segurança para garantir que esse estado permaneça consistente durante todo o fluxo da aplicação.
Clojure por ser uma linguagem prática, possui alguns mecanismos que permitem que o gerenciamento de estado seja feito de forma consistente, sem exigir que os desenvolvedores tenham a necessidade de evitar manualmente os possíveis conflitos que possam ocorrer durante a manipulação do estado da aplicação.
Esses mecanismos são os quatro tipos de referência que a linguagem fornece, que funcionam como “containers” de estado. Vamos entender na prática como atoms, refs e agents funcionam (não abordarei sobre vars neste artigo).
Atom
Atoms fornecem um meio de alterarmos uma estrutura de dado imutável de forma atômica e síncrona utilizando a função swap!
, que recebe como parâmetro um atom e uma função que será aplicada nesse atom. Esta função funciona da seguinte forma:
- Primeiro, o valor “armazenado” pelo atom é consultado e salvo temporariamente;
- A função recebida por parâmetro é aplicada no atom;
- Se o valor consultado não foi alterado por nenhuma outra thread neste mesmo intervalo de tempo, o valor do atom é substituido com o resultado da aplicação da função;
- Caso contrário, o valor do atom não é alterado e o passos anteriores são executados em looping até que sejam concluídos com sucesso;
- Como a função recebida por parâmetro pode ser invocada várias vezes, ela deve ser livre de side-effects.
Alterações de estado em atoms sempre serão livres de race conditions:
Ref
Atoms fornecem um modo eficiente para representarmos um estado independente da aplicação que não precisa ser coordenado com nenhum outro estado.
Refs, por outro lado, fornecem um meio de coordenarmos alterações entre diferentes estados utilizando algo chamado Software Transactional Memory, que permite que as modificações sejam realizadas em transações que garantirão a atomicidade, consistência e isolamento das operações que serão executadas:
Agent
Agents, assim como atoms, são utilizados para armazenar estados indepentes, com a diferença de que as alterações de estado são realizadas de forma assíncrona. Isso significa que quando precisamos alterar o estado de algum agent, enviamos uma ação que será executada assincronamente em algum momento no futuro.
Nesta abordagem, o dispatch das ações são retornados imediatamente pela thread que enviou a ação de alteração para o agent:
Validators e Watchers
Todos os tipos de referência suportam validators e watchers, que são invocados sempre que o valor de uma referência é alterado. Podemos utilizar validators para guardar nossos dados de referência de possíveis estados indesejados (ex: valores negativos, listas vazias, etc), e watchers como notificadores de alguma alteração de estado, entre outras possibilidades:
Recapitulando
- pmap: aplicação paralela de uma função em todos os elementos de uma coleção;
- reducers (fold): redução paralela de coleções de dado particionáveis;
- futures: criação de threads para execução assíncrona de tarefas;
- promises: desacoplamento da execução assíncrona de uma tarefa com o momento de devolução do resultado;
- atoms: gerenciamento de estado síncrono e independente;
- refs: alteração coordenada de diferentes estados através de transações;
- agents: gerenciamento de estado assíncrono e independente.
Conclusão
Finalmente, chegamos ao fim desta série de artigos! Espero ter te ajudado a entender um pouco mais sobre como a linguagem funciona e como podemos utiliza-la no dia-a-dia.
Vimos bastante coisa até aqui, mas ainda há muito a se aprender sobre Clojure. Caso queira explorar mais ainda os assuntos abordados neste artigo, recomendo dar uma olhada nos links abaixo:
- https://clojure.org/about/concurrent_programming
- https://www.youtube.com/watch?v=dGVqrGmwOAw&t
- https://www.youtube.com/watch?v=b7cbPjsYUYY
- https://clojure.org/reference/reducers
- https://medium.com/formcept/performance-optimization-in-clojure-using-reducers-and-transducers-a-formcept-exclusive-375955673547
- https://clojure.org/reference/atoms
- https://clojure.org/reference/refs
- https://clojure.org/reference/agents
- https://clojure.org/reference/vars
- https://github.com/clojure/core.async
- https://github.com/funcool/promesa
Não esqueça de deixar a sua dúvida, crítica ou sugestão nos comentários para que eu possa melhorar ainda mais o conteúdo :)
Até logo!