Channels: comunicação entre goroutines

Channels: comunicação entre goroutines

Channels em Go: envio e recebimento, canais com e sem buffer, fechamento e range, direção nos parâmetros, select com default e timeout, o canal nil que desliga um case, as três formas de pânico com close, done channel, pipelines, fan-out e fan-in e os timers do Go 1.23.
Golang

• • 15 min de leitura

No artigo Goroutines: concorrência leve e o modelo de execução do Go, goroutines foram apresentadas como unidades de execução concorrente. Mas goroutines isoladas têm utilidade limitada — o poder real emerge quando elas se comunicam e coordenam trabalho. Channels são o mecanismo que torna isso possível.

Um channel é um conduíte tipado através do qual goroutines enviam e recebem valores. Ele sincroniza automaticamente o acesso — quem envia espera quem recebe, e vice-versa, dependendo do tipo de channel. Isso elimina grande parte da complexidade que torna concorrência difícil em outras linguagens.

Criando e usando channels

Channels são criados com make e tipados pelo valor que transportam:

package main

import "fmt"

func main() {
    // Channel de inteiros sem buffer
    ch := make(chan int)

    // Enviando em uma goroutine separada
    go func() {
        ch <- 42  // envia 42 para o channel
    }()

    // Recebendo na goroutine principal
    valor := <-ch
    fmt.Println(valor) // 42
}

O operador <- é usado tanto para envio quanto para recebimento. A direção da seta indica a direção do dado:

  • ch <- valor — envia valor para o channel ch
  • valor := <-ch — recebe do channel ch e armazena em valor
  • <-ch — recebe e descarta o valor

Channels sem buffer: sincronização garantida

Um channel criado com make(chan T) é sem buffer. Ele não armazena valores — cada envio bloqueia até que haja um receptor, e cada recebimento bloqueia até que haja um emissor. Essa propriedade de rendezvous garante sincronização precisa:

package main

import (
    "fmt"
    "time"
)

func processarPedido(id int, resultado chan<- string) {
    time.Sleep(100 * time.Millisecond) // simula processamento
    resultado <- fmt.Sprintf("pedido %d processado", id)
}

func main() {
    resultado := make(chan string)

    go processarPedido(1, resultado)

    // main bloqueia aqui até processarPedido enviar
    msg := <-resultado
    fmt.Println(msg)
}

Channels com buffer

Um channel com buffer aceita um número definido de valores sem bloquear o emissor. O bloqueio só ocorre quando o buffer está cheio (para emissores) ou vazio (para receptores):

package main

import "fmt"

func main() {
    // Channel com buffer de capacidade 3
    ch := make(chan string, 3)

    // Esses envios não bloqueiam — o buffer absorve os valores
    ch <- "primeiro"
    ch <- "segundo"
    ch <- "terceiro"
    // ch <- "quarto"  — bloquearia: buffer cheio

    fmt.Println(<-ch) // primeiro
    fmt.Println(<-ch) // segundo
    fmt.Println(<-ch) // terceiro

    fmt.Println(len(ch), cap(ch)) // 0 3
}

len(ch) retorna quantos valores estão no buffer atualmente. cap(ch) retorna a capacidade total do buffer.

A escolha entre channel com ou sem buffer depende do problema. Sem buffer: quando a sincronização entre emissor e receptor é necessária. Com buffer: quando se quer desacoplar levemente o ritmo de produção e consumo.

Fechando channels

Um channel pode ser fechado com close(ch). Após o fechamento, nenhum valor pode ser enviado, mas valores já no buffer podem ser recebidos. Receber de um channel fechado e vazio retorna o valor zero do tipo e false:

package main

import "fmt"

func gerarNumeros(ch chan<- int, limite int) {
    for i := 1; i <= limite; i++ {
        ch <- i
    }
    close(ch) // sinaliza que não há mais valores
}

func main() {
    ch := make(chan int, 5)
    go gerarNumeros(ch, 5)

    // Forma 1: verificando se o channel foi fechado
    for {
        valor, aberto := <-ch
        if !aberto {
            break
        }
        fmt.Println(valor)
    }
}

A forma idiomática de iterar até um channel ser fechado usa for range:

ch := make(chan int, 5)
go gerarNumeros(ch, 5)

for valor := range ch {  // termina automaticamente quando ch for fechado
    fmt.Println(valor)
}

Regras importantes sobre fechar channels: apenas o emissor deve fechar o channel, nunca o receptor. Fechar um channel já fechado causa pânico. Enviar para um channel fechado causa pânico.

