Entender memória é o próximo passo (e o mais importante)
Já que o pessoal curtiu meu último post sobre Por que entender percentis é importante para sua carreira (e vida), eu resolvi trazer mais um conteúdo didático hoje.
Obs.: Valeu @maniero pelas correções e clarificações sobre pontos que ficaram confusos ou mal explicados no post. O resultado abaixo é o texto com essas correções.
Percebo que muita gente na área tem um hiper-foco em frameworks, CRUD, cloud, e muitas outras ferramentas de alto nível. Isso vem, muitas vezes, acompanhado de uma negligência à conceitos de mais “baixo nível” e super importantes como o funcionamento da memória.
Vejo o pessoal compartilhando snippets de código no LinkedIn com formas mais “clean” ou “espertas” de fazer uma certa coisa, como por exemplo:
// ruim :(
public string GetSubLanguage(string subName)
{
if (subName == “u/brdev”)
{
return “brazilian portuguese”;
}
if (subName == “u/cscareerquestions”)
{
return “american english”;
}
// … continua
}
// bom ❤️🙈💕
public string GetSubLanguage(string subName)
{
var languagesPerSubName = new Dictionary<string, string>
{
{ “u/brdev”, “brazilian portuguese” },
{ “u/cscareerquestions”, “american english” },
// … continua
};
return languagesPerSubName[subName];
}
Se você não entende o por que o exemplo “bom” é, na verdade, pior que o exemplo “ruim”, acredito que esse post vai te agregar alguma coisa.
Antes de seguir: a vasta maioria das aplicações não tem uma lógica complicada o suficiente para que analises de tempo de execução sejam relevantes. Na vasta maioria das vezes, a interação com a memória é o que dita a performance.
Vários dos conceitos explicados aqui tem como fundamentação o funcionamento no .NET.
Antes de falarmos sobre memória, precisamos entender o que é uma thread
Antes de ir diretamente a como a memória funciona, é importante entender um pouco sobre o que é uma thread. Bem curto, uma thread é um constructo do sistema operacional para interagir com os núcleos do seu processador. Existem vários tipos de threads, inclusive aquelas que não são gerenciadas pelo sistema operacional, mas essas não vem ao caso agora.
Sua linguagem de programação favorita muitas vezes vai gerenciar as threads para você. Em Node, por exemplo, temos uma thread rodando que reage ao “event-loop” para processar o que precisa. Em C#, temos uma threadpool com várias threads que são criadas e cacheadas sobre demanda, de acordo ao nível de concorrência/paralelismo que precisamos - async/await pode necessitar de uma nova thread, o ASP.NET tenta rodar cada request em uma nova thread (até um certo limite).
Antes de falar sobre a memória na thread, é muito importante que você entenda que cada programa tem PELO MENOS uma thread principal, afinal você precisa de uma thread para executar qualquer coisa.
A thread tem uma memória privada
Finalmente, toda thread tem uma região de memória “privada” e uma região de memória “compartilhada”. A região privada é a mais performática, pois menos gerenciamento e coleção de lixo vai rolar por lá. Tudo que é escrito na região privada, geralmente, deve cumprir os seguintes requisitos:
- O tamanho do dado deve ser conhecido no momento da alocação
- O tempo de vida não deve exceder o escopo
Sobre o item 2., o que é o tempo de vida do dado (ou lifetime)? O tempo de vida do dado é o tempo que o dado demora para ter a região ocupada por ele na memória liberada.
Seu professor da Alura ou da faculdade deve ter te ensinado o mínimo sobre tipos de dados, incluindo tipos de valor (ints, chars, structs, …) e tipos de referência (strings, classes de tamanho variável, etc.). Um exemplo simples para entender o lifetime é notar que tipos de valor tem a memória liberada assim que essas saem de escopo (fim de uma chamada de função, por exemplo) e portanto devem ser copiadas quando passadas de um escopo para o outro, enquanto tipos de referência não necessariamente tem a memória liberada dessa forma, podendo durar por muito mais tempo.
Finalmente, geralmente os dados que são guardados na memória privada e cumprem os dois requisitos são: tipos de valor, ponteiros, e chamadas de função.
Sim, chamadas de função ocupam espaço na memória devido a alocação de seus parâmetros. Talvez isso te faça lembrar de alguma coisa, já que o nome da memória privada é STACK e chamar funções em recursão sem fim pode causar um STACK OVERFLOW (acabou memória na stack).
Cada sistema operacional e arquitetura de hardware tem um limite de memória privada por thread. No Windows 11 x64, por exemplo, o limite padrão pode ir de 1MB a 4MB, mas pode ser modificado.
A thread tem uma memória compartilhada
Existe uma região de memória compartilhada no sistema, em que sua linguagem vai ter que pedir “espaço” ao sistema operacional. O OS decide qual região da memória compartilhada você vai receber, e ela não necessariamente é contígua. Via de regra, toda alocação na memória compartilhada está acompanhada de uma alocação na stack de um ponteiro.
Por ter que fazer mais gerenciamento para alocar itens da memória compartilhada, geralmente a alocação é MENOS performática que na stack. Os itens alocados na memória compartilhada, geralmente, seguem as seguintes características:
- Tem tamanho variável, grande o suficiente, ou atrelado a um tipo de tamanho variável
- Sobrevivem ao final do escopo
Geralmente os itens que respeitam 1. e 2. são tipos de referência, como coleções, classes customizadas, etc.
O nome da memória compartilhada é HEAP
Por que evitar alocações no HEAP é tão importante
Já que grande parte do pessoal trabalha com linguagens com GC (garbage collector), vamos entender o que o GC faz.
O papel do GC é liberar a memória que está sendo ocupada desnecessariamente em ambas a memórias compartilhada e privada. Para isso, ele pode PAUSAR completamente a execução do seu programa para fazer a limpeza. Um exemplo extremo disso, foi quando o Discord migrou alguns serviços de Go para Rust por que o GC do Go pausava a aplicação por 2s.
Chamadas excessivas ao GC destroem a performance da aplicação. E nem todas as chamadas tem o mesmo custo, tipos de coleta podem acontecer em diferentes lifetimes, que são geralmente chamados de “gerações”:
- Gen0 - geralmente coletas de memória ocupada muito recentemente
- Gen1 - lifetime maior que Gen0
- Gen2 - lifetime maior que Gen1
A coleta de Gen0 é a menos custosa, e roda com mais frequência, enquanto Gen1 e Gen2 são mais custosas e rodam com menos frequência.
Entendemos então que o GC afeta muito a performance de um app, mas como podemos evitar coleções excessivas? Vamos voltar um pouco no post para:
“Um exemplo simples para entender o lifetime é notar que tipos de valor tem a memória liberada assim que essas saem de escopo - sobre dados em STACK”
Ou seja, a vasta maioria das stack allocations com lifetime baixo NÃO precisam ser coletada por GC, por que a coleta acontece de forma automática, assim que o escopo termina. Enquanto a VASTA maioria de alocações no HEAP vai precisar ser coletada pelo GC em algum momento.
Resumindo, tente evitar heap allocations desnecessárias.
Aprendendo a substituir heap allocations por stack allocations
Aqui vai depender muito da sua linguagem, então por favor vá pesquisar mais sobre ela. Vou dar umas dicas pra galera do C# que é o que tenho mais experiência atualmente.
- Favoreça tipos de valor para retornar mais de um dado numa função. ValueTuple ou uma struct personalizada substituem uma classe criada apenas para agregar esses dados de retorno.
- Se está lidando com async e sua função só sofre um await e nada mais, ValueTask é uma alternativa sobre Task.
- Se pretende fazer operações MUITO custosas com string, aprenda a usar o StringBuilder ao invés de usar “+“ ou interpolação para tudo.
- Se a intenção é operar sobre uma região de uma collection, aprenda a usar Span e ReadOnlySpan ao invés de LINQ.
- Dê um tamanho máximo, conhecido em compile time para suas coleções sempre que possível. A maioria das ICollection aceitam um atributo capacity no construtor que vai reduzir a quantidade de alocações internamente.
- Estude a linguagem para reconhecer quando heap allocations vão acontecer, falo também das classes de sistema, você sabe como a List<> funciona internamente?
- Aprenda a usar Pools para não alocar objetos pesados no heap desnecessariamente.
Bônus, configurando o GC
Várias linguagens permitem ativar um modo de GC chamado de GC concorrente. Basicamente, isso é voltado para servidores multi-threaded, em que a maioria das alocações não vão ser efetivamente acessadas por várias threads ao mesmo tempo.
O GC concorrente, ao invés de pausar todas as threads do sistemas, vai tentar pausar apenas a thread em que está coletando atualmente, evitando bloquear o seu app.
Isso geralmente aumenta o consumo de recursos do seu app, mas pode ser que valha a pena.
Desafio
Volta lá no início do post e tenta ver se entendeu o por que o exemplo “bom” é , na verdade, pior que o ruim.
Bônus final é se descobrir outro problema de performance no exemplo “bom”, que não está relacionado à memória.
EDIT: Seguindo os posts didáticos diários, aqui está um sobre Flighting vai salvar a sua vida (e a dos seus usuarios)