← Voltar para o blog

Channels em Go: Comunicação entre Goroutines para Produção

Domine channels em Go em serviços reais: semântica de send/receive, select, canais bufferizados, nil channels, fechamento seguro, fan-in/fan-out, pipelines, backpressure e prevenção de vazamento de goroutines. Guia PT-BR com exemplos de produção.

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çãoEstado do canalResultado
ch <- vsem buffer, sem receptorbloqueia até alguém receber
ch <- vbuffer cheiobloqueia até alguém receber
ch <- vbuffer com espaçonão bloqueia
v := <-chvazio, abertobloqueia até alguém enviar
v := <-chvazio, fechadoretorna zero-value, ok false
ch <- vfechadopanic
close(ch)já fechadopanic

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 com select + default para 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:

  1. case + default: torna o select não bloqueante. Útil para heartbeat, descarte de mensagens sob pressão e detecção de canal cheio.
  2. case <-ctx.Done(): garante que todo select que espera em um canal de trabalho também respeita cancelamento. Sempre inclua quando o canal puder bloquear indefinidamente.
  3. case <-time.After(d): timeout por operação. Cuidado: time.After aloca um timer que só é coletado depois de disparar, então em loops de alta frequência prefira time.NewTimer com Reset, ou um único time.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 send ou chan 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 <-sem ao 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:

  1. len(ch) / cap(ch) exportados como gauge por canal crítico, para detectar fila crescendo antes de virar timeout.
  2. Contador de descarte quando você usa select + default para 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 close para o remetente.
  • close em canal compartilhado por vários remetentes. Só um pode fechar; use um sync.Once ou um coordenador.
  • Esquecer ctx.Done() no select. Vira goroutine leak no primeiro cancelamento.
  • Buffer gigante para “resolver” lentidão. Troque por mais consumidores ou por descarte explícito.
  • time.After dentro de loop apertado. Vira alocação de timer por iteração; use time.NewTimer com Reset.
  • 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 em selects 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.