Em 1975, Frederick P. Brooks escreveu o revolucionário The Mythical Man-Month. Quarenta anos depois, esta série de artigos pretende revisar as afirmações e os argumentos do autor. Quanto o ofício da Engenharia de Software evoluiu desde então?
Capítulo 9: Ten Pounds in a Five-Pound Sack
Neste capítulo Brooks discorre sobre como lidar com as restrições de consumo de recursos, envolvendo memória, armazenamento e desempenho, durante o desenvolvimento de um software.
Recheei o artigo de exemplos modernos e acho que você vai se surpreender como pouca coisa mudou nos últimos 40 anos!
Além do tempo de execução, o espaço usado da memória por um programa é um dos principais custos. Isto é especialmente verdadeiro para sistemas operacionais, onde muita desta memória é residente o tempo todo.
Numa primeira leitura deste capítulo, logo vem à tona a tendência de pensar que custo de hardware se trata de um problema superado. Frequentemente ouvimos que a quantidade utilizada de memória ou disco já não é um problema, afinal hardware hoje em dia é barato. Não basta comprar mais um pente de memória, mais um HD, mais um processador?
Na época em que Brooks escreveu tais linhas, um sistema operacional ocupando 400KB de memória era algo preocupante para alguns, num hardware com total de 2MB de memória.
Sim, é verdade que hardware se tornou mais barato, mas ele ainda é um forte fator restritivo para a escalabilidade de sistemas. Irei discorrer mais sobre isso adiante.
Dinheiro gasto em melhor utilização de memória pode levar a um melhor valor funcional… O tamanho do programa não é o problema, mas o espaço que ele ocupa desnecessariamente.
Programas pequenos que você executa no seu computador geralmente não são um problema no que se refere ao consumo de recursos. Eles podem consumir 2GB de RAM, mas como você não tem necessidade de executar outros programas ininterruptamente, está tudo bem.
Por outro lado, é fácil esquecer que muitos sistemas que usamos no dia-a-dia executam 24/7 com uma grande e variada carga de usuários.
Embora a computação em nuvem tenha facilitado bastante que os fornecedores de tais serviços na nuvem escalem o hardware verticalmente, replicando servidores automaticamente quando necessário, ainda muitas empresas percebem o quão verdadeira é a afirmação do autor.
Particularmente, onde trabalho, na Atlassian, há projetos que envolvem otimizações na aplicação que podem economizar milhões e milhões de dólares. Se você tem um milhão de usuários, cada kilobyte despendido com um usuário equivale a um gigabyte no total, sem contar backups e outras consequências desse gasto, tais como transmissão de rede e tempo de processamento.
Um exemplo prático: muitas startups, no começo, prezam por entregar funcionalidades rapidamente e assim começam a usar muitas máquinas virtuais e outros serviços de dados e armazenamento na nuvem. Na medida que a empresa cresce, eles começam a perceber que mais e mais dinheiro é gasto com tais serviços. Então, a fim de economizar o dinheiro que poderia estar sendo gasto em novas funcionalidades, criam-se projetos para otimizar o sistema ou centralizar melhor as informações de forma a evitar redundâncias e muitas vezes consegue-se reduzir o consumo a apenas uma fração do que antes era usado.
Isso é particularmente verdadeiro para aplicações que antes eram distribuídas para instalação na intranet, sendo single-tenant (um cliente por instalação), e agora são migradas para a nuvem, onde precisam ser multi-tenant (uma instalação atende múltiplos clientes). Aplicações multi-tenant evitam que seja necessário alocar recursos específicos para cada cliente ou usuário, afinal nem todo mundo usa o sistema ao mesmo tempo, diminuindo assim grandemente o investimento o uso total de recursos, principalmente quando o acesso do sistema é feito de diferentes fuso horários.
Mesmo em computadores pessoais, usuários frequentemente reclamam que o sistema operacional usa grande parte de memória. Um PC com 4 ou 8GB de RAM, na verdade, terá 60% ou 70% desse espaço realmente livre. Isso faz com que muita gente troque de sistema ou deixe de atualizar para a nova versão que, quase sempre, é mais pesada, não é? Não fosse verdade a Microsoft não prometeria usar menos memória em novas versões do Windows. A mesma analogia pode ser usada para sistemas como Android ou iOS.
Portanto, como afirmou o autor, restrições de uso de memória e investimentos nessas áreas são importantes, particularmente para sistemas operacionais e, acrescentaria eu, para sistemas web de grande escala.
O construtor do software precisa definir objetivos, controlar o tamanho, e pensar em técnicas de redução de tamanho, assim como o construtor do hardware o faz.
Uma grande diferença que percebo, de hoje em relação à época em que de Brooks escreveu o livro, é a escala dos projetos de software com a qual lidamos. Naquela época, o tamanho dos sistemas de software eram muito, mas muito menores.
Pense nisso: um sistema operacional com 400KB é muito mais inteligível para nossa fraca capacidade mental, o que permitiria tratar todos os aspectos do sistema de forma muito mais analítica.
Programadores mais antigos frequentemente relatam suas experiências, vividas há algumas décadas atrás, onde, usando linguagens de baixo nível, eles faziam questão de economizar cada byte possível.
O custo de hardware era maior que o custo de desenvolvimento. Hoje isto se inverteu, mas ainda existe uma relação.
Muitos sistemas hoje, não somente os operacionais, são tão grandes e complexos, geralmente reutilizando outros programas grandes e complexos, que temos de lidar com problemas de consumo de espaço e desempenho de forma mais orgânica.
Hoje não há maneira razoável de analisar cada aspecto de um sistema complexo, então recorremos a técnicas estatísticas, no melhor caso, ou até intuitivas e pragmáticas, atacando apenas os problemas mais evidentes e significativos.
É por isso que programadores mais velhos falam com um tom saudosista dos velhos tempos, quando, embora mais limitados, eles conseguiam ter um controle completo sobre o que estava acontecendo em um programa. Hoje em dia… bem… que Deus nos acuda! 😀
O limite de tamanho deve estar relacionado com a função atribuída; deve-se definir exatamente o que um módulo precisa fazer e especificar quão grande ele deve ser.
Provavelmente definir precisamente o tamanho de cada módulo não é algo prático. Entretanto, posso dizer, por minha experiência, que num sistema grande é prática comum e importante, ainda hoje, definir limites de desempenho e consumo de recursos para os diferentes módulos e de acordo com as prioridade do sistema.
Por exemplo, é aceitável que uma página de um sistema web demore alguns milissegundos a mais para carregar quando uma equipe adiciona uma funcionalidade importante para os usuários e que vai trazer crescimento para a empresa. Em contrapartida, uma melhoria muito pequena que deixe o sistema mais lento pode ser ignorada. Ainda, pode-se reverter qualquer modificação que acidentalmente deixe o sistema mais lento, mesmo que por alguns milissegundos.
Talvez você se pergunte porque alguns sistemas grandes não adicionam mais funcionalidades com certa frequência. Grandes aplicações na nuvem, como Gmail ou JIRA, na verdade, podem remover funcionalidades pouco usadas se isto piora outros aspectos do sistema, desde a velocidade de desenvolvimento, a performance ou até mesmo se fazem o sistema mais difícil de usar se “poluem” a interface. É por isso que você vê grandes empresas como Google e Facebook lançando novas funcionalidades apenas para uma pequenas amostras de usuários, como experimentos, e os demais somente recebem tais atualizações algum tempo depois, se tudo correr bem.
Durante toda a implementação, os arquitetos de sistema precisam manter uma vigilância constante sobre a integridade do sistema.
Sistemas web na nuvem exemplificam isso bem. Cada kilobyte adicionado à página, como scripts, imagens ou estilos, tem o potencial de fazer a experiência do usuário pior, porque fazem a página carregar mais devagar. Quaisquer alterações que façam acesso ao banco antes do HTML ser enviado ao usuário, também vão atrasar o recebimento do conteúdo.
Por esta e outras razões, a equipe de desempenho do JIRA, da qual faço parte, monitora constantemente a quantidade de dados de uma página, os acessos ao banco, além de várias outras métricas, de forma a lutar contra a tendência natural de degradação do desempenho dos sistemas de software.
Promover uma atitude voltada para o cliente e para o sistema como um todo pode ser a função mais importante do gerente de desenvolvimento.
Algo importante citado pelo autor no livro, é que as decisões sobre restrições de um software são compartilhadas entre gerência e pessoal técnico. Nenhum gerente pode definir arbitrariamente como um sistema deve funcionar, mas nenhuma equipe de desenvolvimento deve ser deixada “solta” para fazer o que bem quiser. 🙂
Os gerentes devem trabalhar em conjunto com os desenvolvedores. O primeiro lembra os demais dos custos, os faz cientes das prioridades dos clientes, ajuda a tomar decisões de acordo com o custo-benefício e assim por diante. De acordo com estas restrições, o pessoal técnico toma as decisões apropriadas sobre o que deve ou não ser implementado e como.
Para tomar boas decisões sobre o custo-benefício de consumo de espaço e desempenho, uma equipe precisa ser bem treinada em técnicas de programação peculiares da linguagem ou ambiente.
Infelizmente, pelo que vejo, poucas empresas têm pessoas altamente capacitadas em sua área para lidar com particularidades da linguagem, framework ou plataforma.
Quando se fala em desempenho, é importante ser mais do que um programador pragmático, que consegue resolver as coisas do algum jeito qualquer. É preciso ter experiência e conhecimento das várias opções para se resolver um problema, de modo a definir boas métricas, coletar dados corretamente, analisar apropriadamente e decidir sabiamente qual é a melhor solução para cada diferente situação que surgir.
Um exemplo bem simples. Imagine que você trabalha em um sistema web onde cada página demora 5 segundos para ser totalmente carregada. Medindo esse tempo, você nota que 3 segundos são gastos pelo servidor para gerar e enviar o HTML e nos 2 segundos restantes o browser está baixando os scripts e estilos usados para iniciar certos componentes da página. O que você faria primeiro?
As possibilidades são muitas, mas várias levam a armadilhas. Você poderia pensar em otimizar o servidor, por exemplo, afinal é a parte mais lenta, não é mesmo? Então, melhorando algumas consultas SQL, você diminui o tempo do servidor para 2 segundos, mas o tempo total da requisição não muda. Aí você descobre que não mudou porque a consulta no banco era realizada na renderização do corpo da página e, embora a página termine de renderizar mais cedo, os scripts que aparecem no cabeçalho da página demoram tanto para serem baixados e executados que o tempo total continua o mesmo. Lembre-se de que scripts bloqueiam a renderização da página. A pegadinha é que o tempo de download e execução dos scripts era maior que dois segundos, ocorrendo paralelamente ao tempo de renderização do corpo da página. Neste caso, diminuir o tamanho ou a quantidade de scripts ou estilos, renderizar parte da página de forma assíncrona ou usar um CDN são opções mais adequadas.
Em resumo: você gastou seu tempo para fazer o sistema melhor, mas do ponto de vista do usuário, você não teve absolutamente nenhum impacto. Não temos tempo infinito, portanto priorizar corretamente as melhorias que efetivamente tem impacto no mundo real é uma necessidade absoluta. Isso pode fazer a diferença entre atingir os objetivos do projeto ou não, em atrair mais clientes ou não, ganhar dinheiro ou não.
Nota: o exemplo acima pode não ser tão fácil de entender se você não entende como funciona o carregamento de uma página, mas espero que esteja simples o suficiente. Deixe um comentário se quiser mais explicações. 😉
Bibliotecas de um programa devem ter duas versões de cada componente, uma rápida e uma pequena. [Isso parece obsoleto hoje]
A afirmação acima, embora reconhecidamente obsoleta pelo autor, ainda é muito interessante. O contraste entre tamanho e desempenho é um tema recorrente no desenvolvimento de software.
Quando o custo de hardware era muito maior que o custo de desenvolvimento, valia mais a pena escrever versões diferentes de bibliotecas para serem usadas de acordo com os requisitos de hardware.
Já vi isso em alguns sistemas operacionais. Não lembro bem os detalhes, mas versões antigas do Linux e do Windows eram capazes de carregar diferentes versões de alguns módulos com diferentes capacidades dependendo dos limites do computador utilizado.
Entretanto, isso mudou um pouco hoje com o custo do hardware diminuído. Hoje, não se escrevem bibliotecas diferentes, mas ainda é comum permitir configurações diferenciadas para cada ambiente.
Um exemplo simples é a JVM, a Máquina Virtual Java, que possui diversos parâmetros de otimização de acordo com as necessidades do sistema e restrições do ambiente. Existem garbage collectors que funcionam melhor com vários processadores, mas consomem mais memória, outros que consomem menos e são mais rápidos, mas geram mais paradas na execução. Você também pode configurar o JIT (just-in-time compiler) para gerar código de máquina mais otimizado para ocupar menos memória ou mais otimizado para gerar código mais eficiente. Também é possível alocar mais ou menos memória, e há diferente tipos de memória, o que pode ter diferentes consequências dependendo de como sistema usa a memória. Nem sempre mais memória significa mais velocidade!
Outro exemplo interessante são sistemas operacionais que fazem uso de memória de acordo com o quanto ela está sendo usada. Se há muita memória disponível, o SO faz mais uso dela preeptivamente, fazendo cache de recursos não muito utilizados. Se há menos, ele libera a memória para os programas executando, ao custo de deixar certas funções mais lentas quando utilizadas, afinal ele precisa carregar os módulos utilizados novamente. É por isso que ter mais memória RAM faz o uso geral mais rápido, assim como memória SSD ajuda em vários casos. Eu lembro de ter pouca memória em PCs antigos e ver o sistema lento após fechar um programa mais pesado, afinal era como se o sistema estivesse sendo carregado inteiro novamente do disco.
Sistemas modernos como o Android também fazem uso de um gerenciamento de memória bem avançado. Não há tempo de entrar em detalhes neste artigo, mas digamos que tudo o que foi falado aqui sobre uso de memória (em muito mais detalhes) foi projetado para funcionar de forma automática. O consumo de recursos, uso de bateria, desempenho e experiência do usuário, todos são colocados numa balança e o sistema decide o que fica e o que não fica em memória, em outras palavras, o que pode consumir recursos num momento e o que deve salvar seu estado e permanecer em segundo plano.
Portanto, embora a afirmação inicial do autor esteja realmente obsoleta, o princípio por detrás é importante e largamente utilizado nos sistemas mais importantes da atualidade.
Programas rápidos, pequenos e com consumo baixo são quase sempre resultado de inovação tecnológica ao invés de alguma “esperteza” técnica isolada.
Trocando em miúdos: micro-otimizações não funcionam na prática, mas mudar a tecnologia ou a abordagem em mais alto nível pode ter um resultado significativo.
Exceto para sistemas em tempo real ou pequenas otimizações isoladas, em geral os problemas de desempenho precisam de uma quebra de paradigma para dar realmente certo.
Isso é ainda mais verdade hoje. Experimente trocar todas as variáveis double
para int
ou byte
no seu sistema. Em 99,9% dos casos não vai haver nenhum ganho visível de desempenho, exceto talvez se o sistema seja baseada e computação numérica com volume bastante razoável.
Por outro lado, ganhos reais de desempenho são atingidos quando mudamos de paradigma, por exemplo, de síncrono para assíncrono, de SQL para NoSQL, de eager para lazy e assim por diante, de monolítico para distribuído, etc.
Contudo, este é mais um princípio do que uma regra. Minha dica é: use novas tecnologias com moderação. 😛
Frequentemente a inovação será um novo algoritmo.
Isto ainda é verdade para certas áreas específicas. Em programação paralela e concorrente, por exemplo, o uso da técnica correta para comunicação entre threads e processos de modo a criar a menor contenção possível e uma agregação dos resultados eficiente faz toda a diferença.
Se você está começando em Java, talvez já tenha entrado em contato com a palavra-chave synchronized
. Pois bem, se você continuar estudando, logo vai aprender que esta não é a melhor solução para trabalhar com várias threads na maioria dos casos.
É, eu sei que é duro. Você acaba de conhecer algo que muda a sua vida e logo vem um estraga-prazeres lhe dizer que aquilo é ruim. Neste caso, o carrasco sou eu. 😛
Basicamente, forçar acesso exclusivo com bloqueio em cada lugar onde threads podem interagir é uma solução um tanto ingênua e pouco eficiente. O ideal é diminuir a necessidade de sincronização, ou seja, evitá-la sempre que possível. Dependendo do caso, pode-se usar uma técnica que não exige sincronização, tal como o compare-and-swap, técnica implementada no Java por classes como AtomicInteger
e Atomicreference
. Ou ainda usar locks que diferenciam leitura e alteração, como o ReadWriteLock
.
Por outro lado, de forma geral, a afirmação do autor já não soa tão forte hoje, embora continue sendo verdadeira. Já temos disponíveis os algoritmos necessários para resolver quase todos os problemas do dia-a-dia. A dificuldade na maioria dos casos é que não sabemos aplicá-los corretamente. 🙂
Mais frequentemente, a inovação vem manipulando a representação dos dados. Representação é a essência da programação.
Isto ainda é verdade para muitos casos. Vejamos dois…
Primeiro, analisar dados sobre o uso de um sistema pode abrir novos caminhos para otimização. Em meu trabalho com desempenho na Atlassian, frequentemente vejo hipóteses sobre como melhorar o desempenho que se provam incorretas. A melhor forma de atacar o problema é olhando para os dados que temos e tomando decisões baseadas neles e não (somente) na intuição.
Segundo, grandes otimizações podem ser atingidas com mudanças nas estruturas de dados. A simples criação de alguns índices em tabelas pode trazer ganhos absurdos de desempenho, assim como algum nível de denormalização. Entretanto, mudanças radicais, como a restruturação de um banco de dados convencional para um distribuído, por exemplo aplicando sharding, pode levar a níveis completamente diferente de otimização.
Numa aplicação web comum, há basicamente 3 níveis para se trabalhar com os dados: banco de dados, aplicação, browser. A grande maioria dos sistemas é extremamente ineficiente no que se refere a manipular e mover os dados entre tais níveis. Na verdade, o melhor equilíbrio varia ao longo de tempo dependendo das necessidades do sistema.
Para ilustrar o que estou dizendo, posso dizer que é prática comum, no início do desenvolvimento de um sistema, listar toda uma tabela do banco e exibir na tela para usuário. Entretanto, isso não se sustenta. Logo surge a necessidade de paginar a consulta, isto é, limitar a quantidade de dados que trafegam do banco até a página web.
Indo além, num sistema pequeno talvez não seja problema recarregar a página cada vez que o usuário avança para a segunda página com os resultados de sua pesquisa. Entretanto, num sistema um pouco mais complexo, um carregamento de página completo pode levar vários segundos e consumir bastante recursos do servidor. Portanto, carregar os resultados da busca usando AJAX pode ser vários ordens de magnitude mais eficiente, principalmente se mudarmos a forma de representar os dados de HTML para JSON, removendo assim a necessidade de trafegar todo um documento HTML com seus recursos externos para transmitir apenas um pequeno objeto JSON.
Considerações
A princípio, poderíamos passar “batidos” por um capítulo que traz números e informações de uma realidade totalmente diferente da nossa, isto é, de quarenta anos atrás. Ainda assim pudemos extrair diversas lições e refletir sobre como desenvolvedores software hoje.
No fim das contas, este capítulo foi bem mais denso do que eu esperava, com um grande número de princípios que podemos aplicar hoje e vários exemplos práticos.
Espero que tenha aproveitado tanto quanto eu! Afinal, como desenvolvedores, diariamente nós temos que fazer caber "10 kilos num saco de 5 kilos", como diz o título do capítulo. 😀