Direção de channels em funções

Parâmetros de função podem restringir a direção de uso de um channel. Isso documenta a intenção e permite que o compilador detecte usos incorretos:

// chan<- int — apenas envio (send-only)
func produtor(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
    // <-ch  — erro de compilação: receive from send-only channel
}

// <-chan int — apenas recebimento (receive-only)
func consumidor(ch <-chan int) {
    for v := range ch {
        fmt.Println("recebido:", v)
    }
    // ch <- 1  — erro de compilação: send to receive-only channel
}

func main() {
    ch := make(chan int, 5)
    go produtor(ch)
    consumidor(ch)
}

Um channel bidirecional chan T pode ser passado onde chan<- T ou <-chan T são esperados — a conversão é automática. O contrário não é verdade.

Select: múltiplos channels simultaneamente

O select está para os channels como o switch está para os valores — permite aguardar múltiplos channels simultaneamente, executando o case do primeiro que estiver pronto:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(100 * time.Millisecond)
        ch1 <- "resultado de ch1"
    }()

    go func() {
        time.Sleep(200 * time.Millisecond)
        ch2 <- "resultado de ch2"
    }()

    // Aguarda o que chegar primeiro
    select {
    case msg := <-ch1:
        fmt.Println("ch1 venceu:", msg)
    case msg := <-ch2:
        fmt.Println("ch2 venceu:", msg)
    }
}

Se múltiplos cases estiverem prontos simultaneamente, Go escolhe um aleatoriamente — garantindo que nenhum case seja perpetuamente ignorado.

O select com default é não bloqueante — se nenhum channel estiver pronto, o default executa imediatamente:

select {
case msg := <-ch:
    fmt.Println("recebido:", msg)
default:
    fmt.Println("nenhuma mensagem disponível agora")
}

Timeout com select e time.After

Implementar timeouts em operações concorrentes é trivial com select e time.After:

package main

import (
    "fmt"
    "time"
)

func operacaoLenta(resultado chan<- string) {
    time.Sleep(2 * time.Second)
    resultado <- "dados processados"
}

func main() {
    resultado := make(chan string, 1)
    go operacaoLenta(resultado)

    select {
    case dados := <-resultado:
        fmt.Println("Sucesso:", dados)
    case <-time.After(500 * time.Millisecond):
        fmt.Println("Timeout: operação demorou demais")
    }
}

time.After retorna um channel que recebe um valor após a duração especificada. Quando esse case vence o select, o timeout foi atingido.

Padrão done channel: cancelamento

Um padrão fundamental em Go é o done channel — um channel usado para sinalizar a outras goroutines que devem encerrar:

package main

import (
    "fmt"
    "time"
)

func trabalhoContinuo(done <-chan struct{}, id int) {
    for {
        select {
        case <-done:
            fmt.Printf("goroutine %d encerrada\n", id)
            return
        default:
            fmt.Printf("goroutine %d trabalhando\n", id)
            time.Sleep(200 * time.Millisecond)
        }
    }
}

func main() {
    done := make(chan struct{})

    for i := 1; i <= 3; i++ {
        go trabalhoContinuo(done, i)
    }

    time.Sleep(600 * time.Millisecond)
    close(done) // fecha o channel — sinaliza todas as goroutines simultaneamente
    time.Sleep(100 * time.Millisecond)
    fmt.Println("todas as goroutines encerradas")
}

Fechar um channel é a forma idiomática de fazer broadcast para múltiplas goroutines — todas as que estão lendo do channel desbloqueiam imediatamente quando ele é fechado. Isso não seria possível com envio, que acorda apenas um receptor por vez.

O tipo struct{} é usado porque ocupa zero bytes — o channel serve apenas como sinal, não transporta dados.

Pipeline: channels em cadeia

O padrão de pipeline conecta goroutines em cadeia, onde a saída de uma é a entrada da próxima:

package main

import "fmt"

// Estágio 1: gera números
func gerar(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// Estágio 2: eleva ao quadrado
func elevarAoQuadrado(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

// Estágio 3: filtra apenas pares
func filtrarPares(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            if n%2 == 0 {
                out <- n
            }
        }
        close(out)
    }()
    return out
}

