Pular para o conteúdo

Trilha de Go · parte 26 de 65

Memory leaks em Go: como a memória fica presa

Seis causas comuns que mantêm memória viva em Go, medidas antes e depois com runtime.MemStats, e como localizar cada uma com pprof.

intermediárioPublicada em · 28 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Um vazamento de memória em Go não acontece porque alguém esqueceu de liberar a memória. Não existe free para esquecer. Ele acontece quando o programa mantém uma referência a algo que já não serve, e o coletor de lixo, que só recolhe o que ninguém mais alcança, respeita essa referência e deixa a memória ocupada. Quem procura o free que faltou na linguagem sem essa distinção não encontra nada, e passa a tarde inteira olhando para o lugar errado.

Nesta aula você vê seis causas típicas. Cada uma tem um programa que roda, a saída real e uma medição antes e depois. Para goroutines a medição usa runtime.NumGoroutine, e para memória, runtime.MemStats. No fim, mostramos como localizar a causa com pprof. A aula PProf explica a ferramenta em detalhe. Aqui usamos só os comandos necessários, e tudo o que você precisa para acompanhar está nesta nota.

Pré-requisitos: Garbage Collector, para entender o que é alcançável, e Goroutine, para entender por que uma goroutine pode reter memória. O Heap ajuda, mas não é obrigatório.

Todos os programas foram rodados em 2026-11-03, em um Mac com chip Apple (arm64, 10 núcleos, 32 GB), macOS 27.0.1 e go version go1.27.2 darwin/arm64. Os números de memória mudam de máquina para máquina. O que importa é a forma da curva e a diferença entre as versões de cada exemplo.

Mapa da aula

ParteSeçõesPergunta que responde
1. Fundamentos1 e 2O que é vazamento em Go e por que o coletor não resolve tudo?
2. As causas típicas3 a 8Quais são as formas mais comuns de reter memória sem querer?
3. Como achar9 a 11Como medir e localizar a causa com pprof?
4. Boas práticas12 e 13Quais erros se repetem e como evitá-los?
5. Prática e consolidação14 a 18Exercícios, o que não foi confirmado, glossário, fontes e relacionados

Parte 1: fundamentos

1. O que é vazamento em Go

Imagine o arquivo morto de um escritório. Ninguém consulta mais as pastas, mas a porta continua trancada com uma chave que o chefe guardou na gaveta dele. O zelador não entra, porque a chave existe. Nada se perdeu: as pastas estão lá, e o arquivo fica cada vez mais cheio.

No Go, o coletor de lixo faz o papel do zelador. Ele parte de um conjunto de pontos de entrada chamados raízes, que são as variáveis globais e as pilhas das goroutines vivas, e segue ponteiros a partir delas. Tudo o que é alcançável por esse caminho continua vivo. Tudo o que não é alcançável é liberado. A chave do exemplo é uma referência: enquanto ela existir a partir de uma raiz, o coletor não tem como saber que a memória já não interessa.

Por isso, vazamento em Go é memória retida que o programa não usa mais. A diferença muda onde você procura. Em C, a pergunta é qual chamada de free faltou. Em Go, a pergunta é qual referência sobrou: uma variável global, uma goroutine que nunca termina, uma fatia que aponta para um array grande, um mapa ou um cache que só cresce.

2. Por que o coletor não resolve tudo

O coletor do Go é preciso no que enxerga. Ele não sabe se um objeto alcançável será usado no futuro. Só sabe que existe um caminho até ele. A documentação oficial descreve o modelo assim: depois que um objeto se torna inalcançável, o coletor pode reciclá-lo. Enquanto houver caminho, o objeto é tratado como vivo, mesmo que o código nunca mais o toque. Essa fronteira define o que é responsabilidade do programa.

A figura mostra o heap do ponto de vista do coletor. Duas raízes à esquerda seguram caminhos até o mapa e até a fatia. Um objeto à direita não tem caminho nenhum, então é liberado.

Variável global e goroutine presa mantêm vivos um mapa e uma fatia que aponta para um array de 1 MiB; o objeto sem caminho é coletado
figura 1 · Retenção: o GC só libera o que não tem caminho até uma raiz

Os objetos em rosa estão vivos, e o array de 1 MiB é o que mais pesa. A fatia usa só 16 bytes dele, mas o coletor não tem como liberar o array inteiro enquanto a fatia existir. O objeto verde não tem caminho desde nenhuma raiz, então será liberado na próxima coleta. O coletor não erra ao manter os rosas, porque todos estão alcançáveis. O mapa, em particular, pode nunca mais ser lido, e o coletor não tem como saber disso. Ele enxerga referências, e uma referência esquecida é invisível para ele.

Parte 2: as causas típicas

As seis causas abaixo aparecem muito em serviços reais. Antes de cada código, veja o que ele tenta mostrar. Depois, a saída mostra o efeito, medido.

3. Goroutine presa num canal

Pense numa recepcionista que espera a resposta de um cliente. O cliente pegou a primeira página e saiu sem pedir o resto. A recepcionista continua sentada, com a pasta aberta na mesa, esperando alguém que não vai voltar. Uma goroutine bloqueada num canal faz exatamente isso: ela não termina, e tudo o que está na sua pilha continua vivo.

