capa paralelismo - univale.com.br · duzir. este artigo tem como objetivo eliminar a mágica e a...

12
/ 16 em Java Paralelismo e Performance capa_ Escrever código para processamento em paralelo é algo que, muitas vezes, mistura mágica e polêmica. Erros devido à falta de sincroni- zação normalmente não são fáceis de descobrir ou tão pouco repro- duzir. Este artigo tem como objetivo eliminar a mágica e a polêmica com relação a este assunto. Eliminar a mágica no sentido de men- surar o comportamento do código, seja ele sequencial ou paralelo. E eliminar a polêmica no sentido de escrever código com comporta- mento seguro e previsível. Desenvolva código paralelo de forma simples mensurando ganhos reais em performance. O processamento paralelo faz parte do dia-a-dia de desenvolvedores, sem que isso esteja explíci- to. Afinal de contas, até uma simples aplicação web possui um ThreadPool implementado pelo servidor de aplicação, por exemplo. Em muitos casos, não é necessário entender os mecanismos ou boas práticas do processamento paralelo, graças ao bom trabalho de abstração e arquitetura feito no desenvolvimen- to de servidores de aplicação e frameworks. Muitas vezes existe a oportunidade de paralelizar algum processamento, mas não é conveniente fazer pelo simples fato de manter a arquitetura simples para não introduzir erros ou complexidades no código. A infraestrutura de servidores de aplicação muitas ve- zes consegue deixar algo que foi desenvolvido para processamento sequencial em uma velocidade satis- fatória. O fato é: existem diversos motivos para não se preocupar com código para processamento para- lelo ou programação Multi-threaded. Algumas vezes porém, é necessário enfrentar ce- nários diferentes destes citados, onde o desenvolve- dor deve melhorar performance de algo que já tenha sido otimizado ao máximo. As opções de servidores mais robustos já se esgotaram, tal como simplificar Paralelismo

Upload: doankien

Post on 06-Dec-2018

213 views

Category:

Documents


0 download

TRANSCRIPT

/ 16

em Java

Paralelismoe Performance

capa_

Escrever código para processamento em paralelo é algo que, muitas vezes, mistura mágica e polêmica. Erros devido à falta de sincroni-zação normalmente não são fáceis de descobrir ou tão pouco repro-duzir. Este artigo tem como objetivo eliminar a mágica e a polêmica com relação a este assunto. Eliminar a mágica no sentido de men-surar o comportamento do código, seja ele sequencial ou paralelo. E eliminar a polêmica no sentido de escrever código com comporta-mento seguro e previsível.

Desenvolva código paralelo de forma simples mensurando ganhos reais em performance.

O processamento paralelo faz parte do dia-a-dia de desenvolvedores, sem que isso esteja explíci-

to. Afi nal de contas, até uma simples aplicação web possui um ThreadPool implementado pelo servidor de aplicação, por exemplo. Em muitos casos, não é necessário entender os mecanismos ou boas práticas do processamento paralelo, graças ao bom trabalho de abstração e arquitetura feito no desenvolvimen-to de servidores de aplicação e frameworks. Muitas vezes existe a oportunidade de paralelizar algum processamento, mas não é conveniente fazer pelo simples fato de manter a arquitetura simples para

não introduzir erros ou complexidades no código. A infraestrutura de servidores de aplicação muitas ve-zes consegue deixar algo que foi desenvolvido para processamento sequencial em uma velocidade satis-fatória. O fato é: existem diversos motivos para não se preocupar com código para processamento para-lelo ou programação Multi-threaded.

Algumas vezes porém, é necessário enfrentar ce-nários diferentes destes citados, onde o desenvolve-dor deve melhorar performance de algo que já tenha sido otimizado ao máximo. As opções de servidores mais robustos já se esgotaram, tal como simplifi car

Paralelismo

17 \

Luiz Fernando Teston | [email protected] Possui mais de 12 anos de experiência em desenvolvimento de software não trivial. Neste tempo já desenvolveu plugins

para Eclipse, analisadores de código, sistemas de alta performance em Java e C++, dentre outros. Já palestrou em eventos como TDC, NoSQL:BR, JustJava e JavaOne São Paulo. Atualmente é engenheiro de Software Sênior no campus

de desenvolvimento da Ericsson Irlanda.

as regras de negócio. A fila para agendamento de processamentos a noite já está cheia. Para otimizar qualquer código existente ou para preparar código novo, normalmente o programador faz deduções como: “Com certeza determinada parte é o proble-ma”. Após horas de trabalho é possível que não te-nha havido nenhuma melhoria sequer, ou pior ainda, que o código esteja com novas falhas ou mesmo com um tempo de processamento maior.

Este artigo propõe abordagens concisas para abordar este tipo de problema, como, por exemplo, medir qual o tempo do gargalo que está ocorrendo e descobrir onde está ocorrendo este mesmo garga-lo. Da mesma forma, é necessário obter medições da melhoria, para que a instalação em produção seja fei-ta de forma confiável e sem nenhum receio por par-te do autor da alteração. Para fazer estas medições existem técnicas e ferramentas, tais como medidores de performance ou Profillers, desenvolvimento de testes unitários e integrados, logs e outras aborda-gens do gênero. Após medir e isolar o determinado trecho do código a ser otimizado, utilizando orienta-ção a objetos é possível desenvolver um código novo seguindo a interface do código legado sequencial, e isolar o processamento paralelo. Desta forma, torna--se simples efetuar testes e medições em cenários novos e antigos. Utilizando esta abordagem, é pos-sível se preparar para diversos problemas que se en-frenta no dia-a-dia de desenvolvimento de software, argumentando que determinada alteração trouxe melhorias com evidências claras ou mesmo se pre-parar para a troca de código trocando-se apenas as implementações. Este tipo de atividade não precisa ser complicada e muito menos chata ou desgastante. De fato, isto pode ser divertido e gratificante princi-palmente ao se deparar com resultados satisfatórios e, por consequência, elogios de outros programado-res e pessoas da área. vamos ao que interessa!

Entendendo e visualizando paralelismo e concorrência

