Systems Performance - Aprofundando em Aplicações
Um guia de como medir, analisar e otimizar aplicações de forma eficiente e sem achismos

Bem-vindos a mais um post da série sobre Systems Performance. Até agora abordei desde os conceitos e métricas, até como realizar capacity planning de forma eficaz. E seguindo a ideia, nesse post abordarei como descrever os objetivos de melhoria de performance, técnicas, desafios de diferentes linguagens de programação e muito mais, então pegue um café e aproveite a leitura. ☕
Criando contexto
Antes de iniciar qualquer tipo de análise, assim como abordei em posts anteriores, é importante criar um entendimento sobre como a aplicação se comporta em alto nível, o que ela faz, como ela opera e como é a performance atual. Para isso é importante se perguntar:
Qual é o papel dessa aplicação? É um web server, banco de dados, load balancer?
Quais tipos de requisições essa aplicação serve? É possível medir?
A aplicação roda em qual modo de CPU? User-level ou kernel-level?
Como é realizada a configuração da aplicação? Via arquivo, ENV VARs?
Como é a infraestrutura que hospeda a aplicação? Quais são seus recursos e limites?
A aplicação publica métricas e logs? Quais logs podem ser habilitados e quais métricas de performance estão disponíveis através de logs?
Houve alguma modificação recente visando melhoria de performance?
Existem bugs conhecidos em bibliotecas terceiras? Algum tipo de bug conhecido no web server ou banco de dados que possa afetar performance?
Ao responder essas questões, você terá uma ideia de onde o esforço precisa ser aplicado e o que você precisa fazer e criar um objetivo. Você pode iniciar com os tipos de operações ou requisições que a aplicação serve, se possível sempre usando métricas - tais métricas podem vir como requisito de negócio ou de um arquiteto de soluções (ou alguém que faça esse papel na sua empresa), alguns exemplos de objetivos:
Latência média de 10ms;
90% das requisições completadas com latência abaixo de 200ms;
Não ter requests acima de 2s;
Cada instância (servidor, Pod, VM) da aplicação processar 5000 requests por segundo;
Ao começar esse trabalho é comum (ou deveria ser) olhar o código da aplicação. Uma forma eficiente de realizar performance é iniciando onde a maior parte do workload é realizado. Por exemplo, se uma API está com alta latência, pode ser mais assertivo analisar os trechos de código responsáveis por acessar o banco de dados ou fazer serializações mais pesadas.
Aprofundando nas técnicas
Existem algumas técnicas para melhorar performance de aplicações, vou abordar algumas aqui começando por Caching.
Caching
Sistemas Operacionais utilizam cache para melhorar a performance de leitura em sistemas de arquivos. Nas aplicações utilizamos cache pelo mesmo motivo, ao invés de sempre executar uma operação pesada de leitura, os resultados de algumas operações podem ser armazenados em um cache para uma utilização futura. Um ótimo exemplo é cache em bancos de dados, onde o resultado das queries pode ser armazenado em um memcached ou Redis. Enquanto o cache vai melhorar o tempo de leitura, o disco será usado como um buffer para melhorar operações de escrita.
Buffering
Geralmente falamos muito mais de cache do que buffering, mas a realidade é que essa é uma das maneiras de melhorar operações de escrita, onde os dados podem ser agrupados em um buffer antes de serem enviados para o próximo passo do processamento, aumentando o tamanho das operações de I/O. Porém, existe um trade-off onde dependendo do tipo de escrita, onde isso pode aumentar a latência, já que primeira escrita precisa esperar por outras.
Escolhendo o tamanho da operação de I/O (ou I/O Size)
Antes de falar sobre isso vale diferencia tamanho de I/O (I/O Size) de Block Size.
I/O Size é o page size que a aplicação utiliza, por exemplo 8KB no Postgres, 16KB no MySQL, ou seja, é o tamanho real dos dados que estão sendo lidos e escrito em uma única operação. Já o Block Size é configurado no lado do Sistema Operacional (ou storage) e se refere ao tamanho da unidade de dados consegue endereçar, por exemplo se o Block Size do disco são 16KB, mesmo que você grave 5KB, o sistema irá utilizar 16KB no disco.
Dito isso, aumentar o tamanho das operações de I/O é uma estratégia para melhorar o throughput. Por exemplo, é muito mais eficiente transferir 128 KB de uma vez só, do que fazer 128 operações separadas de 1 KB cada. E como sempre há trade-offs. Se a aplicação não precisa de tamanhos maiores de I/O, o desempenho pode piorar (e muito). Imagine um banco de dados que faz leituras de 8 KB, se o sistema estiver lendo 128 KB por operação, 120 KB são desperdiçados a cada leitura. Isso gera uma latência desnecessária, que poderia ser reduzida com operações de I/O menores e mais alinhadas com o padrão de acesso da aplicação.
Paralelismo e Concorrência
A diferença entre paralelismo e concorrência é bem importante para entender como otimizar aplicações. Concorrência é a capacidade de lidar com múltiplas tarefas ao mesmo tempo, enquanto paralelismo implica na execução simultânea dessas tarefas.
Fibers e Threads
Threads são as unidades básicas de execução gerenciada pelo sistema operacional. Cada thread possui sua própria stack e estado, compartilhando o mesmo espaço de memória com outras threads do mesmo processo. O sistema operacional aloca tempo de CPU para cada thread e realiza o context switching (processo de armazenar o estado da thread) entre elas, o que pode ser custoso devido ao overhead de salvar e restaurar o estado.
Fibers, por outro lado, são threads leves (lightweight threads) gerenciadas pela própria aplicação e não pelo sistema operacional. A principal diferença é que o escalonamento de fibers é cooperativo, ou seja, a aplicação controla o fluxo de execução, em vez de ser interrompido (preemptivo) pelo sistema operacional. O interessante das fibers é que elas ocupam bem menos memória se comparado a threads, cada fiber pode ocupar de 2KB a 4KB.
Coroutines
Coroutines representam um modelo de concorrência onde as funções podem ser suspensas e retomadas posteriormente, mantendo seu estado entre execuções. As principais características incluem:
Suspensão e retomada: Uma coroutine pode pausar sua execução e retornar o controle para quem a chamou, mas mantém seu estado para continuar de onde parou;
Execução assíncrona sem callbacks: Permitem escrever código assíncrono de forma sequencial, evitando o callback hell;
Eficiência: Consomem menos recursos que threads, permitindo alta concorrência.
Thread Pools
Thread pools são mecanismos para gerenciar um conjunto de threads reutilizáveis, evitando a criação e destruição constante de threads, devido ao context switching que mencionei anteriormente.
Um service thread pool é dedicado a operações de longa duração, especialmente com foco em I/O (acesso a rede, DBs ou filesystems). Já um CPU thread pool é dedicado a operações de processamento intensivo, ideal para cálculos, transformações de dados, compressão, etc…
SEDA (Staged Event-Driven Architecture)
SEDA é uma arquitetura que decompõe uma aplicação em um conjunto de estágios conectados por filas, onde cada estágio possui seu próprio thread pool. Essa abordagem fornece alguns benefícios como:
Isolamento de recursos: Problemas em um estágio não afetam diretamente outros estágios;
Permite backpressure: Filas entre estágios podem implementar mecanismos de controle de fluxo;
Otimização de recursos: Cada estágio pode ter seu thread pool dimensionado de acordo com suas características específicas;
Monitoramento mais fácil: Métricas podem ser coletadas por estágio, facilitando a identificação de possíveis gargalos.
Linguagens de programação
Linguagens de programação podem ser interpretadas ou compiladas. Vale um grande parêntese aqui: muitas das linguagens se “vendem” por serem conhecidas por ser otimizadas para performance, mas que no final das contas, vai depender mais do software ou do código escrito do que da linguagem em si. Essa é a verdade.
Compilado vs Interpretado
Linguagens compiladas, como C, Go e Rust, passam por um processo de compilação antes da execução, onde o código fonte é traduzido para instruções de máquina e armazenado em arquivos binários. Esses binários podem ser executados a qualquer momento, sem necessidade de ser recompilado.
Uma das grandes vantagens das linguagens compiladas é a performance. Como o código já está traduzido para instruções que o processador entende, não haverá o overhead de interpretação durante o runtime.
Compiladores também aplicam otimizações, reorganizando instruções para extrair ainda mais desempenho.
Já por outro lado, as linguagens interpretadas como PHP, Python ou Ruby executam o código linha por linha durante o runtime. Esse processo de interpretação envolve parsing, tradução e execução dinâmica, o que vai adicionar um overhead a cada execução. Sim, essas linguagens não foram projetadas pensando em alta performance, e sim em produtividade e facilidade de escrita. Porém, há diversos sistemas mega robustos recebendo milhares de requests por segundo construídos com essas linguagens, o que novamente vale ressaltar: vai depender muito mais de como seu código foi escrito do que da linguagem em si.
Garbage Collection
Algumas linguagens utilizam um sistema automático de gerenciamento de memória, onde a memória alocada pela aplicação é “limpa” de forma assíncrona, chamado Garbage Collector. Porém, ao utilizar GC també há trade-offs:
A memória da aplicação pode crescer de forma gradativa se o GC não liberar objetos corretamente, levando seu sistema a “swapar” ou até cair (aquele famoso OOM no Kubernetes);
O GC consome CPU ao escanear a memória, o que pode reduzir os recursos disponíveis para a aplicação, especialmente em aplicações com uso intensivo de memória;
Dependendo do tipo de coleta utilizando no GC, ele pode pausar a execução da aplicação em alguns casos, retornando requests com uma latência acima do esperado.
Vale consultar a documentação da linguagem utilizada e configurar o GC de acordo com o seu cenário.
Virtual Machines
Linguagens como o Java são normalmente executadas em Virtual Machines (VMs), onde elas podem ser executadas independente da plataforma. O código fonte é compilado para um conjunto de instruções da VM, chamado bytecode, que pode ser executado em qualquer sistema que tenha aquela VM instalada.
Ainda utilizando o exemplo do Java, é possível utilizar JIT que converte o bytecode em instruções de máquina, oferecendo uma performance bem próxima ao de um código compilado.
Voltando em metodologias
Além das metodologias que descrevi em um post dedicado ao tema, vale a pena ressaltar algumas técnicas de análise um pouco mais específicas, como CPU Profiling, análise de syscalls e tracing distribuído.
CPU Profiling
Profiling constrói uma “imagem” de alguns momentos durante a execução da sua aplicação para entender um determinado comportamento da CPU, onde é possível identificar gargalos e problemas de performance.
Atualmente, várias (ou quase todas) ferramentas de observabilidade e monitoração possuem suporte a profilers, mas o que muita gente desconhece é que o próprio linux possui ferramentas para tal finalidade, como o perf
. O perf
roda em modo kernel e pode caputar tanto stacks do kernel (kernel-level) quanto de usuário (user-level), o que vai prover uma visibilidade praticamente completa da utilização da CPU.
Os profilers construidos para linguagens específicas geralmente capturam a utilização da CPU em user-level, o que pode ocultar algumas informações, por isso o ideal é iniciar essa análise utilizando um profiler que tenha acesso tanto ao user-level quanto ao kernel-level, para ter uma visão do todo.
A minha recomendação aqui é utilizar flame graphs. Eles estão disponíveis na maioria das ferramentas de observabilidade hoje em dia.
Outros recursos
Os profilers também mostram evidências de problemas que podem estar acontecendo além da utilização de CPU, I/O de disco é um exemplo disso. Ao analisar resultados de profilers através de flame graph, você pode encontrar funções que referenciam quais operações estão acontecendo naquele momento.
É importante ressaltar que ao analisar essas operações você só irá encontrar as atividades e não seus resultados. Uma das formas de entender melhor essas operações é instrumentando a aplicação com OpenTelemetry por exemplo, dessa forma você irá conseguir correlacionar melhor em qual parte do código está utilizando mais rede ou disco.
Vou listar alguns nomes de funções para dar uma ideia dessas operações ao analisar flame graphs:
ext4
,zfs
,xfs
: Operações no file systemtcp
: Operações de redealloc
: Alocação de memória
Syscalls
System calls também podem ser instrumentadas para encontrar problemas de performance. A ideia aqui é entender onde o time da syscall é gasto, incluindo o tipo e o motivo dela ter sido invocada.
Existe uma documentação muito abrangente sobre syscalls no próprio man
. Vou listar algumas syscalls importantes que podem ser analisadas:
Para execução de novos processos. Você pode utilizar o
execsnoop
para analisar a syscall execve(2);Para syscalls de I/O como read(2), open(2) e send(2) você pode utilizar o
bpftrace
;Já a ferramenta
syscount
pode mostrar o tempo de CPU em operações do Kernel (Kernel time).
Vale lembrar que dependendo da distribuição Linux, o nome desses comandos podem ser um pouquinho diferente. Por exemplo, no Ubuntu as ferramentas são nomeadas como coloquei acima. Já no PopOS! elas tem o sufixo bpfcc
.
USE
Assim como mencionei no post anterior, é possível utilizar a metodologia USE para resolver problemas em aplicação através da análise da utilização, saturação e erros.
Por exemplo, imagine um serviço que processa uploads de arquivos em background. Esse serviço terá utiliza uma fila de tarefas e um conjunto de processos (pool de worker threads) que irão armazenar os arquivos no S3. Tendo esse serviço em mente poder definir três métricas assim:
Utilização: Percentual médio dos processos de upload que estiveram ativos em um intervalo de tempo. Por exemplo, 80% significa que, em média, 8 de 10 processos estavam ocupados lidando com uploads;
Saturação: Tamanho médio da fila de uploads pendentes no mesmo intervalo. Esse valor indica quantos uploads estavam esperando por um processo disponível para começar;
Erros: Quantidade de uploads que falharam, seja por timeout, problemas de rede ou tamanho do arquivo excedido.
A próxima etapa seria identificar como coletar essas métricas. Algumas podem estar expostas pela aplicação via logs e métricas no formato timeseries para ser analisadas via Prometheus ou até utilizar eBPF para uma análise em tempo real. Esse tema ficará para um post futuro.
Tracing distribuído
Por fim, temos tracing distribuído, muito utilizado em ambientes de arquitetura distribuída compostas por serviços rodando em diferentes servidores e/ou sistemas.
Tracing distribuído envolve analisar informações de cada request realizadas nesses serviços e combiná-las para ter uma visão geral. Dessa forma saberemos quais são as dependências de cada serviço, o tempo gasto em cada uma dessas dependências e qual serviço está gerando a latência ou os erros que estão sendo retornados.
Dentro das informações que os traces produzem podem estar:
RequestID único da transação
Horário de início e fim da transação
Status de erros
Um dos desafios do tracing distribuído é o volume de dados que pode ser gerado, onde cada requisição na aplicação pode resultar em múltiplos registros. Uma abordagem comum para lidar com esse problema é utilizar sampling, por exemplo, é possível coletar dados de apenas uma em cada mil (ou mais) requests. Isso costuma ser suficiente para entender o comportamento geral do sistema, mas pode dificultar a análise de erros intermitentes.
Vale também mencionar que existem dois tipos de sampling, head-based e tail-sampled. Onde o head-based a decisão se toda informação será armazenada é feita no início da request e o tail-based é decidido posteriormente. A diferença entre eles é que no tail-based alguns critérios como latência ou presença de erros serão levados em consideração se o trace será mantido ou descartado.
Concluindo
Acredito que seja muito valioso abordar esses temas mais profundos, principalmente para quem trabalha com performance de aplicações, como SREs e engenheiros de software. Entender o que realmente está acontecendo em seu sistema é essencial para não ficar aquele famoso jogo de “empurra-empurra” entre infraestrutura e código.
Ferramentas de observabilidade ajudam bastante a mostrar sintomas e possíveis causas, mas saber como sua aplicação funciona, qual é o seu papel e como iniciar uma análise estruturada é o que faz diferença de verdade. No fim das contas, um bom troubleshooting começa com contexto, não com achismos.
Até a próxima.