O canal do exemplo não tem buffer. Nesse caso, um envio só completa quando alguém recebe. A função processa envia três valores, um por vez. Quem chama lê apenas o primeiro e abandona o canal. A goroutine fica parada no segundo envio, para sempre. Como a pilha dela é uma raiz, tudo o que ela referencia permanece alcançável. A versão corrigida recebe um contexto e, no envio, usa select para escolher entre enviar e sair quando o contexto for cancelado.

package main

import (
	"context"
	"fmt"
	"net/http"
	_ "net/http/pprof"
	"os"
	"runtime"
	"runtime/pprof"
	"time"
)

// processa devolve um canal sem buffer. A goroutine envia um valor por vez.
// Se quem chamou ler só o primeiro valor e for embora, a goroutine espera para sempre.
func processa(itens []int) <-chan int {
	saida := make(chan int)
	go func() {
		for _, it := range itens {
			saida <- it * 2
		}
		close(saida)
	}()
	return saida
}

// processaCom é a versão corrigida: a goroutine também escuta o contexto.
func processaCom(ctx context.Context, itens []int) <-chan int {
	saida := make(chan int)
	go func() {
		defer close(saida)
		for _, it := range itens {
			select {
			case saida <- it * 2:
			case <-ctx.Done():
				return
			}
		}
	}()
	return saida
}

func main() {
	modo := "vazamento"
	if len(os.Args) > 1 {
		modo = os.Args[1]
	}

	base := runtime.NumGoroutine()
	fmt.Println("modo:", modo)
	fmt.Println("goroutines no início:", base)

	for i := 0; i < 1000; i++ {
		if modo == "corrigido" {
			ctx, cancel := context.WithCancel(context.Background())
			r := processaCom(ctx, []int{1, 2, 3})
			<-r      // lê só o primeiro valor
			cancel() // avisa a goroutine de que ninguém mais vai ler
		} else {
			r := processa([]int{1, 2, 3})
			<-r // lê só o primeiro valor e abandona o canal
		}
	}

	time.Sleep(100 * time.Millisecond)
	fmt.Println("goroutines depois de 1000 chamadas:", runtime.NumGoroutine())

	if modo == "servidor" {
		go http.ListenAndServe("localhost:6060", nil)
		select {}
	}

	if modo == "vazamento" {
		fmt.Println("--- perfil de goroutines (debug=1), primeiras linhas:")
		pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)
	}
}

Rodando a versão com vazamento e depois a corrigida:

modo: vazamento
goroutines no início: 1
goroutines depois de 1000 chamadas: 1001
--- perfil de goroutines (debug=1), primeiras linhas:
goroutine profile: total 1001
1000 @ 0x104a246e8 0x1049b58a4 0x1049b54b8 0x104bd063c 0x104a2b704
#	0x104bd063b	main.processa.func1+0x4b	…/e1/main.go:20
modo: corrigido
goroutines no início: 1
goroutines depois de 1000 chamadas: 1

Mil chamadas deixaram 1000 goroutines presas, e a versão corrigida voltou ao ponto de partida. O perfil de goroutines (debug=1) já agrupa as 1000 pela mesma pilha, main.processa.func1 na linha 20, que é o envio. A versão corrigida não deixa nada para trás porque o cancel() acorda o select e a goroutine sai pelo caminho do ctx.Done(). Repare também no defer close(saida): a goroutine fecha o canal ao sair, e isso vale tanto para o caminho normal quanto para o cancelamento.

Sem correção, uma goroutine fica bloqueada enviando o segundo valor a um canal sem leitor; com select e ctx.Done(), ela sai quando o contexto é cancelado
figura 2 · Goroutine presa num envio sem leitor, antes e depois da correção

A figura põe as duas versões lado a lado. À esquerda, a seta vermelha com o X mostra o envio barrado: ninguém lê o canal, e a goroutine não tem para onde ir. À direita, o contexto aparece como uma saída alternativa. O Done() chega à goroutine e ela encerra com o return. Os dois lados têm o mesmo canal. A diferença está na saída pelo contexto, que transforma a espera sem fim numa espera com saída.

Se você quiser ver o estado de cada goroutine, e não só a contagem, o perfil debug=2 mostra a espera com todas as letras:

goroutine 36 [chan send]:
main.processa.func1()
	…/e1/main.go:20 +0x4c
created by main.processa in goroutine 1
	…/e1/main.go:18 +0x98

O estado chan send diz que a goroutine está parada num envio. Esse texto costuma bastar para localizar a linha culpada.

4. Fatia que segura um array grande

Você recebe o recibo de uma compra de 16 bytes grampeado a uma nota fiscal de 1 MiB. Se você guarda só o recibo, a nota inteira continua no seu arquivo. A fatia é o recibo, e o array é a nota.

Uma fatia é um cabeçalho com três campos: um ponteiro para o primeiro elemento, o tamanho len e a capacidade cap. A expressão corpo[:16] cria um cabeçalho novo, com len igual a 16, e ele aponta para o mesmo array. A capacidade continua sendo a do array inteiro, e o coletor não consegue separar a parte usada da parte que sobrou. Enquanto esse cabeçalho existir, o array de 1 MiB inteiro fica vivo. O remédio é copiar os 16 bytes para um array novo, porque assim o original perde todas as referências.