Antes de começar a praticar, é necessário enten-der alguns conceitos. Provavelmente os conceitos iniciais explicados aqui serão familiares, mas algu-mas vezes o entendimento de alguns destes con-ceitos é vago. A explicação acadêmica nem sempre é simples de entender para muitos. Nem sempre se entende problemas como “Barbeiro dorminhoco” ou

“Jantar dos filósofos” já na primeira leitura, embora não sejam problemas realmente complexos. O pri-meiro conceito a ser definido é o conceito de linha de execução, ou thread.

Uma thread, desde que não esteja disputando nenhum recurso, processa de forma independente instruções de um programa. Em se tratando de Java, quando executamos o método main, haverá uma thread para a execução deste método. Internamen-te existe um agendador coordenando a execução das threads, dividindo o tempo disponível dos processa-dores entre as várias threads que precisem ser exe-cutadas. De certa forma, é possível pensar em uma disputa existente entre as threads pelo tempo dis-ponível dos processadores. Em um cenário hipotéti-co, onde existe somente um núcleo do processador disponível para processar mais de uma thread, esta disputa pelo processador certamente ocorrerá. Para existir a aparência de executar todas estas threads ao mesmo tempo será necessário dividir o tempo do processador em fatias de tempo, e ceder cada uma destas fatias para todas as threads em um momen-to diferente. Não ocorre execução em paralelo, pois somente uma thread está executando em um dado momento, mas conforme passa o tempo, a aparên-cia é que todas as threads estão executando. Em um cenário como este, dizemos que a execução é feita de forma concorrente. A execução somente será feita em paralelo em casos onde não ocorrem nenhuma disputa sequer, seja pelo processador ou seja por ou-tros recursos.

É possível ver a diferença entre paralelismo e concorrência ao ver gráficos de monitores de per-formance ou profillers, como, por exemplo, o Visual vM. O visual vM é um programa de monitoração (ou profiller) disponibilizado pela Oracle. Ele é bastante simples de usar. Na figura 1 temos um exemplo de disputa por recursos, onde é possível ver no gráfico de execução das threads, algumas threads nomeadas por “disputer” 1, 2 e 3. Temos pedaços em vermelho, mostrando a thread parada devido à disputa por al-gum recurso, pedaços em verde, mostrando a thre-ad sendo executada, e pedaços em roxo, mostrando a thread em estado “sleeping”. Utilizar um profiller quando se programa esperando paralelismo é algo extremamente recomendável. Na imagem citada, não temos um resultado satisfatório no uso de pa-ralelismo. Isto somente fica claro no uso do profiller

/ 18

uma frase por vez ao invés de uma letra por vez, ou uma palavra por vez, por exemplo. Na figura 2, é pos-sível ver um exemplo onde se pode escrever uma pa-lavra por vez ou uma frase por vez. Em um dos exem-plos a monitoração da escrita é feita de forma correta, e em outra não. O exemplo aqui mostra de forma bem clara que em um dos casos o estado do conteúdo do chat fica inconsistente. O mesmo ocorre se conside-rarmos duas threads de um mesmo programa tentan-do a escrita ao mesmo tempo em um arquivo.

Figura 3. Falta de sincronização em um contador de acessos.

Figura 4. Sincronização correta em um contador de acessos.

Na figura 3, temos outro exemplo do que a falta de um lock pode fazer em termos de produzir um es-tado inconsistente, onde um contador com valor 1 ao ser incrementado duas vezes deveria ter um valor fi-nal 3 e não 2. Isto não teria acontecido se houvesse o uso correto de um lock permitindo que somente uma thread pudesse obter e alterar o valor da variável do contador por vez. Basicamente, nos casos de disputa de recursos, para mantermos um estado consistente, é necessário que este recurso esteja com apenas uma thread o modificando por vez. É possível enumerar inúmeros exemplos de estado inconsistente devido à falta de sincronização para mostrar a necessidade do uso de locks.

durante a execução. O profiller tira “fotos” de como está a execução das threads de tempos em tempos e monta o gráfico. Assim ele não interfere na execução de forma a atrapalhar de forma sensível a execução. E isto é um dos fatores que diferenciam os profillers existentes no mercado.

Figura 1. Execução de threads monitoradas pelo Oracle Visual VM.

A disputa por recursosAlém do processador, existe uma série de outros

itens que são alvo de disputa. Em se tratando de re-cursos computacionais, pode existir disputas por leitura e escrita de arquivos, comunicação de rede, recursos de um banco de dados e itens afins. Tudo o que pode ser acessado ao mesmo tempo precisa ser monitorado para que não fique em estado inconsis-tente. Para que uma thread possa utilizar um recurso monitorado, existem algumas formas de sincronizar este acesso. Isto se dá ao uso de travas ou locks. Em Java, existem algumas formas de fazer isso, como, por exemplo, o uso da palavra-chave synchronized, ou o uso de classe para locking da API de concorrência do Java. O objetivo é o mesmo: garantir que apenas uma thread irá utilizar um recurso por vez. Caso um recurso já esteja sendo utilizado por outra thread, a thread que solicita o recurso irá ficar aguardando este mesmo recurso estar disponível. Falando de for-ma mais simples, cada thread “terá a sua vez” de usar um recurso.

Figura 2. Sincronização em um chat.

Utilizando o cenário onde temos a escrita de fra-ses em um chat existe um texto comum que deve ser exibido para todos. O ideal é que seja escrita apenas

Como você está? SINCRONIzAçãO APROPRIADA

SINCRONIzAçãO NãO APROPRIADA

1.Como você está?2. Tudo bem! E você?3. Tudo bem também.

Tudo bem! E você?

Tudo bem também.

Como você Tudo estábem! E Tudo bem você?também.

Obter e incrementar

THREAD 1

MOMENTO INICIAL:ESTADO CONSISTENTE

MOMENTO FINAL:ESTADO INCONSISTENTE

THREAD 2 Obter e incrementar

Total Total

1+1=2

1+1=2

1 2

Obter e incrementar

THREAD 1

MOMENTO INICIAL:ESTADO CONSISTENTE

MOMENTO FINAL:ESTADO CONSISTENTE

THREAD 2 Obter e incrementar

Total Total Total

1+1=2

1+1=2

1 2 3

