photo of gray faucet

Como resolver memory leaks em maps

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.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	n := 1_000_000
	m := make(map[int][128]byte)
	printAlloc()

	for i := 0; i < n; i++ {
		m[i] = [128]byte{}
	}
	printAlloc()

	for i := 0; i < n; i++ {
		delete(m, i)
	}

	runtime.GC()
	printAlloc()
	runtime.KeepAlive(m)
}

func printAlloc() {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("%d KB\\n", m.Alloc/1024)
}

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.

70 KB // inicial
472402 KB // com 1M de elementos
300398 KB // após remover 1M de elementos

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.

69 KB // inicial
186540 KB // com 1M de elementos
39237 KB // após remover 1M 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.

Deixe suas dúvidas nos comentários.

Até a próxima.


Se inscreva na nossa newsletter

* indicates required

Deixe uma resposta