package main

import (
	"fmt"
	"net/http"
	_ "net/http/pprof"
	"os"
	"runtime"
)

var guardados [][]byte

// cabecalho devolve só os 16 primeiros bytes, mas a fatia aponta para o
// array inteiro de 1 MiB, e o coletor não pode liberar o array enquanto ela existir.
func cabecalho(corpo []byte) []byte {
	return corpo[:16]
}

// cabecalhoCopia copia os 16 bytes para um array novo. O corpo original fica sem referências.
func cabecalhoCopia(corpo []byte) []byte {
	c := make([]byte, 16)
	copy(c, corpo[:16])
	return c
}

func heapKB() uint64 {
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return m.HeapAlloc / 1024
}

func rodar(f func([]byte) []byte) {
	for i := 0; i < 200; i++ {
		corpo := make([]byte, 1<<20) // 1 MiB por "requisição"
		corpo[0] = byte(i)
		guardados = append(guardados, f(corpo))
	}
}

func main() {
	modo := "compara"
	if len(os.Args) > 1 {
		modo = os.Args[1]
	}

	if modo == "servidor" {
		go http.ListenAndServe("localhost:6060", nil)
		rodar(cabecalho)
		fmt.Println("servidor pronto em localhost:6060 com 200 fatias guardadas")
		select {}
	}

	fmt.Printf("antes:                        %7d KB\n", heapKB())
	rodar(cabecalho)
	fmt.Printf("200 fatias de 16 bytes:       %7d KB\n", heapKB())
	guardados = nil
	fmt.Printf("depois de soltar as fatias:   %7d KB\n", heapKB())
	rodar(cabecalhoCopia)
	fmt.Printf("200 cópias de 16 bytes:       %7d KB\n", heapKB())
}
antes:                            228 KB
200 fatias de 16 bytes:        205042 KB
depois de soltar as fatias:       236 KB
200 cópias de 16 bytes:           272 KB

Duzentas fatias de 16 bytes seguraram 200 MiB, porque cada uma mantinha vivo o seu array de 1 MiB. Ao soltar as fatias, o heap voltou para perto dos 230 KB iniciais. Com cópias, o heap ficou em 272 KB, porque cada cópia tem apenas 16 bytes e os corpos foram coletados à medida que o laço avançava.

Fatia de 16 bytes guardada numa variável global mantém vivo o array de 1 MiB inteiro; copiar os 16 bytes deixa o array sem referências para o coletor
figura 3 · Fatia que segura um array de 1 MiB, antes e depois da cópia

No diagrama, a fatia de cima segura o array inteiro pela seta rosa de “mesmo array”. Na parte de baixo, a cópia não tem seta nenhuma para o corpo original, então o corpo tracejado em verde fica sem referências e sai do heap. O ponto que a figura reforça é que o tamanho que você vê na fatia (16) não é o tamanho que o coletor mantém (1 MiB). Isso também vale para append sobre uma subfatia, que escreve no mesmo array enquanto houver capacidade.

Pergunta de entrevistaComo uma goroutine pode causar vazamento de memória em Go?

Uma goroutine bloqueada para sempre nunca termina, e tudo o que está na pilha dela continua alcançável, porque a pilha é uma raiz do coletor. O exemplo clássico é um envio num canal sem buffer em que ninguém recebe mais: a função processa envia três valores, o chamador lê só o primeiro, e a goroutine fica presa no segundo envio. Na aula, mil chamadas deixaram 1000 goroutines paradas. A correção é dar uma saída: passar um context e usar select com ctx.Done() no envio, para a goroutine sair quando o chamador desistir. Para diagnosticar, observo se a contagem de goroutines cresce e leio o perfil de goroutines no pprof, que agrupa as presas pela mesma pilha.

5. Mapa que cresce e não devolve memória

Pense numa agenda de telefones em que você risca os nomes antigos, mas nunca troca a folha. Riscar não tira página nenhuma da encadernação. Com mapas acontece algo parecido: remover entradas libera os valores, mas a estrutura interna do mapa costuma continuar do tamanho que atingiu.

O programa a seguir passa por três momentos. Primeiro enche o mapa com um milhão de entradas. Depois apaga todas. Por fim troca o mapa por um novo.

package main

import (
	"fmt"
	"runtime"
)

func heapKB() uint64 {
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return m.HeapAlloc / 1024
}