19 \

Cuidados ao utilizar locksEm alguns momentos pode ser necessário usar

mais de um lock, como, por exemplo, em um cenário onde existe uma movimentação bancária entre duas contas. A operação de retirada de uma das contas precisa ser sincronizada com a operação de entra-da na conta que recebe o dinheiro da transferência. Este tipo de problema de sincronização já foi resol-vido por muitos bancos de dados, e é um problema difícil de ser resolvido. Pense por um momento na dificuldade de resolver este problema. Por exemplo, utilizar somente um lock para proteger todas as con-tas seria inviável, dado o número elevado de contas bancárias. Outra solução é utilizar um lock por conta bancária. Em cada operação de transferência, seriam adquiridos os locks de cada uma das contas bancárias envolvidas na movimentação, e então, somente após a aquisição de todos os locks das contas envolvidas, seria possível efetuar a movimentação.

Figura 5. Exemplo do uso de locks em movimentação de contas bancárias.

No entanto, em um cenário onde temos mais de um lock envolvido, existe a possibilidade de que ocorra um dead lock. Dead lock é o nome dado quan-do temos dois ou mais recursos sendo usados e ao mesmo tempo aguardado por duas ou mais threads envolvidas. É difícil resumir em apenas uma frase como funciona um dead lock de forma clara. Cada lock envolvido é adquirido por uma thread de forma atômica. Sendo assim, para adquirir dois locks, é ne-cessário adquirir um por vez. Utilizando o exemplo da conta bancária, iremos efetuar uma movimenta-ção da conta A para a conta B, e ao mesmo tempo uma movimentação da conta B para a conta A. Os passos envolvidos na primeira movimentação são adquirir o lock da conta A, adquirir o lock da conta B, e então

efetuar a movimentação bancária, terminando por devolver o lock da conta B e, em seguida, devolver o lock da conta A. Na segunda movimentação a lógica é a mesma, mas em ordem diferente. Em um dado mo-mento a primeira thread terá adquirido o lock da con-ta A, e estará aguardando o lock da conta B enquanto a segunda thread terá adquirido o lock da conta B e estará aguardando o lock da conta A. Neste cenário, ambas estarão esperando eternamente, consumindo ciclos de processamento e, em muitas vezes, travando a aplicação envolvida.

Figura 6. Exemplo de dead lock.

Existem algumas formas simples de se evitar dead locks. A primeira delas é simplesmente utilizar um lock para toda a aplicação. Nesta abordagem, o paralelismo é sacrificado a cada vez que ocorre dispu-ta pelo lock global na aplicação. Outra forma é garan-tir a ordem de aquisição dos locks. Isto é bastante di-fícil, pois, na maior parte dos casos, as aplicações em que ocorrem processamentos em paralelo possuem um código bastante grande e complexo. Uma terceira forma de se evitar dead locks, é evitando a disputa por recursos. A disputa por recursos sempre vai ocor-rer de uma forma ou de outra, mas é interessante pelo menos minimizar ao máximo a disputa por recursos, pois assim se minimiza o risco durante a execução.

HeisenbugsEm física quântica existe o chamado princípio da

incerteza de Heisenberg, que diz que, ao observar um experimento, ele é alterado. Explicado de forma ex-cessivamente simplista, em física quântica, isto ocor-re pois o universo quântico é sensível a interferências, inclusive de detectores e microscópios eletrônicos, por exemplo. Agora, tomemos como ambiente não o universo quântico, mas o sistema o qual ocorre um processamento em paralelo em um computador. Para observar o que acontece é necessário interferir no sistema, seja imprimindo uma mensagem de log em

Conta bancária A

Saldo: R$ 1.000.00

Conta bancária A

Saldo: R$ 1.000.00

Conta bancária A

Saldo: R$ 500.00

Conta bancária B

Saldo: R$ 1.000.00

Conta bancária B

Saldo: R$ 1.000.00

Conta bancária B

Saldo: R$ 1.500.00

TransferênciaConta A > Conta B

R$ 500.00

MOMENTO INICIAL:CONTAS DISPONíVEIS PARA MOVIMENTAçãO

MOMENTO INTERMEDIáRIO:

lOCk DAS DUAS CONTAS PARA MOVIMENTAçãO

MOMENTO FINAL:CONTAS DISPONíVEIS

PARA MOVIMENTAçãO EM ESTADO CONSISTENTE

Thread 1 Thread 1 Thread 1

Thread 2 Thread 2 Thread 2

LockA Lock

ALock

A

LockB Lock

BLock

B

LockB

LockA

MOMENTO INICIAL:AMBAS AS ThREADS ESTãO EXECUTANDO E NENhUMA

DElAS ADQUIRIU AlGUM lOCk

AMBAS AS ThREADS ADQUIREM SEU

RESPECTIVO lOCk EM ORDEM DIFERENTE

MOMENTO FINAL:AMBAS AS ThREADS

ESTãO ESPERANDO UM lOCk Já ADQUIRIDO

/ 20

um arquivo ou na tela, seja parando a execução du-rante uma sessão de depuração ou debug. Repare que todos os itens citados mudam algum estado. Ou seja, qualquer forma de observar um programa em execu-ção constitui em se ter alguma alteração de estado ou side effect (será definido adiante). Os side effects neste caso podem mudar a ordem de aquisição dos locks, por exemplo. Ao ativar um log, efetuar um de-bug ou mesmo ao se trocar o ambiente de execução podemos ter como resultado uma alteração na ordem de aquisição de locks. Um dead lock ou erro que só acontece em produção pode deixar de acontecer em desenvolvimento, em debug ou mesmo em uma nova execução. Estes erros, que tiram o sono de muitos programadores, podem ficar por muito tempo sem se manifestar e/ou só se manifestar em determina-das condições. São conhecidos por heisenbugs. Não é necessário o uso de locks para se ter um heisenbug. A falta de locking ou sincronismo introduz compor-tamento inesperado e é ainda mais perigosa. A me-lhor forma de se evitar heisenbugs é ter um código que não faça uso abusivo de locks, mas que nem por isso deixe de ser seguro. E uma forma simples de fa-zer isso é utilizar a abordagem funcional durante o paralelismo.

