Pular para o conteúdo

Trilha de Go · parte 13 de 65

Goroutines em Go: o que são e por que são baratas

Entenda o que é uma goroutine em Go, por que ela começa com cerca de 2 KB de pilha e por que o programa encerra assim que main termina, sem esperar as demais.

intermediárioPublicada em · 26 min de leitura

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

Uma é uma função rodando ao mesmo tempo que o resto do programa. Você cria uma escrevendo go na frente de uma chamada, e o Go devolve o controle para a linha seguinte sem esperar a função terminar. O sistema operacional não sabe que a goroutine existe. Quem a controla é o runtime da linguagem, que decide em qual thread ela roda, quando ela para e quando volta.

Esta aula explica o que é uma goroutine, por que ela é barata, como o runtime a encaixa em threads do sistema, o que acontece quando main termina e por que, neste ponto da trilha, ainda não existe uma forma segura de esperar por ela. Todos os exemplos foram rodados com Go 1.27.2 em macOS (arm64), numa máquina com 10 CPUs lógicas. Os números de memória e de tempo são desta máquina e servem como ordem de grandeza.

Pré-requisitos: funções e closures. Nenhuma outra aula da trilha é necessária.

Mapa da aula

ParteSeçõesO que você vai aprender
1. O conceito1 e 2O que é uma goroutine e por que threads do sistema ficaram caras para esse uso
2. Custo e crescimento3 e 4A pilha inicial de 2 KB, como ela cresce e o que acontece quando passa do limite
3. O runtime por cima5 e 6Os papéis G, M e P, a fila de prontas e o ciclo de vida de uma goroutine
4. e fim do programa7 a 9A diferença entre concorrência e , o encerramento de main e por que ainda não dá para esperar
5. Prática10 a 13Erros comuns, exercícios com resposta de referência, o que não foi confirmado, glossário e fontes

1. O que é uma goroutine

Imagine uma lanchonete com um único atendente. Ele anota o pedido da mesa 4, entrega a comanda para a chapa e, enquanto o hambúrguer assa, atende a mesa 7. Ninguém da fila fica parado olhando a carne queimar. Uma goroutine é uma dessas tarefas em andamento: uma função que pode ser suspensa quando precisa esperar, deixando a vez para outra função que tenha trabalho útil.

A sintaxe é deliberadamente curta. O exemplo abaixo cria uma goroutine para preparar o café e segue com a mensagem seguinte:

package main

import (
	"fmt"
	"time"
)

func prepararCafe() {
	time.Sleep(100 * time.Millisecond)
	fmt.Println("Café pronto!")
}

func main() {
	go prepararCafe()
	fmt.Println("Pedido anotado, garçom livre.")
	time.Sleep(200 * time.Millisecond)
}

Saída real de go run:

Pedido anotado, garçom livre.
Café pronto!

A linha go prepararCafe() retorna na hora. A goroutine começa a rodar, main imprime a primeira mensagem e, como prepararCafe dorme 100 ms, a segunda mensagem só aparece depois. O time.Sleep no final de main está ali para que o processo não termine antes do café. Esse Sleep é um remendo, e a seção 9 explica por quê.

Vale notar que main também é uma goroutine, a primeira do programa. Ela só difere das outras porque o programa termina quando ela termina, como a seção 8 mostra.

2. Por que existe: o custo das threads do sistema

Antes de uma goroutine ser barata, é preciso entender o que ela substitui. Uma thread do sistema operacional é como alugar uma sala inteira para cada pessoa que precisa de uma mesa de trabalho. A sala vem com paredes, porta e infraestrutura, e você paga a montagem mesmo que a pessoa vá usar só um canto. Em uma thread, esse aluguel é a pilha de execução, reservada quando a thread é criada, e a montagem é a criação feita pelo kernel.

Há dois custos que pesam. O primeiro é a memória da pilha. Cada thread recebe uma pilha de tamanho definido pelo sistema operacional, que costuma ser de centenas de KB ou mais. O segundo é a troca de execução. Quando o sistema quer pausar uma thread e rodar outra, a mudança passa pelo kernel, que salva e restaura o estado do processador. Esse caminho é mais lento que uma simples chamada de função.