func main() {
	// Cenário 1: o mapa cresce e nunca remove nada.
	fmt.Printf("antes:                              %7d KB\n", heapKB())
	m := make(map[int]int)
	for i := 0; i < 1_000_000; i++ {
		m[i] = i
	}
	fmt.Printf("1 milhão de entradas:               %7d KB (len=%d)\n", heapKB(), len(m))

	// Cenário 2: o código apaga todas as entradas.
	for k := range m {
		delete(m, k)
	}
	fmt.Printf("depois de apagar tudo (len=%d):     %7d KB\n", len(m), heapKB())
	runtime.KeepAlive(m) // sem isto, o compilador considera m morto e o GC pode recolhê-lo

	// Cenário 3: troca o mapa por um novo. Só assim a memória volta.
	m = make(map[int]int)
	fmt.Printf("depois de trocar por um mapa novo: %7d KB (len=%d)\n", heapKB(), len(m))
}
antes:                                  193 KB
1 milhão de entradas:                 37122 KB (len=1000000)
depois de apagar tudo (len=0):       37135 KB
depois de trocar por um mapa novo:     207 KB (len=0)

Depois de apagar tudo, o mapa estava vazio e continuava ocupando 37 MB. Só ao trocá-lo por um mapa novo a memória voltou ao patamar inicial. Esse comportamento foi medido no Go 1.27.2 e a documentação não promete que o encolhimento acontece em outras versões, então trate-o como observação, não como contrato.

Há uma armadilha de medição aqui que vale a pena conhecer. Na primeira vez que escrevi este exemplo, sem a linha runtime.KeepAlive, a memória caía logo após o delete, o que sugeria que o mapa sumia do heap. A causa era outra. Depois do último uso, a variável m deixa de ser considerada viva, e o coletor pode recolher o mapa mesmo ele ainda tendo entradas (vazias). runtime.KeepAlive marca a variável como alcançável até aquele ponto do código. Quem mede memória precisa saber disso, ou vai comemorar uma queda que não é real.

6. Cache sem limite

Pense numa geladeira de república sem regra. Todo mundo guarda alguma coisa, ninguém tira nada, e um dia a comida está no corredor. Um cache sem limite faz exatamente isso: cada chave nova entra, nenhuma sai, e o tamanho cresce junto com o número de chaves distintas que o sistema já viu.

O programa abaixo simula um serviço que consulta resultados de 4 KB por pedido. A versão sem nunca descarta nada. A versão com aceita no máximo mil entradas.

package main

import (
	"fmt"
	"os"
	"runtime"
)

type Cache struct {
	dados  map[string][]byte
	limite int // 0 significa sem limite
}

func (c *Cache) Obter(chave string) []byte {
	if v, ok := c.dados[chave]; ok {
		return v
	}
	v := make([]byte, 4096) // simula um resultado de 4 KB
	if c.limite > 0 && len(c.dados) >= c.limite {
		// Descarte arbitrário: simplificação didática. Um cache de verdade
		// costuma usar LRU (o menos usado recentemente sai primeiro).
		for k := range c.dados {
			delete(c.dados, k)
			break
		}
	}
	c.dados[chave] = v
	return v
}

func heapKB() uint64 {
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return m.HeapAlloc / 1024
}

func main() {
	modo := "sem"
	if len(os.Args) > 1 {
		modo = os.Args[1]
	}
	c := &Cache{dados: map[string][]byte{}}
	if modo == "com" {
		c.limite = 1000
	}

	for i := 0; i < 50_000; i++ {
		c.Obter(fmt.Sprintf("pedido-%d", i))
		if (i+1)%10_000 == 0 {
			fmt.Printf("cache %-4s | %6d pedidos | entradas=%6d | heap=%8d KB\n",
				modo, i+1, len(c.dados), heapKB())
		}
	}
}
cache sem  |  10000 pedidos | entradas= 10000 | heap=   41125 KB
cache sem  |  20000 pedidos | entradas= 20000 | heap=   82050 KB
cache sem  |  30000 pedidos | entradas= 30000 | heap=  123551 KB
cache sem  |  40000 pedidos | entradas= 40000 | heap=  163900 KB
cache sem  |  50000 pedidos | entradas= 50000 | heap=  204056 KB
cache com  |  10000 pedidos | entradas=  1000 | heap=    4312 KB
cache com  |  20000 pedidos | entradas=  1000 | heap=    4318 KB
cache com  |  30000 pedidos | entradas=  1000 | heap=    4319 KB
cache com  |  40000 pedidos | entradas=  1000 | heap=    4320 KB
cache com  |  50000 pedidos | entradas=  1000 | heap=    4331 KB

Sem limite, o heap cresce cerca de 4 MB a cada mil pedidos e chega a 204 MB com 50 mil. Com o limite, o número de entradas fica preso em mil e o heap se mantém perto de 4 MB do início ao fim. A diferença não é sutil, e a curva “sem” é a que você vê no gráfico de memória de um serviço que sobe sem parar.

O descarte do exemplo é arbitrário, porque o range sobre um mapa não tem ordem definida. Ele serve para mostrar o limite funcionando, e não como política de cache. Um cache de produção costuma escolher a vítima pela idade ou pelo uso (LRU), e ainda expirar entradas com um tempo de vida. Quem já tem uma estrutura pronta para isso deve preferi-la ao código caseiro.

7. Timers e tickers sem Stop

Existe um conselho antigo: sempre chame Stop num ticker, senão ele fica no heap. A documentação de time.NewTicker explica a mudança que tornou esse conselho menos urgente. Antes do Go 1.23, o coletor não recuperava tickers que não tinham expirado ou sido parados. Desde o Go 1.23, ele pode recuperar tickers sem referências, mesmo sem Stop. A própria documentação diz que o método “is no longer necessary to help the garbage collector”, e acrescenta que o código ainda pode chamá-lo por outros motivos.