A abordagem funcionalProgramação funcional é um assunto que está

voltando ao foco aos poucos com linguagens como Scala, Erlang ou Clojure. Mas o fato é que muitos dos conceitos importantes de programação funcional po-dem ser utilizados em qualquer linguagem. O fato de Java ser orientada a objetos significa que a linguagem foi projetada tendo orientação a objetos em mente, não impedindo de se utilizar Java de forma funcional. De fato, algumas práticas de programação funcional, como, por exemplo, imutabilidade de objetos, são até citadas como boas práticas em Java no livro Effective Java, de Joshua Bloch. Um dos conceitos de progra-mação funcional mais importantes que existem, é o conceito de efeitos colaterais ou side effects. Ocorrem side effects quando uma função ou trecho de um pro-grama modifica algum estado de qualquer elemento, seja ele interno ou externo. Alguns exemplos de side effects são, por exemplo, uma movimentação ban-cária, gravar dados em um arquivo, escrever algo no banco de dados, mudar uma data do sistema, mudar um atributo de algum objeto. De fato, até imprimir um “hello world” na tela implica em se ter um side effect. Side effects na maior parte dos casos significa um ponto onde pode ocorrer disputa. O interessante aqui é que muitas ações triviais feitas no dia-a-dia de desenvolvimento implicam em existir side effects du-rante sua execução.

O paradigma de programação funcional possui este nome por ter muito a ver com funções matemá-

ticas. Por definição, a função pura em programação funcional não pode depender de nenhum estado in-terno ou externo em sua execução e, muito menos, pode alterar qualquer estado, interno ou externo. Por exemplo, uma função pura não pode alterar a data do sistema e nem pode depender desta data. Também não pode alterar estado de objetos ou tão pouco ler ou salvar algo no banco de dados. Funções puras são parecidas com funções matemáticas, onde se recebe parâmetros e se devolve um resultado, não sendo fei-to nada além disso. Funções matemáticas como seno, cosseno, tangente, soma, subtração etc. são funções puras. O mais interessante, no que diz respeito a fun-ções puras, é o fato de que, ao utilizá-las, é possível obter paralelismo. Estas funções não adquirem nem modificam recursos externos, e por isso não neces-sitam do uso de locks. Isolando um código em partes puras e impuras é possível saber onde podem ocorrer dead locks no código. Basicamente, a forma de traba-lhar em paralelo, proposta neste artigo, é não depen-der de estado interno ou externo durante a execução em paralelo.

Pode parecer contraditório falar de programação funcional em uma linguagem orientada a objetos, afi-nal de contas, objetos guardam e modificam estado interno o tempo todo. A questão aqui não é deixar de usar orientação a objetos, mas sim utilizar da forma que melhor convém cada uma de suas características. A forma de trabalhar com objetos de forma funcional é utilizar objetos imutáveis, tanto como argumento quanto como resultado de métodos. Esta abordagem é utilizada em Java em muitas classes do dia-a-dia, como, por exemplo, as classes String, wrappers em geral, como BigDecimal, Integer ou Double. Examine por um momento um dos métodos matemáticos da classe BigDecimal. Estes métodos não modificam o estado interno de seu objeto, mas sim retornam um BigDecimal novo com o resultado. A mesma aborda-gem pode ser feita de forma simples em seus objetos de negócio que precisem ser paralelizados. Faça uma classe com atributos marcados como “final”, imple-mente seus métodos que iriam modificar o estado interno retornando valores novos, e já existe a garan-tia que as lógicas de negócio de sua classe podem ser chamadas em paralelo sem maiores preocupações. O mais interessante é que ao trabalhar com objetos imutáveis, mesmo sem envolver chamadas em para-lelo, a segurança no código aumenta. Verifique que nem por acidente é possível alterar o valor de uma variável (constante) compartilhada.

Listagem 1. Exemplo de segurança no código com a abordagem funcional.

BigDecimal bd = BigDecimal.TEN;

// A chamada abaixo retorna um novo objeto de valor 20,

21 \

// que é descartado. A constante BigDecimal.TEN não foi // alterada nem por acidenteBigDecimal.TEN.add(BigDecimal.TEN);

// abordagem funcional para obter um resultadobd = bd.add(BigDecimal.TEN);

Lembrando que, para uma classe ser imutável, seus campos precisam ser imutáveis ou pelo menos pro-tegidos de alteração externa. Uma das técnicas para proteger um campo mutável de alteração é retornar uma cópia deste campo em seu método getter. Caso uma classe imutável tenha um número excessivo de argumentos em seu construtor, é possível passar um objeto de parâmetros mutável em seu construtor, ou utilizar o design pattern builder conforme descrito no livro Effective Java. Pode parecer burocrático ter uma classe a mais apenas para poder auxiliar na criação de uma nova instância imutável, mas pense nas van-tagens. Objetos imutáveis não precisam de proteção. Podem ser passados como argumento para execuções em paralelos sem necessitar de locking. Isto, por si só, já previne dead locks, simplifica e deixa o código robusto, previsível e, principalmente, simples.

Encontrando o gargaloMuitas vezes o programador se depara com um

problema de performance em um código desconheci-do. Um cenário comum é um programador iniciando em uma empresa como funcionário novo, herdando o projeto cujo autor não trabalha mais na empresa. Um funcionário antigo também poderá ser acionado para descobrir o que está acontecendo em determina-da aplicação em produção, sendo ele o autor da apli-cação ou não. Existem diversas formas de proceder nestes cenários. Todas as formas podem ser conve-nientes, ou não, dependendo do cenário. Um código conhecido na maior parte dos casos não exigirá tanta investigação quanto um código desconhecido para entendimento do mesmo. Ler o código para procurar o gargalo é a primeira coisa que se faz na maioria dos casos, muitas vezes por instinto. Este procedimento para entendimento de sua execução é algo totalmen-te válido, mas o esforço em procurar gargalos lendo o código poucas vezes tem resultado satisfatório, prin-cipalmente se o gargalo não for trivial. Esta aborda-gem em pouco tempo se transforma em tentativa e erro. Para “apontar suspeitos” pelo problema de per-formance apenas por suposição, se esta for a única forma, os “principais suspeitos” terão alguma comu-nicação externa envolvida, seja ela leitura e escrita em arquivos, rede ou comunicação com banco de da-dos. Cláusulas SQL que não utilizam índices de forma apropriada ou que trafegam mais dados do que deve-

