Memory leaks em maps

Continuando nossa série de posts sobre garbage collector e memory leaks em Go, neste post exploraremos o que são maps e como acontecem seus memory leaks.

Antes de ler esse post, convido a ler os posts anteriores da série:

O que são maps

Map é uma estrutura chave-valor não ordenada, altamente eficientes e que baseia na estrutura de dados hash table. Internamente, uma hash table nada mais é do que um array de buckets, onde cada bucket é um ponteiro para um array chave-valor.

Cada array chave-valor tem um tamanho fixo de 8 elementos. Quando esse array está cheio (bucket overflow), um novo array de 8 elementos é criado e “linkado” ao array anterior.

Por último, é muito importante saber que o número de arrays chave-valor nunca diminui. Em outras palavras, mesmo que um dos array chave-valor não seja mais necessário, ele não é removido da memória, o que pode acabar causando memory leak na aplicação.

Memory leak em maps

Para entender melhor como ocorrem os memory leaks, vamos imaginar que utilizamos um map para cache, cenário muito comum.

package main

import (
	"fmt"
	"runtime"
	"time"
)

type Post struct {
	Title   string
	Content string
}

type Cache struct {
	Post    Post
	Created time.Time
}

func main() {
	n := 1_000_000
	m := make(map[int]Cache)
	
	printAlloc("INIT")

	for i := 0; i < n; i++ {
		m[i] = Cache{
			Post: Post{
				Title:   "Hello Gopher!",
				Content: "I think this will leak memory",
			},
			Created: time.Now(),
		}
	}
	printAlloc("FULL")

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

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

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

O código acima irá:

  1. Criar um map do tipo map[int]Cache
  2. Adicionar 1 milhão de entradas ao map
  3. Remover 1 milhão de entradas do map

Além disso, entre cada uma das etapas, vamos exibir a quantidade de memória alocada.

Ao executar o código, recebemos o seguinte resultado:

INIT  - 62 KB
FULL  - 226243 KB
EMPTY - 143678 KB

Veja que mesmo após remover todas as entradas, cerca de 64% da memória continua alocada.

Isso acontece pois, quando removemos uma entrada do map, o Go na verdade substitui a entrada pelo seu valor zero (zero value), o que para uma struct como a do nosso exemplo requer uma boa quantidade de memória.

Esse comportamento somado ao fato do Go não remover os arrays chave-valor vazios, faz com que o consumo continue alto.

Como evitar memory leaks em maps

Como vimos até aqui, o fato do Go substituir o dado pelo seu valor zero, somado ao fato dos arrays chave-valor não serem completamente removidos, torna quase impossível evitar memory leak em map.

Na verdade, a única forma de o fazer é reciclando o map de tempos em tempos. Em outras palavras, o ideal é a cada X tempo, criar um novo map e copiar somente os valores ainda existentes do map antigo.

No entanto, dependendo da complexidade da aplicação, fazer essa mudança pode ser extremamente difícil. Nesses caso, a simples mudança da struct pelo seu ponteiro poderá atenuar o memory leak.

Após mudar o map[int]Cache para map[int]*Cache e executar o código novamente, o resultado obtido foi:

INIT  - 62 KB
FULL  - 124012 KB
EMPTY - 39230 KB

Com essa pequena mudança, além de reduzir o consumo máximo de memória (FULL), conseguimos reduzir o EMPTY para 32% da memória alocada anteriormente, ou seja, 50% menos que a do exemplo anterior.

Conclusão

Embora o Go forneça ferramentas poderosas para gerenciar memória, como o garbage collector, o uso inadequado de maps pode levar a memory leaks difíceis de identificar.

Lembre-se, a eficiência da memória não depende apenas da linguagem, mas também de como você estrutura seu código.

Até a próxima!


Faça parte da comunidade!

Receba os melhores conteúdos sobre Go, Kubernetes, arquitetura de software, Cloud e esteja sempre atualizado com as tendências e práticas do mercado.

Livros Recomendados

Abaixo listei alguns dos melhores livros que já li sobre GO.

Deixe uma resposta