Então, qual é o vazamento real? Ele aparece quando uma goroutine espera num ticker e nunca termina, porque a pilha dessa goroutine mantém o canal t.C vivo. O programa abaixo testa os dois casos. Primeiro cria cem mil tickers sem Stop e sem guardá-los. Depois cria mil goroutines que esperam tickers e nunca saem. Por fim faz a versão em que a goroutine recebe um contexto e chama Stop ao sair.

package main

import (
	"context"
	"fmt"
	"os"
	"runtime"
	"time"
)

func heapKB() uint64 {
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return m.HeapAlloc / 1024
}

// observadorSemSaida: uma goroutine que espera o ticker e nunca termina.
func observadorSemSaida() {
	go func() {
		t := time.NewTicker(10 * time.Millisecond)
		for range t.C {
		}
	}()
}

// observadorComSaida: a mesma espera, mas com saída pelo contexto e Stop no fim.
func observadorComSaida(ctx context.Context) {
	go func() {
		t := time.NewTicker(10 * time.Millisecond)
		defer t.Stop()
		for {
			select {
			case <-t.C:
			case <-ctx.Done():
				return
			}
		}
	}()
}

func main() {
	modo := "descartados"
	if len(os.Args) > 1 {
		modo = os.Args[1]
	}

	switch modo {
	case "descartados":
		// Cem mil tickers criados, sem Stop, e sem nenhuma referência guardada.
		fmt.Printf("antes:                 heap=%6d KB\n", heapKB())
		for i := 0; i < 100_000; i++ {
			t := time.NewTicker(time.Hour)
			_ = t
		}
		fmt.Printf("100 mil tickers sem Stop: heap=%6d KB, goroutines=%d\n", heapKB(), runtime.NumGoroutine())

	case "vazamento":
		base := runtime.NumGoroutine()
		for i := 0; i < 1000; i++ {
			observadorSemSaida()
		}
		time.Sleep(100 * time.Millisecond)
		fmt.Println("1000 observadores sem saída: goroutines =", runtime.NumGoroutine(), "(início:", base, ")")

	case "corrigido":
		base := runtime.NumGoroutine()
		ctx, cancel := context.WithCancel(context.Background())
		for i := 0; i < 1000; i++ {
			observadorComSaida(ctx)
		}
		time.Sleep(100 * time.Millisecond)
		fmt.Println("1000 observadores com saída (antes do cancel): goroutines =", runtime.NumGoroutine())
		cancel()
		time.Sleep(100 * time.Millisecond)
		fmt.Println("depois do cancel: goroutines =", runtime.NumGoroutine(), "(início:", base, ")")
	}
}
antes:                 heap=   193 KB
100 mil tickers sem Stop: heap=   199 KB, goroutines=1
1000 observadores sem saída: goroutines = 1001 (início: 1 )
1000 observadores com saída (antes do cancel): goroutines = 1001
depois do cancel: goroutines = 1 (início: 1 )

Os cem mil tickers descartados não deixaram nada no heap: 193 KB antes e 199 KB depois, com uma única goroutine. O problema está nas goroutines. Mil observadores sem saída deixaram mil goroutines vivas, e o cancelamento da versão corrigida as devolveu ao número inicial. Por isso, na revisão de código, a pergunta útil é “esta goroutine tem como terminar?”, e não “este ticker tem Stop?”. O Stop continua valendo como higiene, porque libera o timer imediatamente e deixa claro para quem lê que aquele recurso acabou.

Pergunta de entrevistaComo um cache em memória pode virar um vazamento e como você evita isso?

Um cache sem limite só adiciona: cada chave nova entra, nenhuma sai, e a memória cresce com o número de chaves distintas que o serviço já viu. Para o coletor isso não é lixo, porque o mapa ainda referencia tudo, então nada é liberado. Na aula, a versão sem limite chegou a 204 MB com 50 mil pedidos de 4 KB, e a versão com limite de mil entradas ficou perto de 4 MB. Eu defino um tamanho máximo e uma política de remoção, como LRU, e um tempo de vida para as entradas. Prefiro uma estrutura de cache pronta a um código caseiro, porque o descarte arbitrário do exemplo serve para demonstrar o limite e não como política de produção.

8. Ponteiros guardados em variáveis globais

Pense numa caixa de correio central que arquiva uma cópia de cada e-mail recebido, para sempre, mesmo depois de lido. Enquanto a caixa existir, o e-mail também existe, e ninguém precisa apagá-lo para isso acontecer. Uma variável global com ponteiros faz o mesmo: tudo o que for apontado a partir dela permanece alcançável até o fim do programa.

No exemplo, cada evento ocupa 64 KB. A versão vazado guarda o ponteiro de cada evento numa fatia global. A versão corrigido guarda só o resumo, com dois inteiros, e deixa o evento para o coletor.

package main

import (
	"fmt"
	"os"
	"runtime"
)

type Evento struct {
	ID      int
	Payload [64 << 10]byte // 64 KB por evento
}

