O desenvolvimento de uma aplicação com ótima performance só é possível mediante considerações desde sua arquitetura. Otimizações pontuais de código, mesmo em baixo nível, geralmente não são suficientes.
É muito difícil desenvolver uma aplicação com performance diferenciada se essa não for uma preocupação desde o início.
Uma excelente palestra sobre o tema
Oren Eine (mais conhecido como Ayende) é um dos desenvolvedores mais geniais com quem já tive (e tenho) a oportunidade de trabalhar. Ele é um dos principais responsáveis por soluções como NHibernate, NHibernate Profiler, Entity Framework Profiler e, claro, RavenDB.
Recentemente, ele apresentou uma palestra onde falou sobre os principais desafios que enfrentamos para a construção do RavenDB 4 – que é 10x mais rápido que a versão 3.5.
Os desafios que enfrentamos no desenvolvimento do RavenDB 4, ao meu ver, estão presentes (em diferentes níveis) em qualquer aplicação.
Se você está com problemas relacionados a performance pode ter vantagens se considerar cuidadosamente como irá tratar:
- alocações de memória,
- parsing,
- I/O em disco,
- Rede.
A arquitetura de sua aplicação (componentes, suas responsabilidades e como se relacionam) precisa ser pensada para abordar cada um desses desafios de forma eficiente.
Para ter êxito, você precisa entender como o sistema (operacional, rede, I/O) funciona. Entender suas regras, e evitar abordagens conflituosas. (Oren Eini)
1. Alocações de Memória
Sempre que sou convidado para verificar problemas de performance em uma aplicação .NET, invariavelmente, encontro uma aplicação que está sofrendo por execuções frequentes do GC. Quase sempre, encontro uma “explosão de strings”, boxing e unboxing, alocações de objetos de forma exagerada e caches ingênuos.
O Garbage Collector é fantástico. Ele permite que nossas aplicações tenham ótima performance (muitas vezes, superiores a aplicações não gerenciadas), […], mas isso só será possível se nosso código se comportar de acordo com suas regras. Do contrário, “GC will kill you” (Oren Eini)
É importante entender como o Garbage Collector funciona. É importante conhecer suas principais heurísticas e pensar alocação de acordo com o especificado.
De forma breve, são boas abordagens:
- evitar alocações (quanto menos melhor)
- alocar objetos que serão úteis durante muito tempo (reaproveitamento)
- alocar (não muitos) objetos que podem ser desalocados rapidamente
O que é problema? Alocar objetos que não tem vida muito longa mas que irão “sobrebriver”, provavelmente, a uma próxima coleta do GC.
No RavenDB utilizamos intensamente o conceito de Contextos de Operação – componentes que gerenciam memória e outros recursos computacionais para suportar determinadas operações. Esses contextos são mantidos em poolings e reaproveitados.
Sempre que uma determinada operação vai iniciar:
- Uma instância de contexto de operação é requisitado ao pooling;
- Um contexto de operação é fornecido, geralmente com memória outros recursos operacionais previamente alocados;
- Operação é executada utilizando os recursos do contexto;
- O contexto é devolvido ao pooling onde poderá ser reutilizado para outra operação.
No RavenDB optamos por ter objetos de vida longa, reutilizados frequentemente (esta também é a abordagem seguida no projeto Roslyn).
Outra decisão arquitetural importante, no RavenDB, é a utilização de blocos nativos de memória (no lugar da memória gerenciada) para operações críticas. Trata-se de uma solução extrema. Entretanto, é uma opção que não pode ser negligenciada caso você tenha algum componente que faça alocações e liberações de forma intensa.
2. Parsing
Outra falha muito comum em aplicações com problemas de performance é a adoção de abordagens pouco cuidadosas de parsing. Seja para processar arquivos texto, planilhas, ou dados em JSON ou XML.
Se sua aplicação precisa fazer muito parsing, então é bom verificar os impactos deste processamento.
No RavenDB, Json.NET acabou não sendo adequado para nossas necessidades. É inviável fazer o parsing de um documento JSON todas as vezes que estivermos executando processos que acessam poucas propriedades. Optamos por ter nosso próprio parser e também ter um formato próprio para manter o JSON na memória.
3. I/O em disco
Se sua aplicação faz uso intensivo de I/O, então você precisará entender como I/O é tratado (principalmente pelo sistema operacional) profundamente. Você precisa entender as premissas assumidas e desenvolver sua aplicação de acordo com essas premissas (Oren Eini).
Faça todo o possível para evitar o disco. Utilize artifícios do SO para fazer fazer a maior parte de suas operações na memória principal.
Não quero usar o disco! Disco é tão lento! (Oren Eini)
Outro problema que temos de lidar quando optamos por utilizar muito acesso ao disco é que, muito frequentemente, esse acesso acontece através da rede.
Não tente ser inteligente demais. O sistema operacional já faz muito por você, então aproveite isso. Faça com que sua aplicação se comporte exatamente da forma como o SO espera. (Oren Eini)
No RavenDB fazemos uso intensivo de Memory Mapped files.
4. Rede
Poucos desenvolvedores consideram o impacto da rede quando estão desenvolvendo suas aplicações. Geralmente, todos os componentes necessários estão em uma única máquina durante o desenvolvimento.
Se não conhece, recomendo que reveja agora as 8 falácias para computação distribuída:
- A rede é confiável
- A latência é zero
- A largura de banda é infinita
- A rede é segura
- A topologia da rede não muda
- Há um administrador
- O custo para transporte é zero
- A rede é homogênea
De forma simples, a melhor abordagem para trabalhar com a rede é evitar usar a rede! Quanto mais processamento local você puder executar, melhor.
Concluindo
A palestra do Ayende é genial! Assista! Ele realmente sabe do que está falando.
Quanto a arquitetura, as recomendações gerais são:
- Pense na interação de seus componentes de forma que a necessidade de utilizar a rede seja minimizada
- Evite operações que façam uso intensivo de I/O em disco. Se for necessário, adote componentes que se beneficiem do sistema operacional
- Seja cuidadoso com o custo do parsing em sua aplicação. Seja cuidadoso em pensar na forma como representa dados em memória e em como os persiste.
- Aloque objetos que vão permanecer na memória durante muito tempo, reutilizando recursos, ou objetos que vão ficar em memória durante muito pouco tempo. Em casos extremos, considere utilizar memória nativa.
Comentários?
Créditos para a imagem da capa toine Garnier