trilha

Tecnologias/01 - Linguagens/01 - Go5 min

Goroutines e Channels

Conceito

O problema que goroutine resolve é custo. Uma thread do sistema operacional custa 1–8 MB de stack reservado e um agendamento no kernel; dez mil threads são um problema de capacidade. Uma goroutine começa com ~2 KB de stack, cresce e encolhe sob demanda, e é agendada em user space. Um milhão de goroutines cabe num processo comum.

O truque é o scheduler M:N do runtime (modelo G-M-P):

G (goroutine)  → milhares, baratas
M (thread OS)  → poucas, uma por core ocupado
P (processor)  → GOMAXPROCS, a permissão de executar

Cada P tem sua fila local de goroutines e rouba trabalho (work stealing) da fila de outro quando fica vazia. Desde o Go 1.14 a preempção é assíncrona: uma goroutine em laço apertado não trava mais o scheduler.

O detalhe que muda tudo: I/O não bloqueia thread

Quando você escreve conn.Read(buf), o código parece bloqueante e é fácil de ler. Por baixo, o runtime registra o descritor no netpoller (epoll no Linux, kqueue no BSD), estaciona a goroutine e libera a thread para outra goroutine.

Você escreve código sequencial e ganha o comportamento de um event loop — sem async/await, sem callback hell, sem colorir funções.

É essa combinação (goroutine barata + netpoller transparente) que faz um serviço Go segurar dezenas de milhares de conexões simultâneas com meia dúzia de threads. Compare com modelo thread-per-request clássico.

Channels e CSP

Channel é uma fila tipada com sincronização embutida. O lema da comunidade:

Não comunique compartilhando memória; compartilhe memória comunicando.

ch := make(chan int)      // sem buffer: send espera receive (rendez-vous)
ch := make(chan int, 100) // com buffer: send só bloqueia se estiver cheio

select espera em vários channels ao mesmo tempo — é o switch da concorrência:

select {
case v := <-work:
    processa(v)
case <-ctx.Done():        // cancelamento: sempre tenha este caso
    return ctx.Err()
case <-time.After(2 * time.Second):
    return errTimeout
}

E context.Context é o mecanismo padrão de cancelamento em cascata: quem chama cancela, e todo mundo abaixo na árvore vê ctx.Done() fechar. É a razão de quase toda função de biblioteca em Go começar com ctx context.Context.

Trade-offs

Channel não é a ferramenta padrão. A cultura Go inicial exagerou. Para proteger um contador ou um mapa, sync.Mutex é mais simples e mais rápido; channel serve para transferir posse de um dado ou coordenar etapas. Rob Pike já disse o essencial: use o que deixar o código mais claro.

Channels não eliminam data race. Nada impede você de mandar um *User por um channel e continuar escrevendo nele do outro lado. O error que o compilador não pega, o go test -race pega — e ele deveria estar no seu CI.

Concorrência barata convida a concorrência ilimitada. Este é o erro mais comum e o mais caro:

for _, id := range ids { // 500 mil ids
    go buscar(id)        // 500 mil chamadas simultâneas ao banco
}

Não é "escalável", é uma negação de serviço contra a sua própria infraestrutura — e contra o pool de conexões do banco (5. Go em Produção). Concorrência precisa de limite explícito: errgroup.SetLimit, semáforo com channel, ou worker pool. É o mesmo raciocínio de Back Pressure e Queue-Based Load Leveling.

O trade-off central: o Go tornou concorrência tão barata que a dificuldade migrou de criar trabalho paralelo para limitar e encerrar trabalho paralelo. Toda goroutine que você inicia é uma pergunta em aberto: quem garante que ela termina?

Exemplo prático

Worker pool com limite, cancelamento e propagação de erro — o padrão que resolve 90% dos casos reais:

import "golang.org/x/sync/errgroup"

func processarTodos(ctx context.Context, ids []int) error {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(16) // no máximo 16 em voo — protege o banco e a memória

	for _, id := range ids {
		id := id // desnecessário desde o Go 1.22 (loop var por iteração)
		g.Go(func() error {
			select {
			case <-ctx.Done(): // o primeiro erro cancela o resto
				return ctx.Err()
			default:
			}
			return processar(ctx, id)
		})
	}
	return g.Wait() // devolve o primeiro erro, esperando todas terminarem
}
sync.WaitGroup.Go

Desde o Go 1.25 o próprio sync.WaitGroup tem o método Go, que dispara a goroutine e cuida do Add/Done. Para o caso simples (sem erro, sem limite) já não é preciso errgroup.

Como isso falha em produção

Falha Causa Sintoma
Goroutine leak goroutine esperando em channel que ninguém mais vai usar memória e contagem de goroutines só sobem
Deadlock send sem receive, ou dois locks em ordens diferentes fatal error: all goroutines are asleep
OOM goroutines sem limite × buffer por goroutine OOMKilled no contêiner
Race struct compartilhada sem lock resultado errado só sob carga
time.After em loop cada chamada aloca um timer que só é liberado no fim crescimento lento de memória; use time.NewTimer + Stop

O vazamento de goroutine é o mais insidioso, porque o programa continua correto — só não devolve recursos. E era difícil de diagnosticar: pprof mostrava 40 mil goroutines e cabia a você adivinhar quais eram legítimas.

Go 1.27: perfil goroutineleak

O Go 1.27 promoveu a detector de vazamento a perfil normal: runtime/pprof expõe goroutineleak (e net/http/pprof publica /debug/pprof/goroutineleak), que roda o GC e lista as goroutines permanentemente bloqueadas em channel, mutex ou similar — inalcançáveis por qualquer goroutine executável. Deixou de ser adivinhação. Ver 4. Versões, Módulos e Compatibilidade.

Aprofundamento aplicado

Cancelamento precisa atravessar todos os bloqueios

Considere um produtor que envia resultados para um consumidor que pode desistir. O envio também precisa observar o cancelamento, usando context:

select {
case resultados <- valor:
    // O valor foi entregue ou entrou no buffer.
case <-ctx.Done():
    return ctx.Err()
}

Esse trecho pressupõe ctx, resultados e valor definidos pela rotina chamadora. Se ninguém consome e o canal está cheio, o segundo caso oferece um caminho de saída. A mesma análise vale para receber trabalho e chamar dependências externas.

O cancelamento é cooperativo: cancel() sinaliza, mas não mata a goroutine. Quem cria as rotinas precisa esperar seu término, e quem fecha o canal precisa saber que nenhum produtor continuará enviando. Um canal de capacidade 100 adia o bloqueio; não resolve um consumidor permanentemente mais lento. Referência: contexto e cancelamento.

Exercício: faça o consumidor parar após o primeiro resultado. Resultado esperado: todos os produtores encerram, inclusive os que estavam esperando enviar; a operação não deixa goroutines acumulando a cada execução.

Relacionado


Parte de MOC Go · go.dev/doc/effective_go

Buscar

Busca por título, seção e texto das notas