// historico é uma global que só cresce.
var historico []*Evento

type Resumo struct {
	ID      int
	Tamanho int
}

var resumos []Resumo

func registrarVazado(id int) {
	historico = append(historico, &Evento{ID: id})
}

func registrarCorrigido(id int) {
	e := &Evento{ID: id}
	resumos = append(resumos, Resumo{ID: e.ID, Tamanho: len(e.Payload)})
}

func heapKB() uint64 {
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return m.HeapAlloc / 1024
}

func main() {
	modo := "vazado"
	if len(os.Args) > 1 {
		modo = os.Args[1]
	}
	fmt.Printf("antes:                     %7d KB\n", heapKB())
	for i := 0; i < 2000; i++ {
		if modo == "corrigido" {
			registrarCorrigido(i)
		} else {
			registrarVazado(i)
		}
	}
	fmt.Printf("2000 eventos (%s): %7d KB\n", modo, heapKB())
	fmt.Println("len(historico)=", len(historico), "len(resumos)=", len(resumos))
}
antes:                         193 KB
2000 eventos (vazado):  144217 KB
len(historico)= 2000 len(resumos)= 0
antes:                         193 KB
2000 eventos (corrigido):     253 KB
len(historico)= 0 len(resumos)= 2000

Dois mil eventos seguraram 144 MB na versão com a global. A versão que guarda só o resumo ficou em 253 KB, e a diferença vem de os eventos terem sido coletados assim que a função terminava. A correção aqui é de modelagem: a global não precisa do evento inteiro, só de um dado que a sustente. Se a lista precisar mesmo do conteúdo, a saída é limitá-la (uma janela das últimas N entradas) ou esvaziá-la periodicamente, porque uma global sem limite é um cache sem limite com outro nome.

Parte 3: como achar

9. Medir antes de adivinhar

Antes de abrir qualquer perfil, faça duas perguntas com números. A primeira é quantas goroutines existem e se esse número sobe com a carga ou só com o tempo. A segunda é quanto heap está em uso. runtime.NumGoroutine responde à primeira. Para a segunda, runtime.MemStats traz HeapAlloc, que a documentação define como “bytes de objetos de heap alocados”, e o runtime.GC() antes da leitura deixa o número mais estável, porque separa o que está vivo do que é lixo recente.

Esses dois números, medidos ao longo do tempo, dizem qual família de causa investigar. Se as goroutines sobem sem parar, o suspeito é uma goroutine presa (seção 3) ou um timer esperado (seção 7). Se só o heap sobe com as goroutines estáveis, o suspeito é uma referência retida (seções 4, 5, 6 e 8). Em produção, esses dois números costumam ir para um painel de métricas, e é aí que o problema aparece pela primeira vez, muito antes de alguém abrir o pprof.

10. pprof de goroutines

O pprof tem um perfil para goroutines, e o endereço dele é /debug/pprof/goroutine. Ele é alimentado pelo pacote net/http/pprof, que basta importar com _ para registrar as rotas. O exemplo usa o modo servidor do programa da seção 3, que mantém as mil goroutines vivas e expõe a porta 6060. Os comandos abaixo rodam sem interação:

go build -o bin-e1 ./e1
./bin-e1 servidor &
curl "http://localhost:6060/debug/pprof/goroutine?debug=2"
go tool pprof -top http://localhost:6060/debug/pprof/goroutine
curl "http://localhost:6060/debug/pprof/goroutineleak?debug=1"

A saída do go tool pprof -top resume as goroutines por função:

Type: goroutine
Showing nodes accounting for 1003, 99.90% of 1004 total
      flat  flat%   sum%        cum   cum%
      1003 99.90% 99.90%       1003 99.90%  runtime.gopark
         0     0% 99.90%       1000 99.60%  main.processa.func1
         0     0% 99.90%       1000 99.60%  runtime.chansend
         0     0% 99.90%       1000 99.60%  runtime.chansend1

Três pontos merecem atenção. O go tool pprof com uma URL grava uma cópia do perfil em ~/pprof, então ele deixa um arquivo a mais no seu diretório pessoal. Para não espalhar arquivos, baixe o perfil com curl e passe o arquivo para o go tool pprof, como fizemos na seção 11. O segundo ponto é que o debug=2 mostra cada goroutine com o estado, e o chan send é a pista que procuramos. O terceiro é o endpoint goroutineleak, que existe neste toolchain e respondeu com as mil goroutines. A documentação local o descreve como “stack traces de todas as goroutines vazadas”. Ele diz quais goroutines o runtime considera presas sem saída, e não apenas quais estão bloqueadas agora. Esse perfil existe no Go 1.27.2, mas não verifiquei em versões anteriores.

11. pprof de heap e a armadilha da alocação

Para a memória, o perfil de heap é a ferramenta. O modo inuse_space mostra os bytes de objetos que ainda estão vivos, agrupados pela pilha de chamadas em que foram alocados. Essa última parte é a chave da leitura. O perfil diz onde a memória nasceu, e não quem a está segurando. Veja a saída do modo servidor do programa da seção 4:

