A premissa central de Go é “não se comunique compartilhando memória; compartilhe memória se comunicando”. Channels são a materialização dessa ideia: filas tipadas, seguras para concorrência, que conectam goroutines sem que você precise escrever um único mutex. O problema é que a interface simples (ch <- v, v := <-ch) esconde regras de fechamento, direção, bloqueio e vazamento que decidem se o serviço aguenta pico ou se corro de madrugada atrás de goroutine leak. Este guia cobre o uso de channels em produção, complementando o guia de concorrência com goroutines e channels, os padrões de concorrência (worker pool, fan-out/fan-in), o guia de errgroup para fan-out com erro e cancelamento e o guia de context para timeout e cancelamento.
O modelo mental: send, receive, close
Um channel é uma fila FIFO concorrente. Cada operação tem um comportamento bem definido que você precisa saber de cor antes de escrever um serviço:
| Operação | Estado do canal | Resultado |
|---|---|---|
ch <- v | sem buffer, sem receptor | bloqueia até alguém receber |
ch <- v | buffer cheio | bloqueia até alguém receber |
ch <- v | buffer com espaço | não bloqueia |
v := <-ch | vazio, aberto | bloqueia até alguém enviar |
v := <-ch | vazio, fechado | retorna zero-value, ok false |
ch <- v | fechado | panic |
close(ch) | já fechado | panic |
As duas linhas de panic são a fonte da maioria dos bugs reais. Quem fecha um canal é o remetente, nunca o receptor — porque só quem sabe que não vai mais enviar pode fechar com segurança. Receptores devem usar range ou a forma v, ok := <-ch para detectar o fechamento.
func producer(out chan<- int) {
defer close(out)
for i := 0; i < 10; i++ {
out <- i
}
}
func consumer(in <-chan int) {
for v := range in {
fmt.Println(v)
}
}
O defer close(out) no remetente é o padrão correto. Fechar do lado do receptor é a forma mais comum de introduzir um panic concorrente em produção.
Canais bufferizados: não são uma fila de mensagens
O tamanho do buffer é uma decisão de projeto, não um ajuste fino. Três tamanhos cobrem quase todos os casos em produção:
- 0 (sem buffer): handoff síncrono. Cada send bloqueia até um receptor estar pronto. É o padrão para sinalização e para garantir backpressure natural entre produtor e consumidor.
- 1: usado em padrões como “result ou done” (
chan struct{}), onde o buffer absorve exatamente um envio tardio depois do receptor já ter desistido. - N (limitado): usado para suavizar rajadas, normalmente pareado com
len(ch)em health checks ou comselect+defaultpara descarte sob pressão.
O erro clássico é achar que um buffer gigante (make(chan int, 100000)) resolve problemas de lentidão no consumidor. Ele só adia: o produtor continua na mesma vazão até o buffer encher, e então bloqueia da mesma forma. O buffer esconde o problema durante o pico e explode quando a memória acaba. Se o consumidor é mais lento que o produtor de forma persistente, a solução é mais consumidores, context.WithTimeout no send, ou descarte explícito — nunca um buffer maior.
// backpressure explícito com timeout: preferível a buffer gigante
select {
case out <- job:
case <-ctx.Done():
return ctx.Err()
}
select: a ferramenta mais importante de concorrência
select escolhe exatamente uma operação de canal pronta, aleatoriamente se várias estiverem prontas. Ele é o switch da concorrência e a base de todos os padrões de produção:
select {
case v := <-src:
process(v)
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
return errTimeout
}
Três formas merecem decorar:
case+default: torna oselectnão bloqueante. Útil para heartbeat, descarte de mensagens sob pressão e detecção de canal cheio.case <-ctx.Done(): garante que todoselectque espera em um canal de trabalho também respeita cancelamento. Sempre inclua quando o canal puder bloquear indefinidamente.case <-time.After(d): timeout por operação. Cuidado:time.Afteraloca um timer que só é coletado depois de disparar, então em loops de alta frequência prefiratime.NewTimercomReset, ou um únicotime.Ticker.
select com todos os cases bloqueando e sem default suspende a goroutine até qualquer um ficar pronto. É exatamente o que torna pipelines elegantes em Go.
Pipelines: estágios conectados por canais
Um pipeline é uma cadeia de estágios, cada um uma goroutine que lê de um canal, transforma e escreve em outro. O padrão escala bem porque cada estágio roda em paralelo e o backpressure flui naturalmente pelo tamanho dos buffers.
func source(ctx context.Context, urls []string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for _, u := range urls {
select {
case out <- u:
case <-ctx.Done():
return
}
}
}()
return out
}
func fetch(ctx context.Context, in <-chan string) <-chan []byte {
out := make(chan []byte, 8)
go func() {
defer close(out)
for u := range in {
body, err := httpGet(ctx, u)
if err != nil {
continue
}
select {
case out <- body:
case <-ctx.Done():
return
}
}
}()
return out
}
Cada estágio fecha seu canal de saída com defer close(out) e respeita ctx.Done() em cada send. Esse par de regras evita dois defeitos comuns: vazar a goroutine quando o consumidor desiste, e bloquear indefinidamente quando o contexto é cancelado.
Para distribuir trabalho entre N workers lendo do mesmo canal de entrada, o jeito idiomático é disparar N goroutines que compartilham o mesmo in <-chan, e coletar os resultados em um único out protegido por um sync.WaitGroup que fecha out quando todos terminam. Esse é exatamente o padrão de worker pool com fila de jobs e o que o errgroup com SetLimit automatiza quando você precisa propagar erro e cancelar irmãs.
nil channels como válvula seletora
Um select em um canal nil nunca é selecionado para aquela direção. Isso vira uma técnica elegante para desativar dinamicamente um case dentro de um loop:
var in <-chan int = src
var out chan<- int = dst
for in != nil || out != nil {
select {
case v, ok := <-in:
if !ok {
in = nil // desativa este case
continue
}
// prepara próximo envio
pending = v
case out <- pending:
if pending == last {
out = nil // desativa quando terminar
}
}
}
Esse padrão aparece em pipelines com fim bem definido (EOF) e em multiplexadores que combinam vários canais em um só. Vale conhecer porque é idiomático em código da biblioteca padrão e em bibliotecas como golang.org/x/sync/errgroup internamente.
Fan-in: combinando vários canais em um
Fan-in é multiplexar N canais de entrada em um único canal de saída. A forma correta usa uma goroutine por fonte e um sync.WaitGroup para fechar a saída exatamente uma vez:
func merge[T any](ctx context.Context, sources ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
wg.Add(len(sources))
for _, src := range sources {
go func(s <-chan T) {
defer wg.Done()
for v := range s {
select {
case out <- v:
case <-ctx.Done():
return
}
}
}(src)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Duas armadilhas clássicas: (1) fechar out antes de todas as goroutines terminarem provoca panic no send; (2) esquecer ctx.Done() no send interno vaza a goroutine se o consumidor desistir antes de todas as fontes esgotarem. A versão acima resolve os dois. Em pipelines que precisam propagar o primeiro erro e cancelar irmãs, prefira errgroup em vez de remontar essa lógica manualmente.
Prevenção de vazamento de goroutines
Uma goroutine vaza quando ela bloqueia para sempre em um send ou receive que ninguém mais vai completar. Os dois sintomas clássicos em produção:
- Memória cresce lentamente e o perfil mostra milhares de goroutines em
chan sendouchan receive. - Latência p95 degrada ao longo do tempo até reiniciar o processo.
A regra fixa: toda goroutine que bloqueia em canal precisa de um caminho de saída. Em geral isso é ctx.Done(). A auditoria consiste em, para cada go func(), confirmar que existe um case <-ctx.Done() (ou um close que a desbloqueie) em cada ponto de bloqueio.
// pronto para produção: respeita cancelamento
go func() {
defer wg.Done()
for {
select {
case v, ok := <-in:
if !ok {
return
}
handle(v)
case <-ctx.Done():
return
}
}
}()
Um teste rápido para capturar vazamento é usar goleak.VerifyNone(t) no fim de cada teste que dispara goroutines. Ele falha o teste se alguma goroutine criada pela suíte ainda estiver viva, expondo vazamentos antes do deploy. Em serviços de longa duração, vale também exportar runtime.NumGoroutine() como métrica e alertar sobre crescimento monotônico.
Sinalização com chan struct
O canal vazio chan struct{} é o idiomático para sinalização (onde o valor não importa, só o fato de ter sido enviado). Padrões comuns:
- Done channel:
done := make(chan struct{})fechado para sinalizar fim; receptores fazem<-done. - Limit/semáforo:
sem := make(chan struct{}, N);sem <- struct{}{}antes de rodar e<-semao terminar limita a N trabalhos simultâneos. Equivalente a um semáforo, sem importar bibliotecas. - One-shot broadcast: fechar um
chan struct{}libera todos os receptores ao mesmo tempo, porque receive em canal fechado retorna imediatamente.
type Worker struct {
stop chan struct{}
}
func (w *Worker) Run() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
w.tick()
case <-w.stop:
return
}
}
}
func (w *Worker) Stop() {
close(w.stop)
}
Esse padrão de “stop channel” é a base de servidores HTTP, workers de fila e qualquer componente que precise desligar limpo. Combine com graceful shutdown para fechar todos os canais na ordem certa durante o término do processo.
Health check e observabilidade de canais
Em produção, canais saturados aparecem como latência e não como erro. Vale expor dois sinais:
len(ch)/cap(ch)exportados como gauge por canal crítico, para detectar fila crescendo antes de virar timeout.- Contador de descarte quando você usa
select+defaultpara descartar sob pressão, para medir quanto trabalho foi perdido.
Não exporte len de canais não bufferizados (sempre 0 ou 1, ruído) nem de canais que mudam de tamanho raramente. Concentre observabilidade nos pontos onde o buffer é estratégico — fila de jobs, fila de escrita em batch, fila de eventos para o outbox.
Para rastrear fluxo end-to-end, propague traceID pelo payload do canal (e não só pelo context), porque quando a mensagem sai da goroutine atual o context original pode ser substituído. O guia de slog para logging estruturado e o de OpenTelemetry em Go mostram como carregar esses campos em produção.
Erros comuns em revisão de código
Uma lista prática do que procurar quando revisar código que usa channels:
- Fechar canal do lado do receptor. Quase sempre panic em algum caminho concorrente. Mova o
closepara o remetente. closeem canal compartilhado por vários remetentes. Só um pode fechar; use umsync.Onceou um coordenador.- Esquecer
ctx.Done()noselect. Vira goroutine leak no primeiro cancelamento. - Buffer gigante para “resolver” lentidão. Troque por mais consumidores ou por descarte explícito.
time.Afterdentro de loop apertado. Vira alocação de timer por iteração; usetime.NewTimercomReset.- Receber de canal fechado e tratar zero-value como dado. Sempre use
v, ok := <-ch. - Assumir ordem entre múltiplos cases prontos.
selecté aleatório; se a ordem importa, separe emselects encadeados.
Quando NÃO usar channels
Channels são elegantes, mas nem todo problema de concorrência é comunicação. Use mutex quando:
- O estado compartilhado é um mapa ou contador acessado por muitas goroutines com leitura/escrita curtas.
- A “mensagem” é na verdade um acesso a estado protegido, não um fluxo de dados.
Use sync.WaitGroup quando só precisa esperar um conjunto de goroutines terminar (sem dados fluindo entre elas). Use errgroup quando precisa do primeiro erro e cancelamento (já coberto no guia de errgroup). Use atomic para contadores simples. Canais brilham em fluxo de dados e sinalização; em estado, mutex e atômicos ganham em clareza e performance.
Para times com múltiplas stacks, vale comparar como o Python Dev Brasil aborda concorrência: asyncio e filas resolvem o mesmo problema de backpressure entre produtores e consumidores, mas o modelo de channels tipados e select do Go elimina uma categoria inteira de data race que em Python exige locks explícitos ou filas thread-safe.
Conclusão
Channels são a parte da biblioteca padrão de Go que mais recompensa estudo, porque cada padrão (pipeline, fan-in, semáforo, done channel) vira um bloco reutilizável que compõe sem atrito. As regras que mantêm um serviço saudável são poucas e consistentes: remetente fecha, todo select respeita ctx.Done(), buffer é decisão de projeto e não remendo, e toda goroutine tem caminho de saída. Aplicadas junto com context para cancelamento e errgroup para fan-out com erro, elas cobrem praticamente todo cenário de concorrência real em backend — sem um único mutex desnecessário.