Para programas que precisam de milhares de tarefas simultâneas, os dois custos somados viram um problema. Um servidor que atende milhares de conexões ao mesmo tempo precisaria de milhares de salas alugadas. Foi essa conta que levou o Go a adotar outro modelo: tarefas leves, criadas e escalonadas pelo próprio runtime da linguagem.

Thread do SO reserva pilha grande e a troca passa pelo kernel; goroutine começa com 2 KB, cresce sob demanda e troca no runtime
figura 1 · Thread do sistema tem pilha fixa e troca de contexto pelo kernel; goroutine come

A figura põe as duas opções lado a lado, com as mesmas três etapas: a pilha, a criação e a troca de execução. Na coluna da thread, a pilha é reservada e a troca passa pelo kernel. Na coluna da goroutine, a pilha começa pequena e a troca acontece dentro do runtime. Os valores exatos das pilhas de threads variam por sistema operacional e não foram medidos nesta aula (veja a seção 12).

3. Quanto custa uma goroutine: a pilha de 2 KB

A pilha de execução guarda as variáveis locais das funções em andamento e o endereço para onde cada função deve voltar. Uma goroutine nova começa com uma pilha de 2048 bytes. Esse valor está fixado no código do runtime, em src/runtime/stack.go, na constante stackMin = 2048. As notas da versão 1.4 registram a mesma mudança: o tamanho inicial padrão caiu de 8192 para 2048 bytes, porque a pilha passou a poder crescer por cópia (seção 4).

Para ver o custo na prática, o programa abaixo dispara n goroutines que dormem por 3 segundos e mede quanto de memória de pilha ficou em uso:

package main

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

func dormir() {
	time.Sleep(3 * time.Second)
}

func pilhasEmUso() uint64 {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return m.StackInuse
}

func main() {
	n, err := strconv.Atoi(os.Args[1])
	if err != nil {
		panic(err)
	}

	antes := pilhasEmUso()
	inicio := time.Now()
	for i := 0; i < n; i++ {
		go dormir()
	}
	criacao := time.Since(inicio)
	time.Sleep(500 * time.Millisecond) // tempo para todas começarem a dormir

	depois := pilhasEmUso()
	fmt.Printf("goroutines vivas: %d\n", runtime.NumGoroutine())
	fmt.Printf("tempo para disparar os %d go: %v\n", n, criacao.Round(time.Millisecond))
	fmt.Printf("pilhas em uso: %.1f MB\n", float64(depois-antes)/1024/1024)
	fmt.Printf("média por goroutine: %.0f bytes\n", float64(depois-antes)/float64(n))
}

Resultados reais, em três execuções:

$ ./bin-ex04 10000
goroutines vivas: 10001
tempo para disparar os 10000 go: 11ms
pilhas em uso: 19.9 MB
média por goroutine: 2087 bytes

$ ./bin-ex04 100000
goroutines vivas: 100001
tempo para disparar os 100000 go: 153ms
pilhas em uso: 195.7 MB
média por goroutine: 2052 bytes

$ ./bin-ex04 1000000
goroutines vivas: 1000001
tempo para disparar os 1000000 go: 2.024s
pilhas em uso: 1953.5 MB
média por goroutine: 2048 bytes

A média fica em torno de 2048 bytes, o que bate com a constante do runtime. A pequena diferença nas execuções de 10 mil e 100 mil vem de StackInuse, que conta os blocos de memória reservados pelo runtime para pilhas, e não as pilhas uma a uma. Com 1 milhão de goroutines, /usr/bin/time -l mostrou um pico de memória residente de cerca de 2,5 GB. A maior parte é a pilha, e o resto são as estruturas internas que o runtime mantém por goroutine e os temporizadores do Sleep.

Um milhão de goroutines dormindo custa algo perto de 2 GB, e isso ainda antes de o programa fazer qualquer trabalho com elas. O que a pilha pequena garante é que o custo cresce de forma previsível, em torno de 2 KB por goroutine, e que a criação é rápida.

4. A pilha cresce quando precisa

