Go Golang

3 abr, 2023

Como resolver memory leaks em maps

Publicidade

Uma das formas mais comuns de se fazer cache em aplicações Go é utilizando um map. Se você já fez isso, deve ter notado um aumento gradual no consumo de memória, e que normalmente após um restart da máquina ou pod volta ao “normal”.

Isso acontece devido a forma como o map funciona. Por isso, antes de ver o que podemos fazer para resolver esse tipo de problema, vamos entender melhor o map.

Para exemplificar o problema, vamos considerar uma variável do tipo map[int][128]byte, que será “carregada” com 1 milhão de elementos e que na sequência serão removidas.

Cada uma das chamadas da função printAlloc() irá exibir a quantidade de memória alocada para a variável m naquele determinado momento.

Ao executar o código acima, obtive o seguinte retorno.

Mesmo após remover todas as entradas do map, o tamanho dele não retornou ao seu tamanho inicial. Curioso não?!

Bom, isso acontece pois em Go, maps são implementados utilizando a estrutura de dados Hash Map, ou seja, um array onde cada posição é apontada para um bucket de objetos do tipo key-value.

Hash Table com foco no bucket 0

Cada um bucket contém um array com tamanho fixo de 8 posições. Quando o array estiver cheio e o Go precisar alocar um novo item, um novo array será criado e linkado ao anterior.

Na struct runtime.hmap, que é o cabeçalho de um map, dentre seus vários atributos, temos o atributo B uint8. Esse atributo é responsável por gerenciar a quantidade de buckets que aquele map tem, seguindo a regra de 2^B.

Após adicionar 1 milhão de elementos, seu tamanho será de 18 (2^18 = 262.133 buckets). No entanto, quando esses mesmos 1 milhão de elementos forem removidos, o valor de B continuará sendo 18.

Isso acontece pois o número de buckets em um map não pode ser reduzido. Logo, sempre que removemos um item de um map, o Go libera aquele slot para ser reutilizado, mas nunca diminui a quantidade total de slots.

Por isso, em um sistema de cache feito com map, pode acontecer que o consumo de memória aumente de forma gradual.

Para solucionar esse problema, a melhor estratégia é criar um novo map de tempos em tempos e “migrar” os dados atuais do cache para esse novo map. Após a “migração”, remover todos os itens do map antigo e deixar o Garbage Collector remover esse map da memória.

Porém, se a complexidade desse modelo for muito grande para ser implementada em seus sistema, uma forma simples para reduzir o consumo é utilizar o value do map como um ponteiro (map[int]*[128]byte).

Ao fazer essa simples mudança no código que escrevemos no inicio, os resultado da execução foi uma redução de aproximadamente 87% no tamanho do map pós remoção dos elementos.

MASSSS…. Antes que você saia mudando todo seu código para utilizar ponteiros, vale dizer que essa mudança só irá surtir efeito se seus elementos ou chaves forem menores do que 128 bytes, já que para elementos/chaves maiores do que isso, o Go irá automaticamente armazenar o ponteiro dos objetos e não seus valores.

Aproveitando, se você quiser aprender mais sobre Go, Kubernetes, Angular, Domain-Driven Design, Terraform e gRPC, venha participar da imersão Aprenda Golang. São mais de 240 aulas gravadas e 20 ao vivo. Para mais informações, acesse https://aprendagolang.com.br/imersao.

Até a próxima.