Os artigos anteriores (Select, timeouts e cancelamento de operações, sync.WaitGroup e sync.Mutex: coordenando goroutines, O pacote context: propagação de cancelamento e prazos) apresentaram as ferramentas: goroutines, channels, select, WaitGroup, Mutex e context. Este artigo mostra como combiná-las em padrões arquiteturais — estruturas reutilizáveis que resolvem classes inteiras de problemas concorrentes de forma elegante e previsível.
Padrões não são receitas rígidas. São vocabulário. Quando um engenheiro diz "vamos usar um pipeline com fan-out no estágio de processamento", toda a equipe entende imediatamente a estrutura, as garantias e as trocas envolvidas. Dominar esses padrões é comunicar intenção com precisão.
Pipeline revisitado: com cancelamento
O artigo Channels: comunicação entre goroutines introduziu pipelines. Aqui o padrão é refinado com cancelamento via context — essencial para pipelines em produção:
package main
import (
"context"
"fmt"
"time"
)
// Estágio gerador — produz valores até o context ser cancelado
func gerar(ctx context.Context, valores ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, v := range valores {
select {
case out <- v:
case <-ctx.Done():
return
}
}
}()
return out
}
// Estágio transformador — aplica uma função a cada valor
func transformar(ctx context.Context, in <-chan int, fn func(int) int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
select {
case out <- fn(v):
case <-ctx.Done():
return
}
}
}()
return out
}
// Estágio filtro — passa apenas valores que satisfazem o predicado
func filtrar(ctx context.Context, in <-chan int, predicado func(int) bool) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
if predicado(v) {
select {
case out <- v:
case <-ctx.Done():
return
}
}
}
}()
return out
}
func main() {
ctx, cancelar := context.WithTimeout(context.Background(), 2*time.Second)
defer cancelar()
// Pipeline: gerar → dobrar → filtrar maiores que 10 → imprimir
numeros := gerar(ctx, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
dobrados := transformar(ctx, numeros, func(n int) int { return n * 2 })
maioresQue10 := filtrar(ctx, dobrados, func(n int) bool { return n > 10 })
for v := range maioresQue10 {
fmt.Println(v) // 12, 14, 16, 18, 20
}
}
Cada estágio é uma goroutine independente. O context garante que, ao cancelar, todos os estágios encerram graciosamente sem goroutine leaks.
Fan-out: distribuindo trabalho
Fan-out distribui os itens de um único channel de entrada para múltiplos workers, processando em paralelo:
package main
import (
"context"
"fmt"
"sync"
"time"
)
type Tarefa struct {
ID int
Dado string
}
type Resultado struct {
TarefaID int
Saida string
Duracao time.Duration
}
func worker(
ctx context.Context,
id int,
tarefas <-chan Tarefa,
resultados chan<- Resultado,
wg *sync.WaitGroup,
) {
defer wg.Done()
for {
select {
case tarefa, ok := <-tarefas:
if !ok {
return
}
inicio := time.Now()
// Simula processamento com duração variável
select {
case <-time.After(time.Duration(50+tarefa.ID*10) * time.Millisecond):
case <-ctx.Done():
return
}
resultados <- Resultado{
TarefaID: tarefa.ID,
Saida: fmt.Sprintf("worker%d processou: %s", id, tarefa.Dado),
Duracao: time.Since(inicio),
}
case <-ctx.Done():
return
}
}
}
func fanOut(
ctx context.Context,
entrada <-chan Tarefa,
numWorkers int,
) <-chan Resultado {
resultados := make(chan Resultado, numWorkers*2)
var wg sync.WaitGroup
for i := 1; i <= numWorkers; i++ {
wg.Add(1)
go worker(ctx, i, entrada, resultados, &wg)
}
// Fecha resultados quando todos os workers terminarem
go func() {
wg.Wait()
close(resultados)
}()
return resultados
}
func main() {
ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelar()
// Alimenta o channel de tarefas
tarefas := make(chan Tarefa, 10)
go func() {
defer close(tarefas)
for i := 1; i <= 10; i++ {
select {
case tarefas <- Tarefa{ID: i, Dado: fmt.Sprintf("item_%d", i)}:
case <-ctx.Done():
return
}
}
}()
// 3 workers processam as 10 tarefas em paralelo
resultados := fanOut(ctx, tarefas, 3)
inicio := time.Now()
for r := range resultados {
fmt.Printf("[tarefa:%02d] %s (%.0fms)\n",
r.TarefaID, r.Saida, r.Duracao.Seconds()*1000)
}
fmt.Printf("\ntotal: %v\n", time.Since(inicio))
}
Fan-in: consolidando resultados
Fan-in faz o oposto — combina múltiplos channels de entrada em um único channel de saída:
package main
import (
"context"
"fmt"
"sync"
"time"
)
// fanIn genérico — combina N channels em 1
func fanIn[T any](ctx context.Context, channels ...<-chan T) <-chan T {
merged := make(chan T, len(channels))
var wg sync.WaitGroup
enviar := func(ch <-chan T) {
defer wg.Done()
for {
select {
case v, ok := <-ch:
if !ok {
return
}
select {
case merged <- v:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}
wg.Add(len(channels))
for _, ch := range channels {
go enviar(ch)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}
// Fonte de dados simulada
func fonte(ctx context.Context, nome string, intervalo time.Duration) <-chan string {
out := make(chan string)
go func() {
defer close(out)
ticker := time.NewTicker(intervalo)
defer ticker.Stop()
contador := 0
for {
select {
case <-ticker.C:
contador++
select {
case out <- fmt.Sprintf("[%s] evento_%d", nome, contador):
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
return out
}
func main() {
ctx, cancelar := context.WithTimeout(context.Background(), 1*time.Second)
defer cancelar()
// Três fontes com cadências diferentes
ch1 := fonte(ctx, "sensores", 200*time.Millisecond)
ch2 := fonte(ctx, "logs", 150*time.Millisecond)
ch3 := fonte(ctx, "metricas", 300*time.Millisecond)
// Fan-in consolida tudo em um stream único
todos := fanIn(ctx, ch1, ch2, ch3)
for evento := range todos {
fmt.Println(evento)
}
}
A função fanIn usa generics — disponíveis desde Go 1.18 — tornando-a reutilizável para qualquer tipo sem duplicação.
Fan-out seguido de fan-in: o padrão completo
A combinação fan-out → processamento → fan-in é um dos padrões mais poderosos para processamento paralelo com ordem preservada opcional:
package main
import (
"context"
"fmt"
"sort"
"sync"
"time"
)
type Item struct {
ID int
Valor string
}
type ItemProcessado struct {
ID int
Original string
Resultado string
}
func processarItem(ctx context.Context, item Item) (ItemProcessado, error) {
select {
case <-time.After(50 * time.Millisecond):
return ItemProcessado{
ID: item.ID,
Original: item.Valor,
Resultado: fmt.Sprintf("PROCESSADO(%s)", item.Valor),
}, nil
case <-ctx.Done():
return ItemProcessado{}, ctx.Err()
}
}
func processar(
ctx context.Context,
itens <-chan Item,
erros chan<- error,
) <-chan ItemProcessado {
out := make(chan ItemProcessado)
go func() {
defer close(out)
for item := range itens {
resultado, err := processarItem(ctx, item)
if err != nil {
select {
case erros <- err:
case <-ctx.Done():
}
return
}
select {
case out <- resultado:
case <-ctx.Done():
return
}
}
}()
return out
}
func processarParalelo(
ctx context.Context,
itens []Item,
concorrencia int,
) ([]ItemProcessado, error) {
entrada := make(chan Item, len(itens))
for _, item := range itens {
entrada <- item
}
close(entrada)
erros := make(chan error, concorrencia)
// Fan-out: N goroutines de processamento
channels := make([]<-chan ItemProcessado, concorrencia)
for i := 0; i < concorrencia; i++ {
channels[i] = processar(ctx, entrada, erros)
}
// Fan-in: consolida resultados
var wg sync.WaitGroup
merged := make(chan ItemProcessado, len(itens))
for _, ch := range channels {
wg.Add(1)
ch := ch
go func() {
defer wg.Done()
for v := range ch {
merged <- v
}
}()
}
go func() {
wg.Wait()
close(merged)
close(erros)
}()
// Coleta resultados
var resultados []ItemProcessado
for r := range merged {
resultados = append(resultados, r)
}
// Verifica erros
if err, ok := <-erros; ok {
return nil, err
}
// Reordena por ID para resultado determinístico
sort.Slice(resultados, func(i, j int) bool {
return resultados[i].ID < resultados[j].ID
})
return resultados, nil
}
func main() {
ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelar()
itens := make([]Item, 12)
for i := range itens {
itens[i] = Item{ID: i + 1, Valor: fmt.Sprintf("item_%02d", i+1)}
}
inicio := time.Now()
resultados, err := processarParalelo(ctx, itens, 4)
if err != nil {
fmt.Println("Erro:", err)
return
}
for _, r := range resultados {
fmt.Printf("[%02d] %s → %s\n", r.ID, r.Original, r.Resultado)
}
fmt.Printf("\n12 itens em %v (4 workers)\n", time.Since(inicio))
}
Esse encadeamento — distribuir, recolher, parar tudo no primeiro erro — é tão comum que ganhou uma biblioteca semioficial, o golang.org/x/sync/errgroup. Com g, ctx := errgroup.WithContext(ctx), cada tarefa é disparada com g.Go(func() error { ... }), g.SetLimit(4) limita quantas rodam ao mesmo tempo, o primeiro erro cancela o ctx das demais, e g.Wait() devolve esse erro. Gravações em posições fixas de uma fatia pré-alocada, uma por tarefa, dispensam o canal de saída e a reordenação. Vale conhecer a versão manual, acima, para entender o que a biblioteca faz.
Or-done: lendo de channel com cancelamento
Um utilitário elegante que encapsula a leitura de um channel com suporte a cancelamento via context:
func orDone[T any](ctx context.Context, ch <-chan T) <-chan T {
out := make(chan T)
go func() {
defer close(out)
for {
select {
case v, ok := <-ch:
if !ok {
return
}
select {
case out <- v:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
return out
}
// Uso
func main() {
ctx, cancelar := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancelar()
ch := make(chan int)
go func() {
for i := 0; ; i++ {
ch <- i
time.Sleep(100 * time.Millisecond)
}
}()
for v := range orDone(ctx, ch) {
fmt.Println(v)
}
fmt.Println("encerrado por timeout")
}
Batch processing: processamento em lotes
Processar itens individualmente pode ser ineficiente quando o custo por operação é alto — por exemplo, inserções em banco de dados. O padrão de batch agrupa itens e os processa juntos:
package main
import (
"context"
"fmt"
"time"
)
func batcher[T any](
ctx context.Context,
entrada <-chan T,
tamanho int,
espera time.Duration,
) <-chan []T {
out := make(chan []T)
go func() {
defer close(out)
lote := make([]T, 0, tamanho)
timer := time.NewTimer(espera)
defer timer.Stop()
enviarLote := func() {
if len(lote) > 0 {
copia := make([]T, len(lote))
copy(copia, lote)
select {
case out <- copia:
case <-ctx.Done():
return
}
lote = lote[:0]
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(espera)
}
for {
select {
case item, ok := <-entrada:
if !ok {
enviarLote()
return
}
lote = append(lote, item)
if len(lote) >= tamanho {
enviarLote()
}
case <-timer.C:
enviarLote() // envia lote parcial por timeout
case <-ctx.Done():
return
}
}
}()
return out
}
func main() {
ctx, cancelar := context.WithTimeout(context.Background(), 3*time.Second)
defer cancelar()
entrada := make(chan int)
go func() {
defer close(entrada)
for i := 1; i <= 17; i++ {
select {
case entrada <- i:
time.Sleep(80 * time.Millisecond)
case <-ctx.Done():
return
}
}
}()
// Lotes de até 5 itens, ou a cada 300ms
lotes := batcher(ctx, entrada, 5, 300*time.Millisecond)
for lote := range lotes {
fmt.Printf("lote de %d: %v\n", len(lote), lote)
}
}
Semáforo: limitando concorrência
Quando é necessário limitar o número de goroutines simultâneas — por exemplo, para não sobrecarregar uma API externa com chamadas paralelas — um channel com buffer funciona como semáforo:
package main
import (
"context"
"fmt"
"sync"
"time"
)
type Semaforo chan struct{}
func NovoSemaforo(limite int) Semaforo {
return make(Semaforo, limite)
}
func (s Semaforo) Adquirir(ctx context.Context) error {
select {
case s <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s Semaforo) Liberar() {
<-s
}
func main() {
ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelar()
sem := NovoSemaforo(3) // máximo 3 operações simultâneas
var wg sync.WaitGroup
for i := 1; i <= 10; i++ {
wg.Add(1)
i := i
go func() {
defer wg.Done()
if err := sem.Adquirir(ctx); err != nil {
fmt.Printf("tarefa %d cancelada\n", i)
return
}
defer sem.Liberar()
fmt.Printf("tarefa %d iniciada (ativas: %d)\n", i, len(sem))
time.Sleep(200 * time.Millisecond)
fmt.Printf("tarefa %d concluída\n", i)
}()
}
wg.Wait()
}
Para limites com pesos diferentes — uma tarefa grande que ocupa três vagas, outra pequena que ocupa uma —, o pacote golang.org/x/sync/semaphore oferece o semaphore.Weighted, com Acquire(ctx, n) e Release(n). Para o caso simples de limitar goroutines, o canal com buffer do exemplo, ou o SetLimit do errgroup, costumam bastar.
Pipelines, fan-out, fan-in, lotes e semáforos são combinações das mesmas poucas peças — goroutines, canais, select e context —, e é justamente por isso que viram vocabulário comum entre quem escreve Go. O que separa a versão de exemplo da versão de produção é quase sempre o mesmo detalhe: todo envio que pode ficar sem receptor precisa de uma saída pelo contexto, e quem desiste de ler precisa cancelar para avisar quem escreve. Com essa regra seguida em cada estágio, e o errgroup para o caso mais comum, os padrões param de vazar goroutines.
Fontes e leituras recomendadas
-
Go Blog: Go Concurrency Patterns: Pipelines and cancellation — Artigo oficial sobre pipelines com cancelamento. https://go.dev/blog/pipelines
-
Go Blog: Advanced Go Concurrency Patterns — Padrões avançados apresentados na GopherCon. https://go.dev/blog/advanced-go-concurrency-patterns
-
Concurrency in Go — Katherine Cox-Buday — Livro dedicado inteiramente a padrões de concorrência em Go. https://www.oreilly.com/library/view/concurrency-in-go/9781491941294/
-
Go by Example: Worker Pools — Exemplo prático de worker pool com channels. https://gobyexample.com/worker-pools
-
golang.org/x/sync — semaphore — Implementação oficial de semáforo ponderado. https://pkg.go.dev/golang.org/x/sync/semaphore
-
golang.org/x/sync — errgroup — WaitGroup com propagação de erros, essencial para pipelines. https://pkg.go.dev/golang.org/x/sync/errgroup
Exercícios
Exercício 1
No exemplo de or-done, o programa imprime "encerrado por timeout" e parece terminar bem. Um colega usa o mesmo padrão dentro de um servidor, com uma goroutine produtora por requisição, e o número de goroutines não para de crescer. O que o or-done protege, o que ele não protege, e como corrigir o produtor?
Ver resposta
✓ Resposta: O or-done protege quem lê: o laço for v := range orDone(ctx, ch) termina quando o contexto é cancelado, mesmo que ch nunca seja fechado. Ele não protege quem escreve. A goroutine produtora do exemplo faz ch <- i num canal sem buffer e, depois do timeout, ninguém mais lê esse canal; ela fica presa no envio para sempre. Num teste com o exemplo, 300 ms depois do timeout ainda havia 2 goroutines vivas: o main e o produtor. Num programa que termina logo em seguida, isso não aparece; num servidor, é uma goroutine perdida por requisição. A correção é o produtor também conhecer o contexto e enviar com select { case ch <- i: case <-ctx.Done(): return }, como fazem os estágios do pipeline no começo do artigo. A regra geral é que todo envio que pode ficar sem receptor precisa de uma saída pelo contexto. O or-done serve para quando você não controla o produtor, como um canal vindo de uma biblioteca, e mesmo aí a goroutine dele continua sendo responsabilidade de quem a criou.
Exercício 2
No batcher do artigo, a função enviarLote copia o lote para uma fatia nova antes de enviá-lo, e só depois faz lote = lote[:0]. Um revisor acha a cópia um desperdício e propõe enviar lote direto. O que o consumidor passaria a receber?
Ver resposta
✓ Resposta: Lotes corrompidos. lote[:0] zera o tamanho, mas mantém o mesmo array por baixo, e os próximos append escrevem por cima dos elementos que o consumidor recebeu. Uma fatia enviada por canal é só um cabeçalho apontando para esse array, então o consumidor e o batcher passam a compartilhar a memória. Num teste com lotes de 3 enviados sem cópia para um canal com buffer, os números de 1 a 6 chegaram como [4 5 6] e [4 5 6]: o primeiro lote foi sobrescrito pelo segundo antes de ser lido. Com canal sem buffer, o defeito depende do ritmo do consumidor, o que o torna ainda pior, porque aparece de forma intermitente. A cópia é o custo de transferir a posse do lote para o consumidor. Uma alternativa sem cópia é alocar uma fatia nova a cada lote, com lote = make([]T, 0, tamanho) depois de enviar o antigo; a regra que importa é que, depois de enviada, uma fatia não deve mais ser tocada por quem a enviou.
Exercício 3
No exemplo de fan-out, cada worker envia o resultado com resultados <- Resultado{...}, sem select. O consumidor é alterado para parar de ler e retornar assim que encontra o primeiro resultado com erro. Que problema isso cria, e quais são as duas formas de resolvê-lo?
Ver resposta
✓ Resposta: Os workers ficam presos. O canal resultados tem buffer de numWorkers*2; quando o consumidor para de ler, os próximos envios enchem o buffer e, a partir daí, cada worker bloqueia no envio seguinte. Como eles nunca terminam, o wg.Wait() que fecharia o canal também nunca retorna, e todo o conjunto de goroutines vaza. É o mesmo defeito do exercício 1, visto do lado dos workers. A primeira correção é o envio respeitar o contexto, select { case resultados <- r: case <-ctx.Done(): return }, com o consumidor cancelando o contexto antes de retornar; um defer cancelar() na função que consome resolve isso em todos os caminhos de saída. A segunda é usar o errgroup, que o artigo apresenta: o primeiro erro devolvido por uma tarefa cancela o contexto compartilhado, e as outras, se o respeitam, param sozinhas. Nas duas, a peça central é a mesma: quem desiste de ler precisa avisar quem está escrevendo.
Exercício 4
Um fan-out com 8 workers processa uma lista de pedidos e grava os resultados num canal, na ordem em que ficam prontos. O relatório final precisa sair na ordem original. O artigo reordena com sort.Slice pelo ID no final. Descreva duas alternativas que dispensam a ordenação e diga quando cada uma é melhor.
Ver resposta
✓ Resposta: A primeira é gravar por posição: pré-alocar resultados := make([]ItemProcessado, len(itens)) e fazer cada tarefa escrever em resultados[i], o índice do seu item. Cada goroutine escreve numa posição diferente, então não há corrida e nem precisa de mutex; depois do Wait, a fatia já está na ordem original, sem canal de saída e sem sort. É a melhor escolha quando todos os itens cabem na memória e o resultado só é usado no fim, o caso do relatório. A segunda é o reordenamento em fluxo: o consumidor guarda num map os resultados que chegam adiantados e vai liberando em sequência, emitindo o item n assim que ele e todos os anteriores tiverem chegado. É a escolha quando a entrada é grande ou contínua e a saída precisa começar antes de tudo terminar, como ao escrever um arquivo ou responder em streaming; o custo é a memória dos itens represados atrás de um item lento. A ordenação final do artigo funciona, mas custa um sort e exige guardar tudo, sem as vantagens de nenhuma das duas.
Exercício 5
Um serviço chama uma API parceira que aceita no máximo 10 requisições simultâneas por cliente. Para respeitar o limite, o time usa o semáforo do artigo com capacidade 10, e mesmo assim recebe erros 429 (Too Many Requests) em picos. O semáforo está errado? Que outra forma de limite pode estar em jogo?
Ver resposta
✓ Resposta: O semáforo provavelmente está certo, mas limita a coisa errada, ou só parte dela. Ele controla concorrência, quantas chamadas estão em andamento ao mesmo tempo, e muitas APIs limitam também, ou só, a taxa: quantas requisições por segundo. Com respostas rápidas, 10 vagas simultâneas podem significar centenas de requisições por segundo, e o limite de taxa estoura mesmo com o semáforo funcionando. Outra causa comum é haver várias instâncias do serviço: cada uma com o seu semáforo de 10 soma 30 ou 50 chamadas simultâneas do ponto de vista do parceiro, que vê um cliente só. Para taxa, a ferramenta é um limitador de tipo balde de fichas, como golang.org/x/time/rate, com limiter.Wait(ctx) antes de cada chamada. Para o limite global entre instâncias, o limite precisa ser dividido entre elas ou coordenado fora do processo. Em todos os casos, vale tratar o 429 de forma explícita: respeitar o cabeçalho Retry-After quando ele vier e aplicar o backoff com jitter visto no artigo sobre select.