Uma pilha de 2 KB cabe pouco. Uma função com um vetor local grande, ou uma recursão funda, precisa de mais. Por isso a pilha cresce. A regra está nas notas da versão 1.4: quando uma goroutine atinge o limite da pilha, o runtime aloca uma pilha maior, copia todos os quadros ativos para lá e ajusta os ponteiros que apontavam para a pilha antiga. Antes dessa versão, a pilha era segmentada, e o problema dos “hot splits” fazia uma chamada que cruzava a fronteira de dois segmentos pagar a alocação e a liberação a cada iteração de um laço.

A checagem fica no início das funções. Cada função compara o ponteiro de pilha com stackguard0, um campo da struct da goroutine (o comentário do campo em runtime2.go diz que ele é “a stack pointer compared in the Go stack growth prologue”). Se o espaço não basta, a execução salta para runtime.morestack, que faz a cópia.

Pilha de 2 KB dobra a cada estouro pela rotina morestack, copia os quadros e leva a erro fatal ao passar de 1 GB
figura 2 · Pilha de 2 KB que dobra a cada estouro, até o limite de 1 GB, quando o programa

A figura mostra o caminho completo. A checagem decide se a próxima função cabe. Se cabe, ela roda sem cópia. Se não cabe, o morestack aloca o dobro, copia e segue, e a pilha vai de 2 KB para 4 KB, depois 8 KB, e assim por diante. O caminho pontilhado mostra o fim: quando a pilha ultrapassa o limite de 1 GB, o programa cai com fatal error: stack overflow. O limite de 1 GB em arquiteturas de 64 bits está em runtime/proc.go, na atribuição maxstacksize = 1000000000.

Um programa de demonstração confirma o crescimento. A função descer usa um quadro de 64 bytes em cada chamada e percorre um milhão de níveis dentro de uma goroutine. Para medir a pilha sem inflar o próprio quadro, pilhaEmUso fica separada com //go:noinline, porque sem essa marca a struct runtime.MemStats, grande, entrava no quadro de descer.

package main

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

// noinline: sem isso, o MemStats (uma struct grande) entra no quadro de descer.
//
//go:noinline
func pilhaEmUso() uint64 {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return m.StackInuse
}

// descer usa um quadro de tamanho razoável em cada chamada.
func descer(n int) int {
	if n == 0 {
		return 0
	}
	var buffer [64]byte
	buffer[0] = byte(n)
	if n == 500_000 {
		fmt.Printf("no meio da descida: pilhas em uso = %.1f MB\n", float64(pilhaEmUso())/1024/1024)
	}
	return descer(n-1) + int(buffer[0]&1)
}

func main() {
	go func() {
		fmt.Println("resultado:", descer(1_000_000))
	}()
	// Só para a demonstração: sem canais ou WaitGroup ainda, esperamos com sleep.
	time.Sleep(3 * time.Second)
	fmt.Printf("depois da descida: pilhas em uso = %.1f MB\n", float64(pilhaEmUso())/1024/1024)
}

Saída real:

no meio da descida: pilhas em uso = 128.2 MB
resultado: 500000
depois da descida: pilhas em uso = 0.2 MB

No meio da descida, a pilha da goroutine chegou a 128 MB. Depois que a função retornou, o uso voltou para 0,2 MB, o que mostra que a memória dessas pilhas é devolvida ao runtime quando a goroutine termina. A goroutine não recebeu nenhuma instrução especial para isso.

O caminho inverso também existe. Durante a coleta de lixo, o runtime pode encolher pilhas que ficaram muito maiores que o necessário, pela função shrinkstack em runtime/stack.go. Os critérios exatos não foram conferidos nesta aula (seção 12).

Agora o outro lado. Uma recursão sem caso de parada não cresce para sempre: ao passar de 1 GB, o programa cai.

package main

import (
	"fmt"
	"time"
)

func sem_fim(n int) int {
	return sem_fim(n+1) + 1
}

func main() {
	go func() {
		fmt.Println(sem_fim(0))
	}()
	time.Sleep(10 * time.Second)
	fmt.Println("main nunca chega aqui se a goroutine estourar a pilha")
}

A saída começa assim, seguida de um rastreamento de pilha longo:

runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0x2902505603a0 stack=[0x290250560000, 0x290270560000]
fatal error: stack overflow

O processo terminou com código de saída 2. O erro é fatal para o programa inteiro, e a linha que o main imprimiria depois do Sleep nunca aparece. Um é um erro fatal da runtime, fora do mecanismo de panic. A consequência prática é que recursão sem limite derruba o serviço inteiro, e não apenas a goroutine que a executou.