curl -s --retry 20 --retry-connrefused "http://localhost:6060/debug/pprof/heap?gc=1" -o heap.pb.gz
go tool pprof -top -sample_index=inuse_space heap.pb.gz
Type: inuse_space
Showing nodes accounting for 204.39MB, 100% of 204.39MB total
      flat  flat%   sum%        cum   cum%
  202.39MB 99.02% 99.02%   202.39MB 99.02%  main.rodar (inline)
       2MB  0.98%   100%        2MB  0.98%  runtime.mallocgc
         0     0%   100%   202.39MB 99.02%  main.main

O perfil aponta para main.rodar, porque é lá que o array de 1 MiB é alocado com make. Ele não aponta para cabecalho, que apenas fatia o array e não aloca nada. A referência que mantém tudo vivo está na variável global guardados, e nenhum gráfico do perfil desenha essa seta. Para fechar o diagnóstico, você precisa subir do ponto de alocação até quem guarda o resultado, lendo o código. A regra prática é: o pprof mostra quem aloca muito, e você descobre quem retém lendo as raízes (globais, goroutines, caches) que levam até o objeto.

Com isso, a árvore de decisão completa fica assim:

Fluxo para achar um vazamento: conte as goroutines com NumGoroutine; se elas crescem, use pprof de goroutines; se só a memória sobe, use pprof de heap.
figura 4 · Como achar um vazamento: do sintoma ao perfil de pprof

A figura começa pelo sintoma e separa dois caminhos. Se o número de goroutines cresce com a carga, o pprof de goroutines mostra o estado de cada uma, e o losango pergunta se elas estão bloqueadas num canal. Se a resposta é sim, o remédio é dar uma saída à goroutine, como na seção 3. Se só o heap cresce, o pprof de heap aponta onde a memória foi alocada, e a pergunta passa a ser quem guarda a referência. Os dois caminhos terminam em correções diferentes, e confundir um com o outro é a causa mais comum de perder tempo nessa investigação.

Parte 4: boas práticas e erros comuns

12. Erros comuns

Todo mundo já escreveu um go func() sem pensar em como a goroutine termina. Quem diz que nunca fez isso ainda não olhou o número de goroutines de um serviço depois de uma semana no ar. Os erros se repetem em poucos padrões. O primeiro é o envio sem dono, em que a goroutine manda um valor para um canal que ninguém mais lê, como no envio do segundo valor da seção 3. O segundo é o laço for range sobre um canal que nunca é fechado, porque o produtor esqueceu do close ou porque nunca há produtor. O terceiro é a subfatia de um payload grande guardada num cache, que segura o payload inteiro para sempre, como na seção 4. O quarto é o mapa ou o cache sem política de remoção, que cresce na mesma proporção das chaves novas (seções 5 e 6). O quinto é a global que acumula ponteiros, como na seção 8. O último é a goroutine que espera um ticker ou um timer sem saída, como na seção 7, e que parece inofensiva justamente porque o ticker, sozinho, não vaza.

13. Boas práticas

Toda goroutine precisa de um jeito de terminar: um contexto cancelado, um canal fechado ou um canal de sinal. A razão é simples: sem caminho de saída, a goroutine e tudo o que ela referencia ficam vivos enquanto o processo existir. Quando uma estrutura vai guardar algo grande que veio de fora, copie só o que precisa, porque uma fatia pequena pode segurar um array enorme. Dê limite a qualquer estrutura que cresce com entrada externa, seja um mapa, um cache ou uma lista global. Limite é o que transforma um crescimento sem fim em um teto conhecido. Remova entradas quando o motivo acabar e, se o mapa encolheu muito, troque-o por um novo, porque o tamanho interno não volta sozinho (seção 5). Chame Stop em tickers e timers, mesmo sabendo que o coletor pode recuperá-los, porque isso libera o recurso no momento certo e comunica a intenção ao leitor. E monitore NumGoroutine e HeapAlloc em produção, porque são os dois números que mostram o vazamento antes de qualquer sintoma para o usuário.

Parte 5: prática e consolidação

14. Exercícios

Exercício 1: onde está o vazamento

Considere a função abaixo. A função baixa é um exemplo qualquer que devolve uma string.

func buscaComTimeout(url string) (string, error) {
	resultado := make(chan string)
	go func() {
		resultado <- baixa(url)
	}()
	select {
	case r := <-resultado:
		return r, nil
	case <-time.After(time.Second):
		return "", errors.New("timeout")
	}
}

Quando o timeout dispara, o que acontece com a goroutine? Resposta de referência: ela continua viva, bloqueada no envio para resultado, porque ninguém mais vai ler esse canal. Ela e o resultado que segura ficam retidos até o fim do processo, uma vez por chamada que estourou o tempo. A correção mais simples é dar buffer ao canal, com make(chan string, 1). Assim o envio sempre completa, a goroutine termina, e o canal é coletado depois que a função retorna. Uma solução com contexto, em que a goroutine recebe ctx e escuta ctx.Done(), também resolve e é mais útil quando a função baixa aceita cancelamento.

Exercício 2: o registro de pedidos

Considere o código abaixo, que acompanha pedidos abertos em um serviço.