func main() {
    // Conecta o pipeline: gerar → quadrado → filtrar pares
    nums := gerar(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    quadrados := elevarAoQuadrado(nums)
    pares := filtrarPares(quadrados)

    for v := range pares {
        fmt.Println(v) // 4, 16, 36, 64, 100
    }
}

Cada estágio do pipeline roda em sua própria goroutine. Os channels garantem que cada estágio só processa um valor quando o próximo está pronto para recebê-lo — backpressure natural.

Fan-out e fan-in

Fan-out distribui trabalho de um channel para múltiplos workers. Fan-in combina resultados de múltiplos channels em um só:

package main

import (
    "fmt"
    "sync"
)

// Fan-out: múltiplos workers leem do mesmo channel
func worker(id int, tarefas <-chan int, resultados chan<- string, wg *sync.WaitGroup) {
    defer wg.Done()
    for tarefa := range tarefas {
        resultados <- fmt.Sprintf("worker %d processou tarefa %d", id, tarefa)
    }
}

// Fan-in: combina múltiplos channels em um
func merge(channels ...<-chan string) <-chan string {
    var wg sync.WaitGroup
    merged := make(chan string, 10)

    enviar := func(ch <-chan string) {
        defer wg.Done()
        for v := range ch {
            merged <- v
        }
    }

    wg.Add(len(channels))
    for _, ch := range channels {
        go enviar(ch)
    }

    go func() {
        wg.Wait()
        close(merged)
    }()

    return merged
}

func main() {
    tarefas := make(chan int, 10)
    resultados := make(chan string, 10)
    var wg sync.WaitGroup

    // Fan-out: 3 workers lendo do mesmo channel de tarefas
    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go worker(i, tarefas, resultados, &wg)
    }

    // Envia 9 tarefas
    for i := 1; i <= 9; i++ {
        tarefas <- i
    }
    close(tarefas)

    // Fecha resultados após todos os workers terminarem
    go func() {
        wg.Wait()
        close(resultados)
    }()

    for resultado := range resultados {
        fmt.Println(resultado)
    }
}

Channels transformam a comunicação entre goroutines em algo que o código mostra: quem envia, quem recebe, quando espera. As regras que evitam quase todos os problemas cabem em poucas linhas — só o emissor fecha, e com vários emissores fecha quem sabe que todos terminaram; canal fechado está sempre pronto para leitura; canal nil nunca está; e um buffer de 1 é o que permite a uma goroutine entregar o resultado mesmo quando ninguém espera mais por ele.

Fontes e leituras recomendadas

Exercícios

Exercício 1

No exemplo de timeout do artigo, o canal resultado é criado com make(chan string, 1), com buffer de 1. Um colega "simplifica" para make(chan string), sem buffer, e o programa continua imprimindo "Timeout" corretamente. O que a simplificação quebrou, e por que o defeito não aparece num teste rápido?

Ver resposta

✓ Resposta: Quebrou o fim da goroutine operacaoLenta. Quando o select escolhe o timeout, a função segue adiante e ninguém mais lê resultado. Com buffer de 1, o envio que acontece dois segundos depois cabe no buffer, a goroutine termina, e o canal é recolhido pelo coletor. Sem buffer, o envio espera um receptor que nunca vem, e a goroutine fica presa para sempre, com tudo o que ela referencia. Num teste que roda uma vez e termina, o fim do processo apaga o problema; num servidor que faz isso a cada requisição lenta, as goroutines se acumulam, exatamente o vazamento medido no artigo anterior, Goroutines: concorrência leve e o modelo de execução do Go (mil chamadas, mil goroutines presas). O buffer de 1 é a forma idiomática de dizer "o resultado pode ser entregue mesmo que ninguém esteja mais esperando". A alternativa, quando a operação pode e deve ser interrompida, é passar um context para ela e cancelá-lo no timeout, o que também libera o trabalho em andamento e não só o envio.

Exercício 2

Um agregador lê de dois canais, a e b, num select dentro de um laço, até os dois serem fechados. Quando a fecha antes de b, o laço passa a girar sem parar, consumindo 100% de CPU. Por que isso acontece, e qual é o truque idiomático para "desligar" um case do select?

Ver resposta

✓ Resposta: Um canal fechado está sempre pronto para leitura: cada recebimento devolve na hora o valor zero e false (num teste, v, ok := <-c num canal fechado deu 0 false). Depois que a fecha, o case dele vence o select em quase toda volta, e o laço gira em falso lendo zeros. O truque é atribuir nil à variável do canal assim que ele fechar, dentro do próprio case: if !ok { a = nil; continue }. Operações num canal nil bloqueiam para sempre, e por isso o select nunca escolhe um case com canal nil, que fica efetivamente desligado. A condição do laço vira for a != nil || b != nil, e ele termina quando os dois estiverem desligados. Num teste com esse padrão, o laço desligou o case de a ao fechar e encerrou normalmente. É um dos poucos usos legítimos de canal nil; em quase todos os outros lugares, ele é defeito, porque um envio ou recebimento nele trava a goroutine sem erro nenhum.