5. Como o runtime encaixa goroutines em threads

Com a pilha entendida, falta a pergunta de quem roda o quê. Em uma frase: o runtime tem três papéis, G para a goroutine, M para a thread do sistema operacional e P para o recurso que uma thread precisa para executar código Go. O comentário no topo de runtime/proc.go, na seção “Goroutine scheduler”, define exatamente isso e diz que um M precisa de um P para executar código Go, mas pode ficar bloqueado ou em uma chamada de sistema sem ter um P associado.

Pense no P como a caixa registradora de um supermercado. A thread (M) é o caixa que cobra, e as goroutines (G) são os clientes com as compras na mão. Sem caixa aberto, a fila não anda, por mais que haja gente esperando. Ao mesmo tempo, o número de caixas abertos é limitado pela quantidade de P, que o GOMAXPROCS define. O valor padrão vem da quantidade de CPUs lógicas da máquina, com ajustes de afinidade e de cota de cgroup no Linux, segundo a documentação do pacote runtime.

Quando você escreve go f(), o runtime cria a goroutine e a coloca no slot runnext do P atual, pela chamada runqput(pp, newg, true) em newproc. Se esse slot já tinha uma goroutine, ela é empurrada para a do P. A fila local tem 256 posições (runq [256]guintptr em runtime2.go), e há uma compartilhada para quando a local enche ou fica vazia.

O programa abaixo mostra os três números que definem o palco: CPUs lógicas, quantidade de P e goroutines vivas.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("CPUs lógicas:", runtime.NumCPU())
	fmt.Println("GOMAXPROCS (quantidade de P):", runtime.GOMAXPROCS(0))
	fmt.Println("goroutines vivas:", runtime.NumGoroutine())
}

Saída real:

CPUs lógicas: 10
GOMAXPROCS (quantidade de P): 10
goroutines vivas: 1

A figura abaixo mostra o arranjo. Cada P tem sua fila local, com o slot runnext à esquerda e as posições de fila à direita. A fila global, à direita, atende quando a local não tem trabalho. Cada P entrega a vez a uma thread M, e a thread executa uma goroutine por vez.

Goroutines G esperam em filas locais de processadores P e na fila global; cada P entrega a vez a uma thread M, e cada M executa uma goroutine por vez
figura 3 · Goroutines esperam nas filas dos processadores P e rodam nas threads M do sistem

Repare que a goroutine G1 está executando dentro de M0, mas a fila de G5, G6 e G7 não tem dono até que um P as pegue. Isso é o escalonador trabalhando. A parte que cuida de roubar trabalho de outro P, de como a fila global é consultada e de quando uma thread dorme, fica na nota Scheduler. Aqui basta o mapa: G para o trabalho, M para quem executa, P para o direito de executar.

6. O ciclo de vida de uma goroutine

Uma goroutine não fica simplesmente “rodando” do começo ao fim. Ela passa por estados, e entender os estados ajuda a ler traces de profiling e a diagnosticar programas travados. Pela ordem de vida, usamos quatro estados neste texto: criada, pronta, executando e , mais o fim, quando a função retorna.

A criada ainda não recebeu vez de nenhum P. A pronta está numa fila, esperando um P para rodar. A executando está com um P e uma thread, rodando instruções. A bloqueada parou porque precisa de algo que ainda não chegou, como um dado num canal, o fim de um time.Sleep ou uma operação de rede. Quando o motivo acaba, ela volta a pronta.

Ciclo de vida de uma goroutine: ela fica pronta, executa, bloqueia enquanto espera e volta a pronta, até a função retornar
figura 4 · Ciclo de vida de uma goroutine: criada, pronta, executando, bloqueada e encerrad

A figura traça as transições. O go f() cria a goroutine, que entra no slot runnext do P. Quando o P dá a vez, ela passa a executar. Se precisar esperar, vai para a bloqueada, e só volta para a pronta quando o motivo da espera for resolvido. Uma goroutine executando também pode voltar a pronta por preempção, quando o runtime percebe que ela monopolizou o processador, ou quando chama runtime.Gosched(). Quando f retorna, a goroutine termina.