riam são comuns, assim como transações em banco de dados que duram mais tempo do que o necessário, travando registros que precisem ser utilizados por outras transações.

Figura 7. Profiller exibindo tempo gasto por métodos.

Nos casos onde temos comunicação externa como um possível vilão, o uso de escrita em arquivos de logs mostrando o tempo gasto nestas operações se trata de uma ferramenta útil. Existe a introdução de um side effect aqui para efetuar uma medição, mas, neste caso, isso se trata de algo válido. Uma vantagem clara do arquivo de log é que será possível ver o com-portamento de algo que já aconteceu e que não será possível simular em ambiente de desenvolvimento. Por exemplo, um relato de um gerente dizendo “on-tem às 18h30 a aplicação ficou instável” se torna uma pista extremamente útil, caso existam entradas úteis nos arquivos de log para este horário. A lição aqui é simples: utilize arquivos de log para relatar os passos críticos de sua aplicação. Mas sempre tenha cuidado para não colocar entradas desnecessárias ou redun-dantes. Entradas de log dentro de um método chama-do repetidas vezes poluem o log com o mesmo texto e atrapalham ao invés de ajudar.

Outra ferramenta utilizada é efetuar debugs na aplicação em busca do mesmo comportamento de erro relatado. Efetuar debug é uma das técnicas para se achar problemas de performance, sem dúvida. Mas fique atento, pois depois de alguns minutos efetuan-do o debug de uma aplicação, às vezes por acidente passamos da etapa procurada dentro da aplicação. Lembre-se que em debug, o comportamento do pa-ralelismo da aplicação será certamente modificado. Use break points condicionais nos casos em que bus-ca uma determinada condição ao invés de controlar a execução do debug passo a passo olhando o valor de cada variável. O break point condicional somen-te será ativado caso uma condição seja satisfeita. No eclipse proceda de acordo com o mostrado nas figu-ras. Outras plataformas de desenvolvimento possuem recursos semelhantes. Mas se lembre que debug nem sempre é uma opção conveniente, principalmente em se tratando de heisenbugs.

/ 22

Figura 8. Habilitando break point condicional.

ProfillersUma das melhores formas de encontrar garga-

los na execução de códigos é utilizando um profiller. Nem sempre é possível anexá-lo ao ambiente o qual o problema se manifesta. Quando é possível, seu uso é sem dúvida uma das formas mais efetivas de desco-brir o que está causando um determinado comporta-mento ou problema de performance.

Existem diferentes profillers no mercado. A maior parte tem o foco em gravar e/ou visualizar o que está acontecendo durante a execução de um código. Al-guns deles têm o custo aparentemente elevado no mercado, mas isto possui um motivo: eles interferem menos na execução da aplicação do que outros. vale a pena executar uma versão de teste destes profillers para avaliar, ou mesmo para resolver um problema pontual que esteja ocorrendo. O autor deste artigo não possui nenhum vínculo com fornecedores, mas sugere que o leitor teste o Oracle Visual VM, o JProfil-ler e o Yourkit. Eles são parecidos em suas funções e a preferência por determinado profiller pode se tornar uma preferência tão subjetiva quanto à preferência por algum ambiente de desenvolvimento. Tire suas próprias conclusões. Neste artigo estão sendo usadas imagens de uma execução do Oracle visual vM, mas tenha em mente que outros profillers possuem fun-ções semelhantes.

Figura 9. Profiller exibindo uso de memória e outros gráficos.

Algumas das principais visualizações disponí-veis são: execução de threads, tempo gasto durante

a execução de métodos, monitor mostrando gráficos de memória heap e perm gen space, e memória gasta por quantidade de objetos alocados. A importância da visualização do monitor é mostrar o comportamento do garbage collector e também se existem muitos ob-jetos retidos durante a execução. É possível ver qual o tipo de objeto que está consumindo mais memória durante a execução. Da mesma forma, é possível ver o método que está gastando mais tempo. Esta visu-alização será uma das mais importantes quando se estiver procurando gargalos durante uma execução. O ponto de maior demora será o candidato à otimi-zação, se possível com paralelismo. Raramente uma aplicação tem como seu maior problema o consumo de CPU. Normalmente os gargalos estão ligados a ou-tros fatores e a demora é uma consequência da forma com que o programa foi desenhado. Nos casos em que CPU é um dos fatores determinantes para problemas de performance, a escolha do profiller deve ser feita depois de se testar algumas possibilidades.

Possibilidade de paralelismo: existindo, prepare seu código

Utilizando uma das técnicas citadas anterior-mente é possível encontrar os pontos onde está sen-do gasto mais tempo na aplicação. Sair deste ponto para paralelizar o código nem sempre é simples. Exis-te a necessidade de analisar alguns aspectos, dentre eles se existe um grande volume de informação que possa ser dividido para ser processado, e se a demo-ra acontece de fato neste ponto. Quando o tempo de processamento cresce com o volume de informação, geralmente é possível paralelizar de alguma forma. Nesta etapa, as métricas citadas anteriormente serão úteis para que a refatoração não seja feita no escuro. Comece verificando se o ponto o qual será feita a al-teração está isolado de alguma forma ou não. Por iso-lado, falando de forma clara: a etapa onde será feito o paralelismo está em uma ou poucas classes fáceis de controlar? Se a resposta para esta pergunta for não, comece por uma refatoração extraindo uma interfa-ce que terá sua implementação paralelizada inter-namente. Assim, será possível mudar entre a versão paralela ou sequencial de forma simples.

Temos um cenário hipotético, onde é necessário finalizar os pedidos de uma empresa, listando suas filiais e notas, de acordo com o código da Listagem 2.

Listagem 2. Cenário hipotético a ser paralelizado.