var pedidosAtivos = map[int]*Pedido{}

type Pedido struct {
	ID          int
	Itens       []Item
	Comprovante []byte
}

func abre(p *Pedido) {
	pedidosAtivos[p.ID] = p
}

func fecha(id int) {
	// o pedido já foi concluído, mas ninguém removeu a entrada
}

O que retém memória, e como corrigir? Resposta de referência: o mapa global guarda um ponteiro para cada pedido, e fecha não remove a entrada, então todo pedido concluído continua alcançável para sempre, junto com o Comprovante. A correção tem duas partes. A primeira é delete(pedidosAtivos, id) dentro de fecha. A segunda, mais profunda, é perguntar se o mapa precisa do pedido inteiro. Se só a contagem e o status interessam, guarde uma struct pequena com esses campos e deixe o Comprovante para o armazenamento que deve mantê-lo.

Exercício 3: por que o pprof aponta rodar

Rode o modo servidor do programa da seção 4, consulte o perfil de heap e explique por que a função de topo é main.rodar e não cabecalho. Resposta de referência: o perfil agrupa a memória viva pela pilha em que ela foi alocada. O array de 1 MiB é alocado dentro de rodar, com o make. A função cabecalho apenas cria uma fatia sobre o array existente, então não aparece como origem de alocação. Quem mantém tudo vivo é a variável global guardados, e o perfil não mostra essa referência. Para chegar a ela, é preciso subir do ponto de alocação até o código que armazena o resultado.

Exercício 4: meça antes de afirmar

Escreva uma função que chame processa cem vezes e imprima runtime.NumGoroutine antes e depois, sem o time.Sleep de cem milissegundos mostrado na seção 3. Depois faça a versão corrigida com contexto e compare. Resposta de referência: sem alguma pausa, o número lido logo após o laço pode não refletir as goroutines que acabaram de sair, então o time.Sleep ou um laço de espera curto faz parte da medição. Na versão com vazamento, o esperado é 101 ao final, porque a função inicial conta uma goroutine e cada chamada deixa uma presa. Na versão corrigida, o esperado é voltar a 1. Confirme com a sua máquina, porque o número exato depende do agendamento.

15. O que não foi confirmado

Alguns pontos desta aula não foram verificados nesta revisão, e o leitor deve tratá-los como observação:

  • O encolhimento de mapas (seção 5) foi medido apenas no Go 1.27.2 em arm64 macOS. A documentação não garante o comportamento, e não testei outras versões nem outras arquiteturas.
  • O comportamento de tickers antes do Go 1.23 (seção 7) está descrito na documentação, mas não foi reproduzido aqui, porque o toolchain disponível é o 1.27.2.
  • O perfil goroutineleak respondeu no Go 1.27.2. Não verifiquei se ele está ativo por padrão em outras versões, nem em quais condições ele classifica uma goroutine como vazada.
  • Os valores de memória e de tempo são de uma execução. Outras máquinas e outras cargas produzirão números diferentes, e a comparação válida é a forma da curva e a diferença entre as versões de cada exemplo.
  • O descarte do cache (seção 6) é uma simplificação. A política LRU não foi implementada nem medida.
  • Os exemplos isolam cada causa. Não testei um serviço real sob carga, nem o efeito combinado de duas ou mais causas no mesmo processo.

16. Glossário

  • Alcançável: objeto para o qual existe um caminho de ponteiros a partir de uma raiz.
  • Raiz (root): ponto de partida da varredura do coletor, formado pelas variáveis globais e pelas pilhas das goroutines.
  • Retenção: manutenção de memória viva por uma referência, mesmo quando o programa não a usa mais.
  • Goroutine leak: goroutine que nunca termina, geralmente bloqueada num canal, num timer ou num mutex sem saída.
  • Subfatia: fatia criada a partir de outra, que compartilha o mesmo array e a mesma capacidade.
  • Cabeçalho de fatia: os três campos internos de uma fatia (ponteiro, len e cap).
  • HeapAlloc: bytes de objetos de heap alocados, conforme runtime.MemStats.
  • inuse_space: perfil de heap que mostra os bytes de objetos vivos, agrupados pela pilha de alocação.
  • Perfil debug=2: saída textual do perfil de goroutines com a pilha e o estado de cada uma.
  • KeepAlive: função de runtime que marca uma variável como alcançável até um ponto do código.
  • LRU: política de descarte que remove a entrada menos usada recentemente.

17. Fontes

Consultadas em 2026-11-03.

  • Go Team. A Guide to the Go Garbage Collector. https://go.dev/doc/gc-guide. Usada para o modelo de alcançabilidade: depois que um objeto se torna inalcançável, ele pode ser reciclado.
  • Pacote time, função NewTicker. https://pkg.go.dev/time#NewTicker. Usada para a mudança de comportamento dos tickers a partir do Go 1.23.
  • Documentação local da toolchain go1.27.2: go doc runtime.NumGoroutine, go doc runtime.MemStats, go doc runtime.KeepAlive, go doc runtime/pprof, e o código-fonte de runtime/pprof/pprof.go, que lista os perfis predefinidos, incluindo goroutineleak.

18. Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?