Os nomes internos do runtime são outros. Em runtime2.go, os estados são _Grunnable (pronta), _Grunning (executando), _Gwaiting (bloqueada), _Gsyscall (dentro de uma chamada de sistema) e _Gdead (encerrada). Não há um estado separado para “criada”: pelo código de newproc, a goroutine já entra na fila de execução logo ao nascer. O estado “criada” da figura é um passo didático; a runtime não tem um estado com esse nome.

O que acontece com a thread quando a goroutine bloqueia é a parte mais importante do ciclo. A figura seguinte mostra o caso de time.Sleep.

Goroutine que chama Sleep sai da thread, e o temporizador a devolve à fila; enquanto isso, o P entrega a thread a outra goroutine pronta
figura 5 · Goroutine que dorme sai da thread M, e o processador P entrega a vez para a próx

G1 chama time.Sleep(100 ms) e fica bloqueada. Ela não ocupa a thread enquanto dorme: o runtime registra o prazo no temporizador, e a thread fica livre para executar outra goroutine da fila do P. Quando o prazo passa, o temporizador devolve G1 à fila, e ela espera a sua vez de novo. Nesse arranjo, a thread M0 nunca fica parada à toa, desde que haja trabalho pronto. É essa troca barata, feita em espaço de usuário, que permite manter milhares de goroutines dormindo sem milhares de threads.

Nem toda espera funciona assim. Chamadas de sistema que bloqueiam a thread no kernel, como algumas leituras de arquivo, exigem outro tratamento, em que o runtime pode dar o P a outra thread. Esse caminho é tratado na nota Scheduler e não foi testado nesta aula.

7. Concorrência não é paralelismo

Os dois termos costumam ser usados como sinônimos, mas descrevem coisas diferentes. Concorrência é a forma como o programa é organizado, com tarefas que progridem de maneira independente e podem ser intercaladas. Paralelismo é a execução física ao mesmo tempo, em mais de um núcleo. Um programa concorrente pode rodar em um único núcleo, alternando entre as tarefas, e um programa sem nenhuma concorrência não se beneficia de núcleos extras.

Rob Pike, um dos criadores do Go, fez essa distinção na palestra “Concurrency is not parallelism”, apresentada na Heroku Waza de 2012. O post do blog oficial do Go que resume a palestra, publicado em 16 de janeiro de 2013, traz a frase “Concurrency is about dealing with lots of things at once”, e a seguinte, sobre o paralelismo: “Parallelism is about doing lots of things at once”. Em português, a ideia é que concorrência trata de lidar com muitas coisas ao mesmo tempo, e paralelismo de fazer muitas coisas ao mesmo tempo (tradução nossa).

Pense num cozinheiro que alterna entre três panelas: é concorrência, porque o trabalho avança em três frentes, mesmo com uma pessoa só. Três cozinheiros cada um numa panela é paralelismo. O programa escrito com goroutines está no primeiro caso por construção, e o runtime decide quanto do trabalho vai para o segundo caso, conforme o GOMAXPROCS.

Com um processador, goroutines A, B e C revezam uma thread; com dois, A e B rodam ao mesmo tempo em núcleos diferentes
figura 6 · Concorrência é estruturar tarefas que progridem juntas; paralelismo é executá-la

A figura mostra o mesmo conjunto de tarefas em duas máquinas. À esquerda, um único P com uma thread: A, B e C revezam, e nenhuma roda no mesmo instante que outra. À direita, dois P com duas threads: A e C rodam em um núcleo enquanto B roda em outro. O código das goroutines é o mesmo nos dois casos, e só muda quanta execução simultânea a máquina oferece.

8. O que acontece quando main termina

Esta é a regra que mais pega iniciantes. A especificação da linguagem diz que a execução do programa começa pela função main e que, “when that function invocation returns, the program exits. It does not wait for other (non-main) goroutines to complete” (seção “Program execution”). Em português: quando a chamada de main retorna, o programa termina, e ele não espera as outras goroutines.

Veja o programa abaixo. Uma goroutine imprime uma batida a cada 100 ms, e main espera 350 ms antes de terminar:

package main

import (
	"fmt"
	"time"
)