public void finalizaPedidos(long empresaId){ Empresa e = dao.buscaEmpresa(empresaId); List<Filial> filiais = dao.listarFiliais(e); for(Filial f: filiais){ List<Notas> notas = dao.listarNotas(f); for(Nota n: notas){ List<Imposto> impostos =

23 \

gerarImpostos(n); // … ponto onde ocorre demora devido ao grande // numero de itens … } }}

Este código não possui uma interface clara onde é se-parado o ponto onde ocorre a demora. O fato de utili-zar paralelismo não elimina outras possibilidades de otimização, como, por exemplo, deixar o seu mapea-mento objeto relacional de lado por um momento e efetuar uma query pesada em SQL nativo JDBC com otimizações como fetch result size coerente (no caso de leitura em massa), ou efetuar batch inserts/update em JDBC (no caso de gravação de massa de dados). Estes assuntos fogem ao escopo do artigo, mas vale a pena dar uma olhada nas referências e fazer alguns testes, pois a falta de uso destes recursos infelizmen-te é comum. No exemplo o número de acessos ao banco de dados será reduzido com apenas um método da classe DAO sendo chamada ao invés de dois.

Listagem 3. Primeira refatoração: simplificando aces-so a dados externos.

public Status finalizaPedidos(long empresaId){ ResultSet rs = dao.listarNotasParaTodasAsFiliais(empresaId); while(rs.next()){ Nota n = converter(rs); // … ponto onde ocorre demora devido ao grande // numero de itens … }}

No exemplo acima foi retornado um objeto Result-Set onde cada entrada é convertida em um objeto de domínio por demanda. Este ponto fictício foi feito propositalmente para exemplificar um possível uso de um objeto ResultSet com fetch result size coerente ou mesmo uma SQL nativa otimizada.

Listagem 4. Segunda refatoração: preparando para o paralelismo.

private FinalizadorPedidoEmLote finalizadorEmLote = new FinalizacaoSequencial();

public Status finalizaPedidos(long empresaId){ Iterable<Notas> notas = dao .listarNotasParaTodasAsFiliais(empresaId); List< FinalizacaoPedido > finalizacoes = new

LinkedList< FinalizacaoPedido >(); for(Nota n: notas){ // cada processamento sera agrupado finalizacoes(new FinalizacaoPedido(n)); } // e seu processamento sera feito em um ponto // externo finalizadorEmLote.processar(finalizacoes);}// Interface onde a finalizacao do lote de pedidos sera // executadapublic interface FinalizadorPedidoEmLote{ public Status processar(Collection< FinalizacaoPedido > finalizacoes);}

// Implementação padrão seqüencial. Aqui o tempo deve // ser próximo do original, pois tudo acontece ainda de // forma seqüencialpublic class FinalizacaoSequencial implements FinalizadorPedidoEmLote { public Status processar(Collection< FinalizacaoPedido > finalizacoes){ for(FinalizacaoPedido f: finalizacoes){ f.execute(); } // retornar o Status de acordo com o resultado... }}

public class FinalizacaoPedido implements Callable<StatusPedido>{ private final Nota nota; public FinalizacaoPedido(Nota n){ this.nota = n; } public Status StatusPedido call() throws Exception { // … ponto onde ocorre demora, mas onde sera // executado item a item … }}

Repare que o código de exemplo praticamente triplicou de tamanho mesmo com a implementação onde ocorre a demora sendo omitida. Mas, de fato, o código que irá demandar manutenção de negó-cio será isolado na maior parte do tempo dentro da classe FinalizacaoPedido. Então, apesar do aumento, a manutenção poderá ser simplificada. Analisando o que foi feito, temos que o processamento de cada item é isolado dentro de uma classe FinalizacaoPedi-do, e sua execução é postergada para o final do méto-do, onde temos outra classe controlando a execução desta coleção de finalizações de pedido. O que foi

/ 24

feito ainda é uma implementação padrão sequencial. Não foi introduzido nenhum conceito de programa-ção paralela. A interface FinalizadorPedidoEmLote será implementada pelo novo código para proces-samento em paralelo. É interessante escrever testes unitários para esta interface. Os testes devem funcio-nar inicialmente com a implementação sequencial, e posteriormente devem funcionar na implementação nova em paralelo. Coloque em seus testes uma me-dição do tempo gasto na execução do FinalizadorPe-didoEmLote, pois assim dados preciosos comparando as execuções em paralelo e sequencial irão mostrar se de fato houve vantagem no paralelismo. Repare que o trabalho maior a ser feito foi preparar o códi-go para paralelismo. Até então não houve codificação com nenhuma classe da API de concorrência do Java, exceto pelo uso da interface Callable. Tenha em men-te os preceitos de programação funcional explicados anteriormente. Deixe para efetuar side-effects fora da classe de execução de baixa granularidade (fora da classe FinalizacaoPedido neste exemplo), pois assim o paralelismo será máximo. Caso exista a necessidade de consultas ou alteração do banco de dados dentro desta classe, não havendo outra alternativa, verifique através de testes se existe vantagem real no paralelis-mo. Reforçando o que já foi dito, normalmente batch insert/updates do JDBC ou otimizações na parame-trização dos objetos ResultSet possuem bons resulta-dos. Se existir dúvida, teste ambos os casos e meça o tempo de execução de ambas as possibilidades. Acre-dite nos resultados medidos e não em suposições.

Implementação em paraleloApós feito o trabalho de isolar a execução no item

anterior, paralelizar a execução se torna algo trivial. Neste exemplo iremos usar a API de execução do Java 5 através da utilização da classe ExecutorService.

Listagem 5. Primeiro exemplo de implementação de código em paralelo.

public class FinalizadorEmParalelo implements FinalizadorPedidoEmLote{ public Status processar(Collection< FinalizacaoPedido > finalizacoes){ // ThreadPool com 4 threads em paralelo ExecutorService e = Executors. newFixedThreadPool(4); // Aqui acontece a execução em paralelo Collection<Future<StatusPedido>> c = e.invokeAll(finalizacoes); // desligue o ThreadPool para evitar desperdício de // recursos e.shutdown(); // retornar o Status de acordo com o resultado... }}

O código em paralelo não ficou complexo, graças à refatoração feita anteriormente. Alguns pontos inte-ressantes devem ser destacados, como, por exemplo, o fato da regra de negócio ter ficado isolada na clas-se de baixa granularidade FinalizacaoPedido faz com que seja simples trocar entre uma implementação e outra. A classe Future também possui um comporta-mento interessante. Ao invocar o método get(), caso este item não tenha sido executado ainda, o método get() ficará bloqueado, aguardando o retorno. Então é possível iterar o resultado de todas as chamadas efe-tuadas da mesma forma que seria feito em um loop no resultado sequencial. O interessante aqui é que o código que está sendo usado para controlar o para-lelismo é todo da API de concorrência do Java. Não existe nenhum lock explícito, chamada de métodos como sleep, notify ou yield, ou outra manipulação de

Figura 10. Primeira abordagem de paralelismo, produzindo os itens para posterior processamento em paralelo.

... ......

Trabalhar CoM o rEsUlTadoExECUTar iTENs EM ParalElo UsaNdo ThrEadPool (ExECUTEr)

ProdUzir iTENs Para PROCESSAMENTO EM PARALELO

resultado

25 \

thread explícita. Isso introduz simplicidade no códi-go, deixa a manutenção mais simples e torna o códi-go legível. Mas para fazer código simples é necessário entender o que acontece. Por isso foi dada a explica-ção anterior sobre dead locks, side effects e afins.

Foi feito o exemplo com finalização de pedidos para que fosse possível ver uma aplicação próxima do mundo real. Mas é importante destacar que deve existir um ponto para processamento em baixa gra-nularidade, e um ponto para controlar a execução do processamento como um todo. Este segundo, pode ter uma implementação inicial sequencial, e uma implementação posterior paralela, conforme foi fei-to neste exemplo. O ponto de baixa granularidade (a implementação da classe Callable) poderá ter um retorno que será utilizado pela execução do proces-samento como um todo.

Diminuindo o consumo de memóriavale lembrar que no exemplo de refatoração

mostrado anteriormente, é provável que se esteja trabalhando com um número elevado de itens. De-pendendo da quantidade de itens a serem agrupados antes de dividir o processamento, é bem possível que ocorram problemas devido ao uso excessivo de me-mória, como, por exemplo, um OutOfMemoryError. Outra questão é que, esperar o agrupamento termi-nar implica em mais um gargalo. Caso a produção destes itens envolva um tempo considerável ou en-volva um gasto de memória considerável, vale a pena avaliar a possibilidade de produzir e consumir estes itens ao mesmo tempo, utilizando o pattern Produ-cer/Consumer. Neste padrão existe uma thread pro-duzindo itens para que outra thread consuma-os. É interessante destacar que o consumo de memória é reduzido pelo fato de não precisar produzir todos os itens (às vezes falamos de milhões de objetos) para iniciar o processamento. Desenhe o código para que seja possível utilizar um threadpool com itens produ-tores e outro thread pool para efetuar o consumo des-tes itens. Utilize uma coleção da API de concorrência do Java para armazenar os itens que estão sendo pro-duzidos e consumidos.

No exemplo anterior, cada item a ser processa-do foi implementado como uma instância de Calla-ble, o que permite que este item seja chamado por um thread pool Executor da API de concorrência do Java. Esta é uma possibilidade. A outra possibilidade é que a classe executada não represente o item a ser processado, mas simplesmente o produza ou o con-suma. O segundo exemplo será implementado des-ta forma. vale lembrar que, ao trabalhar com thread pool dos dois lados (produtor e consumidor), caso a produção de itens esteja mais rápida que o consumo ou vice-versa, é possível aumentar o número de thre-ads no thread pool mais lento. É importante testar

este comportamento em condições próximas de onde a aplicação será instalada em produção, para se ob-ter dados mais reais. É importante destacar que, no caso de haver paralelismo no produtor, deve-se tomar o cuidado de não produzir itens duplicados. Em caso de leitura no banco de dados, por exemplo, uma das formas é definir um intervalo de dados por produtor. Como sempre, a melhor abordagem é começar de for-ma simples e sem otimizações prematuras. Se parale-lizar o produtor estiver complicado demais, pode-se iniciar com apenas um produtor e depois, se houver necessidade, efetuar a refatoração para o paralelismo do produtor.

Listagem 6. Exemplo de produtor-consumidor.

public class Produtor implements Callable<Void>{ public Produtor(BloquingQueue<Item> itens){ this.items = itens; } private final BloquingQueue<Item> itens; public Void call() throws Exception{ while(/* existem mais itens para produzir */){ Item item = // efetue a produção dos itens itens.offer(item); } }}

public class Consumidor implements Callable<Void>{ public Consumidor(BloquingQueue<Item> itens){ this.items = itens; } private final BloquingQueue<Item> itens; public Void call() throws Exception{ while(/* existem mais itens para consumir */){ //Recuperar e retirar da coleção itens o proximo //item abaixo. Item item = itens.pool(10,TimeUnit.SECONDS); consumir(item) } }}

Aqui temos as classes produtoras e consumidoras fei-tas. Existem algumas decisões a serem tomadas aqui. Para o argumento do while do consumidor é possível validar a existência de itens novos a serem processa-

/ 26

dos ou simplesmente efetuar um loop infinito, para que em momento posterior seja feita a verificação de novos itens. Outras formas de coordenar a execução dos produtores e consumidores podem ser usadas, pois no caso de não haverem mais itens sendo pro-duzidos, os consumidores devem parar a execução. Verifique a documentação das coleções concorrentes, dentre elas a BloquingQueue, para então conhecer e efetuar a escolha da coleção mais coerente de acor-do com seu cenário. veja também a documentação da classe CountDownLatch, que também pode ser utili-zada para coordenar execução em alguns cenários.

Listagem 7. Código de controle do thread pool para o produtor. O mesmo código para o consumidor é semelhante.

BloquingQueue<Item> itens = //... // paralelismo escolhido para produtores (numero de // threads em paralelo)int paralelismoProdutores = //... ExecutorService poolProdutores = Executors.newFixedsizeThreadPool(paralelismoProdutores);Collection<Produtor> produtores = //...for(int i=0;i<paralelismoProdutores;i++){ produtores.add(new Produtor(itens));}poolProdutores.invokeAll(produtores);poolProdutores.shutdown();

No código anterior foi criado um thread pool e fo-ram adicionados o mesmo número de Callables para execução. A coleção itens será compartilhada entre todas as threads. Assim é possível manter todas as threads criadas ocupadas. Para a instância da variá-vel itens, certifique-se de usar uma coleção da API de concorrência neste ponto (package java.util.concur-rent) ou uma classe thread safe (feita para não haver dead locks ou inconsistência durante a execução em paralelo). A preparação da execução dos consumido-res é semelhante ao que foi feito para os produtores e será omitida. Mas um ponto de atenção nos consu-midores é quando parar a execução. Uma das formas é efetuar a chamada do método que retira itens da coleção com um timeout. Por exemplo, utilizando o método BlockingQueue#pool(long, TimeUnit) para recuperar um item no consumidor, é possível rece-ber o valor null quando não houverem mais itens disponíveis dentro de um tempo limite especificado. Passado o tempo de tolerância para produção de um novo item, pode-se parar o processamento no con-sumidor (colocando-se um break em um loop infi-nito, por exemplo). Outra forma é compartilhar um AtomicBoolean entre os produtores e consumidores. Mais uma vez, os testes são necessários para evitar uma execução infinita. Não se esqueça neste ponto de utilizar um profiller para se certificar que ambos os thread pools estão sendo executados de forma sa-tisfatória.

Figura 11. Segunda abordagem para processamento em paralelo, onde a produção e o consumo de itens ocorre ao mesmo tempo em diferentes threads ou thread pools.

ao MEsMo TEMPo UMa oU Mais THREADS CONSOMEM OS ITENS

ProdUzidos

x

COLEçãO DE ITENS COMPARTILHADA

UMa oU Mais ThrEads ProdUzEM ITENS A SEREM PROCESSADOS

27 \

> http://en.wikipedia.org/wiki/Thread_(computing)

> http://en.wikipedia.org/wiki/Dining_philosophers_

problem

> http://en.wikipedia.org/wiki/Sleeping_barber_problem

> http://stackoverflow.com/questions/1050222/

concurrency-vs-parallelism-what-is-the-difference

> http://visualvm.java.net/

> http://en.wikipedia.org/wiki/Side_effect_(computer_

science)

> http://en.wikipedia.org/wiki/Functional_programming

> http://en.wikipedia.org/wiki/Deadlock

> http://www.amazon.com/Effective-Java-Edition-Joshua-

Bloch/dp/0321356683

> http://www.amazon.com/Java-Concurrency-Practice-

Brian-Goetz/dp/0321349601

> http://en.wikipedia.org/wiki/Uncertainty_principle

> http://www.yourkit.com/

> http://www.ej-technologies.com/

> http://docs.oracle.com/javase/6/docs/technotes/guides/

visualvm/index.html

> http://java.sun.com/developer/Books/JDBCTutorial/

> http://docs.oracle.com/javase/1.4.2/docs/guide/jdbc/

getstart/resultset.html

> http://en.wikipedia.org/wiki/Producer-consumer_problem

> http://www.scala-lang.org/

> http://clojure.org/

> http://www.erlang.org/

> http://en.wikipedia.org/wiki/Actor_model

/referências

Alguns cuidados que devem ser tomados

Durante testes com paralelismo, ao obter re-sultados extremamente satisfatórios, é possível que ocorra uma ansiedade por dar notícias de melhoria ou mesmo por colocar código novo em produção. Certifi-que-se de testar em cenário realista, o mais próximo possível do cenário em produção. Durante qualquer refatoração de código é possível se esquecer de algu-ma etapa importante, e com isso ver um cenário mais otimista do que o cenário real. Geralmente notícias de melhoria de performance espalham-se sozinhas. Apenas espalhe notícias de melhoria após validar em produção, ou tenha muitas evidências de testes.

Certifique-se também de que voltar o código se-quencial seja possível e simples. Voltar código ante-rior deve ser simples em qualquer cenário de reinsta-lação, mas no caso da troca de código sequencial por paralelo, isto se torna absurdamente importante. O código feito neste momento foi feito com os cuidados necessários para se evitar side effects indesejados, dead locks e inconsistências. Mas nada garante que componentes alheios estejam preparados para exe-cução em paralelo. O autor deste artigo já se deparou algumas vezes com este cenário. A volta do código sequencial não foi necessária, mas tratamento de erros adicional durante a interação com componen-tes alheios teve que ser feito. Lembrando ainda dos heisenbugs: alguns erros de processamento paralelo somente poderão acontecer em produção. O desen-volvedor tem que estar preparado.

ConclusõesParalelismo é uma ótima forma de atingir me-

lhoria de performance em casos de processamento de grande volume de dados. Por outro lado, é sim-ples desenvolver código paralelo sem desempenho satisfatório, ou pior ainda, código paralelo que gere resultados inconsistentes. A plataforma Java possui muitas boas abstrações e ferramentas para efetuar desenvolvimento de código paralelo simples e de fá-cil manutenção, sem, para isso, deixar de lado o de-sempenho. Sejam as coleções da API de concorrência, o ExecutorService ou outras abstrações, é possível obter paralelismo satisfatório utilizando em conjun-to conceitos de programação funcional, como, por exemplo, funções puras durante o paralelismo para evitar locking.

Utilizar orientação a objetos de forma que seja possível trocar o código sequencial por paralelo alia-do a outras técnicas como TDD ou mesmo o uso de profillers ajuda a mensurar se houve melhorias de performance com o código novo. Assim, a troca de código pode ser feita sem maiores preocupações por parte do desenvolvedor. Caso o leitor tenha gostado da abordagem funcional, vale a pena se aprofundar

no assunto, seja estudando linguagens que utilizem este paradigma, como, por exemplo, Erlang, ou den-tro da JvM, Clojure ou Scala. O modelo de concorrên-cia utilizando atores é particularmente interessante.

Resolver problemas envolvendo perfomance, pa-ralelismo ou grande quantidade de dados pode ser algo que tire o sono do desenvolvedor. Mas ao seguir alguns preceitos a resolução deste tipo de problema torna-se divertida e recompensadora para o desen-volvedor.