Exercício 3

Três workers enviam resultados para o mesmo canal, e cada um, ao terminar, chama close(resultados) "para avisar que acabou". O programa cai de forma intermitente. Quais são as mensagens de pânico possíveis, e como o artigo resolve a questão de quem fecha o canal?

Ver resposta

✓ Resposta: São duas, dependendo de quem termina quando. Se um worker fecha o canal enquanto outro ainda vai enviar, o envio seguinte dá send on closed channel. Se dois workers terminam e ambos fecham, o segundo close dá close of closed channel. As duas foram reproduzidas num teste, e ambas são pânicos que derrubam o programa. A intermitência vem da ordem em que as goroutines terminam, que muda a cada execução. A regra do artigo, a de que só o emissor fecha, precisa de um complemento quando há vários emissores: fecha quem sabe que todos terminaram. É o que o exemplo de fan-out faz: cada worker só chama wg.Done(), e uma goroutine separada faz wg.Wait() e, em seguida, close(resultados), uma vez só. Fechar um canal nil também é pânico (close of nil channel), mais um motivo para concentrar o fechamento num único ponto do código.

Exercício 4

Um laço de consumo faz select entre uma mensagem e time.After(30 * time.Second), criando um timer novo a cada volta, milhares de vezes por segundo. Um guia antigo diz que isso vaza memória até o timer disparar. No Go 1.27, o problema continua? O que mudou e do que a mudança depende?

Ver resposta

✓ Resposta: O guia descrevia o comportamento anterior ao Go 1.23. Até ali, um timer criado por time.After ficava registrado no runtime até disparar, mesmo que ninguém mais tivesse referência ao canal dele. Num laço rápido com timeout de 30 segundos, isso significava centenas de milhares de timers vivos ao mesmo tempo, e o conselho era trocar time.After por um time.NewTimer com Stop e reutilizá-lo. O Go 1.23 mudou a implementação: timers e tickers sem referência passaram a poder ser recolhidos pelo coletor imediatamente, mesmo sem Stop, e o canal do timer deixou de ter buffer, o que também eliminou a necessidade de esvaziá-lo antes de um Reset. Como toda mudança de comportamento, essa depende da diretiva go do go.mod: só vale para módulos que declaram go 1.23 ou mais. Num projeto com diretiva antiga, o conselho do guia continua valendo. Em código novo, o time.After no laço é aceitável, embora um único timer reutilizado ainda economize alocações num caminho muito quente.

Exercício 5

O pipeline do artigo tem três estágios, cada um com um canal sem buffer. Um desenvolvedor mede a vazão e propõe pôr buffer de 1000 em todos os canais "para acelerar". Explique o que o buffer muda no comportamento do pipeline, quando ele ajuda de fato e o que se perde com um buffer grande.

Ver resposta

✓ Resposta: Sem buffer, cada estágio só entrega um valor quando o seguinte está pronto para recebê-lo; é a contrapressão natural que o artigo menciona, e ela mantém o pipeline inteiro no ritmo do estágio mais lento. O buffer não acelera o estágio mais lento, que continua limitando a vazão total. O que ele faz é absorver variações momentâneas: se um estágio às vezes demora e às vezes é rápido, um buffer pequeno evita que o anterior fique parado a cada pico. Nesse caso, um buffer da ordem do tamanho dos picos resolve. Com buffer de 1000, perde-se a contrapressão: o gerador pode ficar até mil itens à frente, esses itens ocupam memória, e se o programa for cancelado ou falhar, há mil itens produzidos e descartados. Também fica mais difícil perceber o gargalo, porque as filas escondem por um tempo o estágio lento. A forma de acelerar de verdade é identificar esse estágio, medindo ou com o profiler, e paralelizá-lo com fan-out, como no final do artigo.

Comentários

Mais em Golang

Estruturas de controle: if, for e switch
Estruturas de controle: if, for e switch

Controle de fluxo em Go: if com inicialização, as formas do for, incluindo o…

Introdução ao Go: história, filosofia e os criadores da linguagem
Introdução ao Go: história, filosofia e os criadores da linguagem

A origem do Go: o build de 45 minutos que motivou Griesemer, Pike e Thompson…

Instalação, configuração do ambiente e o comando `go`
Instalação, configuração do ambiente e o comando `go`

Instalação do Go no Linux, macOS e Windows, o PATH que também precisa incluir…