func contarBatidas() {
	for i := 1; ; i++ {
		fmt.Printf("batida %d\n", i)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	go contarBatidas()
	time.Sleep(350 * time.Millisecond)
	fmt.Println("main terminou; o processo acaba aqui.")
}

Saída real (binário compilado sem -race):

batida 1
batida 2
batida 3
batida 4
main terminou; o processo acaba aqui.

A batida 1 sai em 0 ms, a 2 em 100 ms, a 3 em 200 ms e a 4 em 300 ms. Em 350 ms, main retorna, e a quinta batida, que sairia em 400 ms, nunca acontece.

O exemplo de abertura desta aula tinha a mesma armadilha, só que com menos tempo. O programa abaixo não tem nenhum Sleep em main:

package main

import "fmt"

func imprimirMensagem() {
	fmt.Println("Executando dentro da goroutine!")
}

func main() {
	go imprimirMensagem()
	fmt.Println("Fim da função main.")
}

Rodei esse programa dez vezes, e em nenhuma das dez a mensagem da goroutine apareceu. A saída foi sempre:

Fim da função main.

A goroutine é criada, mas main retorna antes que ela receba a vez de um P. Como a ordem não está garantida, o resultado pode mudar de máquina para máquina ou de execução para execução. O certo é esperar por ela de forma explícita, e isso fica para a seção 9.

Uma observação sobre o detector de corrida. Compilei o exemplo de contagem com go build -race, e o processo ficou vivo cerca de 1 segundo a mais depois que main retornou (medido em 1,36 s e 1,38 s, contra 0,35 s a 0,44 s sem a flag). Nesse intervalo, a goroutine continuou imprimindo: foram 13 ou 14 batidas, contra 4 sem a flag. A causa está em runtime.main: com -race, o runtime chama racefini() antes do exit(0), e as goroutines seguem rodando durante essa finalização. Então o contrato da especificação continua valendo (o programa termina quando main retorna), mas o intervalo até o encerramento real depende de a flag estar ligada. Não use a saída de uma build com -race para inferir o comportamento de produção.

Main retorna em 350 ms e o processo encerra; a goroutine que contava batidas para antes da quinta, que nunca chega a rodar
figura 7 · Quando main retorna, o processo encerra e as goroutines que ainda não terminaram

A figura mostra a mesma história na vertical, com o tempo descendo. A coluna de main tem três passos, e a de contarBatidas tem as batidas. Quando main retorna, a batida 5 fica tracejada e marcada como erro, porque ela é a parte que nunca acontece. A figura serve para mostrar que a goroutine não tem como avisar que ainda não terminou: quem decide o fim do programa é main.

9. Por que ainda não dá para esperar

Até aqui, o único jeito de impedir que main termine cedo foi dormir por um tempo. O código abaixo faz isso no exemplo que falhou na seção 8:

package main

import (
	"fmt"
	"time"
)

func imprimirMensagem() {
	fmt.Println("Executando dentro da goroutine!")
}

func main() {
	go imprimirMensagem()
	fmt.Println("Fim da função main.")
	time.Sleep(100 * time.Millisecond) // frágil: a goroutine pode precisar de mais tempo
}

Saída real:

Fim da função main.
Executando dentro da goroutine!

A mensagem aparece, mas a correção é uma aposta. Os 100 ms são uma estimativa de quanto a goroutine precisa para rodar, e em uma máquina sobrecarregada, ou com uma tarefa mais pesada, a estimativa pode faltar. Pior: se o tempo for grande demais, o programa fica parado sem motivo. Não existe um número de milissegundos que substitua saber de fato quando a goroutine acabou.

A solução segura vem em duas aulas. Para saber quando um grupo de goroutines terminou, a trilha usa sync.WaitGroup, na aula waitGroup. Para transportar resultados de uma goroutine para outra, usa canais, na aula Channels. Esta aula não usa nenhum dos dois, de propósito, para que o mecanismo das goroutines fique isolado. Os exemplos que dependem de tempo usam time.Sleep só como demonstração, e os comentários dizem isso.

Pergunta de entrevistaComo você garante que uma goroutine não vai se perder e vazar?

Toda goroutine precisa de um dono e de um jeito de terminar. Passo um context para ela e faço um select com ctx.Done(), para que ela pare quando o chamador desistir. Espero as goroutines com WaitGroup ou errgroup, e coloco timeout nas operações que podem travar. Canais de envio também precisam de receptor, senão a goroutine fica presa para sempre. Para investigar um vazamento, comparo a contagem de goroutines e leio o perfil de goroutines com pprof.

10. Erros comuns e boas práticas

Esquecer que main encerra o programa é o erro número um desta aula, e quem diz que nunca caiu nele ainda não escreveu um programa com goroutines de verdade. O custo é silencioso: o código parece certo, funciona em um teste rápido e falha quando a máquina está ocupada. A prática que resolve é esperar de forma explícita, sem depender de tempo.

Usar time.Sleep para sincronizar é o mesmo erro com aparência de solução. Ele funciona até o dia em que não funciona, e um Sleep longo demais vira lentidão que ninguém entende. Em código de verdade, a espera deve dizer o que espera.

Criar uma goroutine por item, sem limite, é o terceiro erro. Como cada goroutine custa cerca de 2 KB de pilha no início, um lote de um milhão de itens consome cerca de 2 GB antes de qualquer trabalho. O padrão de pool de workers, que limita a quantidade de goroutines ativas, está em Worker Pools e Rate Limiting. A goroutine é barata, mas o custo de memória existe e cresce com a quantidade.

Recursão sem caso de parada derruba o processo inteiro, como a seção 4 mostrou. O fatal error: stack overflow não passa pelo mecanismo de panic e não dá chance de o programa se recuperar. A proteção é a mesma de qualquer recursão: um caso de parada que seja alcançado.

Uma goroutine que espera por algo que nunca chega fica viva para sempre, e a memória dela não é liberada. Isso se chama vazamento de goroutine. Nesta aula não há canais, então o vazamento ainda não aparece nos exemplos. Ele volta na aula de canais, junto com as formas de evitá-lo.

11. Exercícios

As respostas de referência usam as saídas reais dos programas. Tente cada exercício antes de ler a resposta.

Exercício 1: previsão da contagem. O programa abaixo imprime a quantidade de goroutines vivas em três momentos. Antes de rodar, diga quais valores ele imprime.

package main

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

func esperar() {
	time.Sleep(100 * time.Millisecond)
}

func main() {
	fmt.Println("no início:", runtime.NumGoroutine())
	for i := 0; i < 3; i++ {
		go esperar()
	}
	fmt.Println("logo depois dos go:", runtime.NumGoroutine())
	time.Sleep(200 * time.Millisecond)
	fmt.Println("depois de 200 ms:", runtime.NumGoroutine())
}

Resposta de referência: a saída é no início: 1, logo depois dos go: 4 e depois de 200 ms: 1. A goroutine principal conta como uma. As três goroutines criadas dormem por 100 ms, então, aos 200 ms, já terminaram. Se você respondeu 1, 4 e 4, esqueceu que as três goroutines ainda estão vivas logo depois do go.

Exercício 2: medir a pilha. Rode o programa da seção 3 com 10 mil e com 100 mil goroutines, e compare a média por goroutine com o valor da constante stackMin. Explique por que o resultado não é exatamente 2048.

Resposta de referência: nesta máquina, a média foi de 2087 bytes com 10 mil goroutines e de 2052 bytes com 100 mil. A média fica perto de 2048 porque cada goroutine começa com essa pilha. A diferença vem de StackInuse, que mede blocos de memória reservados pelo runtime, e não pilhas isoladas. Em outra máquina, o número pode variar um pouco, mas deve ficar nessa faixa.

Exercício 3: corrigir sem chutar tempo. Reescreva o exemplo da seção 8, com imprimirMensagem, para que a mensagem da goroutine seja impressa antes de main terminar, sem usar time.Sleep como garantia.

Resposta de referência: sem WaitGroup nem canais, a única correção disponível nesta aula é o time.Sleep da seção 9, e ela é uma aposta. A resposta honesta é que o exercício não tem solução segura ainda. A solução correta usa sync.WaitGroup e será vista na aula waitGroup. Se você chegou a um código sem Sleep que funcionou, ele funcionou por sorte, e vale rodá-lo várias vezes para confirmar.

12. O que não foi confirmado

  • O tamanho padrão da pilha de uma thread do sistema operacional, em cada plataforma, não foi medido nesta aula. Os valores citados são ordens de grandeza, sem medição em um sistema específico.
  • O custo de uma troca de contexto entre threads do sistema e entre goroutines não foi medido. A comparação da seção 2 é qualitativa.
  • Os critérios exatos com que shrinkstack encolhe uma pilha durante a coleta de lixo não foram conferidos no código nem testados.
  • A data de lançamento do Go 1.14 não consta na página de notas da versão consultada. O texto da aula não dá essa data.
  • O comportamento de preempção assíncrona foi descrito a partir das notas do Go 1.14 e do comentário forcePreemptNS de 10 ms em runtime/proc.go. Não houve teste específico de preempção nesta aula.
  • As medições de memória valem para uma máquina macOS arm64 com 10 CPUs lógicas e Go 1.27.2. Outras máquinas e outras versões podem dar números diferentes.
  • O comportamento de chamadas de sistema bloqueantes, com a troca de P para outra thread, não foi testado.
  • O documento de projeto do escalonador (golang.org/s/go11sched), citado no comentário do runtime, não foi consultado nesta aula.

13. Glossário

  • Goroutine: função executando de forma concorrente com o resto do programa, escalonada pelo runtime do Go.
  • G: a struct que representa uma goroutine no runtime.
  • M: uma thread do sistema operacional que executa código Go quando tem um P associado.
  • P: o recurso necessário para uma thread executar código Go, com fila local de goroutines prontas. A quantidade de P é o GOMAXPROCS.
  • runnext: slot especial de cada P que guarda a goroutine que deve rodar em seguida, normalmente a que acabou de ser criada.
  • Fila local (LRQ): fila de goroutines prontas de um P, com 256 posições.
  • Fila global (GRQ): fila de goroutines prontas compartilhada pelos P, usada quando a local enche ou fica vazia.
  • Pilha contígua: pilha que, ao crescer, é copiada para uma área maior, em vez de encadear segmentos.
  • morestack: rotina do runtime chamada quando a pilha não cabe a próxima função. Ela aloca o dobro e copia os quadros.
  • Estouro de pilha (stack overflow): erro fatal quando a pilha de uma goroutine ultrapassa o limite de 1 GB em 64 bits.
  • Bloqueada: estado de uma goroutine que espera algo, como um canal, um temporizador ou uma operação de rede, e não ocupa uma thread enquanto espera.
  • GOMAXPROCS: quantidade máxima de P ativos, que limita quantas goroutines executam código Go ao mesmo tempo.
  • Concorrência: organização de um programa em tarefas que progridem de forma independente.
  • Paralelismo: execução simultânea de tarefas em núcleos diferentes.

14. Fontes

Consultas feitas em 3 de novembro de 2026.

  • Especificação da linguagem Go, seções “Go statements” e “Program execution”: https://go.dev/ref/spec#Go_statements (texto conferido também no arquivo doc/go_spec.html da toolchain go1.27.2).
  • Documentação dos pacotes runtime e time, conferida com go doc na toolchain go1.27.2: https://pkg.go.dev/runtime e https://pkg.go.dev/time.
  • Código-fonte do runtime da toolchain go1.27.2, nos arquivos src/runtime/proc.go (comentário “Goroutine scheduler”, maxstacksize, newproc, runqput, runtime.main), src/runtime/stack.go (stackMin, newstack, shrinkstack), src/runtime/runtime2.go (struct p com runnext e runq, estados de g, stackguard0) e src/runtime/signal_unix.go (sigPreempt = _SIGURG).
  • Notas da versão 1.4, sobre pilhas contíguas e o tamanho inicial de 2048 bytes: https://go.dev/doc/go1.4.
  • Notas da versão 1.14, sobre a preempção assíncrona de goroutines: https://go.dev/doc/go1.14.
  • Effective Go, seção “Goroutines” e “Share by communicating”, sobre goroutines leves: https://go.dev/doc/effective_go.
  • Andrew Gerrand, post “Concurrency is not parallelism” no blog do Go, 16 de janeiro de 2013, resumindo a palestra de Rob Pike na Heroku Waza de 2012: https://go.dev/blog/waza-talk.

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
?