O artigo Channels: comunicação entre goroutines introduziu o select como mecanismo para aguardar múltiplos channels. Este artigo aprofunda esse recurso e explora os padrões que emergem dele: timeouts precisos, cancelamento gracioso de operações e controle de fluxo em sistemas concorrentes.
Esses padrões são o núcleo de como serviços Go reais lidam com latência, falhas e encerramento controlado. Dominá-los é o que separa código Go funcional de código Go robusto.
O select em profundidade
O select bloqueia até que um de seus cases esteja pronto. Se múltiplos estiverem prontos simultaneamente, Go escolhe um aleatoriamente — uma garantia de imparcialidade que evita starvation:
package main
import "fmt"
func main() {
ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
ch1 <- "um"
ch2 <- "dois"
// Ambos prontos — Go escolhe aleatoriamente
for i := 0; i < 4; i++ {
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
ch1 <- "um"
case msg := <-ch2:
fmt.Println("ch2:", msg)
ch2 <- "dois"
}
}
}
Executar esse programa múltiplas vezes produzirá ordens diferentes — a aleatoriedade é intencional e documentada na especificação da linguagem.
Select em loop: polling e event loops
O select dentro de um for cria um event loop — a estrutura central de muitos servidores, workers e agentes:
package main
import (
"fmt"
"time"
)
type Evento struct {
Tipo string
Payload string
}
func processarEventos(
eventos <-chan Evento,
erros <-chan error,
done <-chan struct{},
) {
for {
select {
case evento, ok := <-eventos:
if !ok {
fmt.Println("channel de eventos fechado")
return
}
fmt.Printf("evento [%s]: %s\n", evento.Tipo, evento.Payload)
case err, ok := <-erros:
if !ok {
fmt.Println("channel de erros fechado")
return
}
fmt.Printf("erro recebido: %v\n", err)
case <-done:
fmt.Println("encerramento solicitado")
return
}
}
}
func main() {
eventos := make(chan Evento, 5)
erros := make(chan error, 5)
done := make(chan struct{})
go processarEventos(eventos, erros, done)
eventos <- Evento{"compra", "pedido #1001"}
eventos <- Evento{"login", "usuário ana@exemplo.com"}
time.Sleep(50 * time.Millisecond)
close(done)
time.Sleep(50 * time.Millisecond)
}
Timeouts com time.After
time.After retorna um <-chan Time que recebe um valor após a duração especificada. Combinado com select, implementa timeouts de forma expressiva:
package main
import (
"fmt"
"time"
)
func consultarBancoDados(query string) <-chan string {
resultado := make(chan string, 1)
go func() {
// Simula latência variável
time.Sleep(300 * time.Millisecond)
resultado <- fmt.Sprintf("dados para: %s", query)
}()
return resultado
}
func executarComTimeout(query string, limite time.Duration) (string, error) {
resultado := consultarBancoDados(query)
select {
case dados := <-resultado:
return dados, nil
case <-time.After(limite):
return "", fmt.Errorf("timeout após %v: query=%s", limite, query)
}
}
func main() {
// Timeout generoso — deve ter sucesso
dados, err := executarComTimeout("SELECT * FROM usuarios", 500*time.Millisecond)
if err != nil {
fmt.Println("Erro:", err)
} else {
fmt.Println("Sucesso:", dados)
}
// Timeout apertado — deve falhar
dados, err = executarComTimeout("SELECT * FROM pedidos", 100*time.Millisecond)
if err != nil {
fmt.Println("Erro:", err)
} else {
fmt.Println("Sucesso:", dados)
}
}
O problema com time.After em loops
time.After cria um novo timer a cada chamada. Até o Go 1.22, timers antigos não eram coletados pelo GC até dispararem, o que acumulava memória em loops de alta frequência. Desde o Go 1.23 — em módulos cujo go.mod declara go 1.23 ou mais —, timers sem referência são coletados na hora, mesmo sem Stop. Reaproveitar um único timer continua economizando alocações num caminho quente, e fica mais simples: o canal do timer deixou de ter buffer, e Reset já não exige esvaziá-lo antes:
package main
import (
"fmt"
"time"
)
func processarComTimeout(ch <-chan int, timeout time.Duration) {
timer := time.NewTimer(timeout)
defer timer.Stop() // libera o timer se a função retornar antes
for {
// Resetar o timer a cada iteração — desde o Go 1.23 basta o Reset;
// em módulos com diretiva mais antiga, era preciso Stop e esvaziar timer.C antes
timer.Reset(timeout)
select {
case v, ok := <-ch:
if !ok {
fmt.Println("channel fechado")
return
}
fmt.Println("processado:", v)
case <-timer.C:
fmt.Println("timeout — nenhum dado recebido")
return
}
}
}
func main() {
ch := make(chan int)
go func() {
for i := 1; i <= 3; i++ {
time.Sleep(100 * time.Millisecond)
ch <- i
}
close(ch)
}()
processarComTimeout(ch, 200*time.Millisecond)
}
Ticker: ações periódicas
time.Ticker dispara em intervalos regulares — útil para polling, heartbeats e tarefas agendadas:
package main
import (
"fmt"
"time"
)
func monitorar(done <-chan struct{}) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case t := <-ticker.C:
fmt.Printf("verificação em %s\n", t.Format("15:04:05.000"))
case <-done:
fmt.Println("monitoramento encerrado")
return
}
}
}
func main() {
done := make(chan struct{})
go monitorar(done)
time.Sleep(2 * time.Second)
close(done)
time.Sleep(100 * time.Millisecond)
}
Cancelamento com context
O pacote context é a solução idiomática e recomendada para cancelamento em Go. Ele propaga cancelamento, deadlines e valores pela árvore de chamadas de função — incluindo através de limites de goroutines, chamadas HTTP e consultas a banco de dados.
package main
import (
"context"
"fmt"
"time"
)
func operacaoLonga(ctx context.Context, id int) error {
select {
case <-time.After(500 * time.Millisecond):
fmt.Printf("operação %d concluída\n", id)
return nil
case <-ctx.Done():
fmt.Printf("operação %d cancelada: %v\n", id, ctx.Err())
return ctx.Err()
}
}
func main() {
// Context com cancelamento manual
ctx, cancelar := context.WithCancel(context.Background())
go func() {
time.Sleep(200 * time.Millisecond)
cancelar() // cancela após 200ms
}()
if err := operacaoLonga(ctx, 1); err != nil {
fmt.Println("Erro:", err) // context canceled
}
// Context com deadline absoluto
prazo := time.Now().Add(300 * time.Millisecond)
ctxDeadline, cancelDeadline := context.WithDeadline(context.Background(), prazo)
defer cancelDeadline()
if err := operacaoLonga(ctxDeadline, 2); err != nil {
fmt.Println("Erro:", err) // context deadline exceeded
}
// Context com timeout relativo — mais comum que WithDeadline
ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancelTimeout()
if err := operacaoLonga(ctxTimeout, 3); err != nil {
fmt.Println("Erro:", err) // context deadline exceeded
}
}
Sempre chame a função de cancelamento retornada por WithCancel, WithDeadline e WithTimeout — mesmo que o contexto já tenha sido cancelado. Isso libera recursos internos. O defer cancelar() logo após a criação do contexto é o padrão correto.
Propagando context pela árvore de chamadas
O context forma uma árvore: cancelar um contexto pai cancela todos os filhos automaticamente. Isso permite cancelamento em cascata sem comunicação explícita entre goroutines:
package main
import (
"context"
"fmt"
"time"
)
func buscarUsuario(ctx context.Context, id int) (string, error) {
select {
case <-time.After(100 * time.Millisecond):
return fmt.Sprintf("usuario_%d", id), nil
case <-ctx.Done():
return "", fmt.Errorf("buscarUsuario: %w", ctx.Err())
}
}
func buscarPedidos(ctx context.Context, usuarioID int) ([]string, error) {
select {
case <-time.After(200 * time.Millisecond):
return []string{"pedido_1", "pedido_2"}, nil
case <-ctx.Done():
return nil, fmt.Errorf("buscarPedidos: %w", ctx.Err())
}
}
func carregarPerfil(ctx context.Context, userID int) error {
// Cada operação ganha o seu próprio prazo, sempre dentro do prazo global do pai
ctxUsuario, cancelarUsuario := context.WithTimeout(ctx, 150*time.Millisecond)
defer cancelarUsuario()
usuario, err := buscarUsuario(ctxUsuario, userID)
if err != nil {
return err
}
ctxPedidos, cancelarPedidos := context.WithTimeout(ctx, 250*time.Millisecond)
defer cancelarPedidos()
pedidos, err := buscarPedidos(ctxPedidos, userID)
if err != nil {
return err
}
fmt.Printf("Perfil carregado: %s com %d pedidos\n", usuario, len(pedidos))
return nil
}
func main() {
// Contexto raiz com timeout global de 500ms
ctx, cancelar := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancelar()
if err := carregarPerfil(ctx, 42); err != nil {
fmt.Println("Erro:", err)
}
}
Verificando cancelamento sem bloquear
Em loops de processamento, verificar o context sem bloquear usa select com default:
func processarItens(ctx context.Context, itens []string) error {
for i, item := range itens {
// Verifica cancelamento antes de cada item
select {
case <-ctx.Done():
return fmt.Errorf("processamento cancelado após %d itens: %w",
i, ctx.Err())
default:
// continua
}
// Processamento do item
fmt.Println("processando:", item)
time.Sleep(50 * time.Millisecond)
}
return nil
}
Encerramento gracioso de servidores HTTP
Um dos casos de uso mais importantes de cancelamento em produção é o graceful shutdown — encerrar um servidor HTTP sem interromper requisições em andamento:
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond) // simula processamento
fmt.Fprintln(w, "OK")
})
servidor := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Canal para capturar sinais do sistema operacional
sinal := make(chan os.Signal, 1)
signal.Notify(sinal, syscall.SIGINT, syscall.SIGTERM)
// Inicia servidor em goroutine separada
go func() {
fmt.Println("Servidor iniciado em :8080")
if err := servidor.ListenAndServe(); err != http.ErrServerClosed {
fmt.Println("Erro no servidor:", err)
}
}()
// Aguarda sinal de encerramento
<-sinal
fmt.Println("\nSinal recebido — encerrando...")
// Contexto com 30 segundos para finalizar requisições em andamento
ctx, cancelar := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelar()
if err := servidor.Shutdown(ctx); err != nil {
fmt.Println("Erro no shutdown:", err)
} else {
fmt.Println("Servidor encerrado com sucesso")
}
}
Padrão: retry com context e backoff
Combinando context e select, é possível implementar retry com backoff exponencial e cancelamento:
package main
import (
"context"
"fmt"
"time"
)
func operacaoComRetry(ctx context.Context, maxTentativas int) error {
espera := 100 * time.Millisecond
for tentativa := 1; tentativa <= maxTentativas; tentativa++ {
// Verifica cancelamento antes de tentar
select {
case <-ctx.Done():
return fmt.Errorf("cancelado antes da tentativa %d: %w",
tentativa, ctx.Err())
default:
}
err := tentarOperacao()
if err == nil {
fmt.Printf("sucesso na tentativa %d\n", tentativa)
return nil
}
fmt.Printf("tentativa %d falhou: %v\n", tentativa, err)
if tentativa == maxTentativas {
return fmt.Errorf("todas as %d tentativas falharam", maxTentativas)
}
// Aguarda com cancelamento
select {
case <-ctx.Done():
return fmt.Errorf("cancelado durante espera: %w", ctx.Err())
case <-time.After(espera):
espera *= 2 // backoff exponencial
}
}
return nil
}
var contador int
func tentarOperacao() error {
contador++
if contador < 3 {
return fmt.Errorf("serviço indisponível")
}
return nil
}
func main() {
ctx, cancelar := context.WithTimeout(context.Background(), 2*time.Second)
defer cancelar()
if err := operacaoComRetry(ctx, 5); err != nil {
fmt.Println("Erro final:", err)
}
}
O select é o ponto em que uma goroutine decide o que fazer quando mais de uma coisa pode acontecer: chegou um dado, venceu um prazo, pediram para parar. O context leva essa última possibilidade por toda a árvore de chamadas, e dois cuidados evitam a maior parte dos defeitos — chamar sempre o cancel, de preferência com defer na linha seguinte, e lembrar que prazo de contexto é um instante no relógio, não uma cota por chamada. Com esses dois hábitos, timeouts, cancelamento em cascata e encerramento gracioso deixam de ser casos especiais.
Fontes e leituras recomendadas
-
Documentação do pacote context — Referência completa de WithCancel, WithTimeout e WithDeadline. https://pkg.go.dev/context
-
Go Blog: Go Concurrency Patterns: Context — Artigo oficial sobre o pacote context. https://go.dev/blog/context
-
Go by Example: Timeouts — Exemplos práticos de timeout com select. https://gobyexample.com/timeouts
-
Go by Example: Tickers — Exemplos de time.Ticker para ações periódicas. https://gobyexample.com/tickers
-
Go by Example: Context — Exemplos de cancelamento com context. https://gobyexample.com/context
-
Documentação net/http — Server.Shutdown — Referência do encerramento gracioso de servidores. https://pkg.go.dev/net/http#Server.Shutdown
Exercícios
Exercício 1
Numa versão anterior deste artigo, a função carregarPerfil criava um único contexto de 150 ms e o usava nas duas buscas, a de usuário (100 ms) e a de pedidos (200 ms), com o comentário "timeout mais restrito para cada operação". O que o programa imprimia, e qual é o erro de raciocínio por trás desse desenho?
Ver resposta
✓ Resposta: Imprimia sempre Erro: buscarPedidos: context deadline exceeded, embora o texto apresentasse o exemplo como um perfil carregado com sucesso. O erro de raciocínio é tratar um prazo de contexto como se fosse um limite por chamada. O prazo é um instante absoluto: WithTimeout(ctx, 150ms) significa "cancelado daqui a 150 ms", e esse relógio corre para tudo o que usa o contexto, na sequência. A busca de usuário consumiu 100 ms, sobraram 50 ms para uma busca que leva 200, e ela foi cancelada. A versão corrigida dá a cada operação o seu próprio filho, ctxUsuario com 150 ms e ctxPedidos com 250 ms, e o programa passa a imprimir Perfil carregado: usuario_42 com 2 pedidos. Os dois continuam limitados pelo prazo global de 500 ms do pai: um filho nunca vive mais que o pai. Ao desenhar prazos, some os tempos esperados do caminho inteiro e deixe o prazo global cobrir essa soma com folga.
Exercício 2
Para "simplificar", alguém escreve ctx, _ := context.WithTimeout(context.Background(), time.Second), descartando a função de cancelamento, com o argumento de que o timeout vai cancelar o contexto de qualquer jeito. O que o go vet diz, e por que o argumento está incompleto?
Ver resposta
✓ Resposta: O go vet acusa: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak. O argumento vale só para o caso em que a operação dura até o fim do prazo. Quando ela termina antes, que é o caso comum, o contexto continua registrado no pai, com o timer pendente, até o prazo expirar. Num servidor com timeout de 30 segundos por requisição e respostas de 20 ms, isso mantém vivos os contextos de 30 segundos de tráfego, em vez de só os das requisições em andamento. Com um pai de vida longa, como um contexto de servidor com WithCancel sem prazo, o filho não cancelado fica pendurado até o pai morrer. Chamar cancel libera tudo na hora e é seguro em qualquer situação: chamá-lo depois do timeout ou mais de uma vez não faz mal. Por isso o padrão do artigo, defer cancel() na linha seguinte à criação, é regra, e o vet o impõe.
Exercício 3
Um serviço de checkout chama três APIs com o mesmo contexto de requisição, que tem timeout. Quando algo estoura, o log mostra apenas context deadline exceeded, sem dizer qual prazo venceu nem por quê. Como distinguir cancelamento de timeout, e como fazer o erro carregar a causa?
Ver resposta
✓ Resposta: ctx.Err() só devolve dois valores: context.Canceled, quando alguém chamou cancel (inclusive o cliente que desistiu da requisição), e context.DeadlineExceeded, quando o prazo venceu. Mesmo embrulhados com %w, eles são reconhecidos com errors.Is: num teste, fmt.Errorf("cotação: %w", ctx.Err()) deu true para DeadlineExceeded e false para Canceled. Para saber o motivo, o Go 1.20 trouxe context.Cause, e o 1.21, as variantes WithTimeoutCause e WithDeadlineCause, que recebem um erro próprio. Com WithTimeoutCause(ctx, 10*time.Millisecond, ErrLento), o ctx.Err() continua sendo context deadline exceeded, para quem só testa o tipo, e context.Cause(ctx) devolve serviço de frete lento. Na prática, dê a cada chamada externa um prazo filho com causa nomeada e registre context.Cause no log: o incidente passa a dizer qual dependência falhou, e não só que algo demorou.
Exercício 4
O exemplo de encerramento gracioso usa signal.Notify num canal e depois servidor.Shutdown(ctx). Um colega observa que o ListenAndServe, na goroutine, retorna http.ErrServerClosed assim que o Shutdown começa, e propõe encerrar o main nesse momento. O que se perderia, e como o signal.NotifyContext simplifica o código?
Ver resposta
✓ Resposta: Perder-se-iam justamente as requisições em andamento, que são o motivo de existir o encerramento gracioso. O Shutdown fecha os listeners na hora, e é isso que faz o ListenAndServe retornar ErrServerClosed imediatamente, mas ele continua trabalhando: espera as conexões ativas terminarem suas requisições, até o prazo do contexto. Se o main terminar quando o ListenAndServe retorna, o processo morre no meio dessas requisições. Quem sinaliza o fim de verdade é o retorno do Shutdown, e é por ele que o main deve esperar, como o exemplo faz. O signal.NotifyContext, do Go 1.16, troca o canal de sinais por um contexto que é cancelado quando o sinal chega: ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM), e depois <-ctx.Done(). O mesmo contexto pode ser repassado a workers e laços de fundo, que param junto, e o stop() restaura o comportamento padrão, fazendo um segundo Ctrl+C derrubar o processo se o encerramento travar.
Exercício 5
O retry do artigo espera 100, 200, 400 e 800 ms entre tentativas. Numa queda do serviço de pagamentos, mil instâncias usam o mesmo código e, quando o serviço volta, ele cai de novo. Explique o fenômeno e o ajuste que falta no backoff.
Ver resposta
✓ Resposta: É o efeito manada. Todas as instâncias falharam praticamente no mesmo instante, quando o serviço caiu, e todas usam exatamente a mesma sequência de esperas. Por isso elas voltam a tentar em ondas sincronizadas, mil requisições no mesmo milissegundo, e o serviço, que mal se recuperou, recebe um pico que o derruba de novo. O backoff exponencial espaça as ondas, mas não as desfaz. O ajuste que falta é o jitter, uma aleatoriedade na espera: em vez de dormir exatamente espera, cada instância dorme um valor sorteado entre zero e espera (o chamado "full jitter"), por exemplo com time.Duration(rand.Int64N(int64(espera))) usando o math/rand/v2. As tentativas se espalham no tempo e a carga chega ao serviço como uma rampa, não como um degrau. Dois cuidados completam o padrão: um teto para a espera, para que ela não dobre indefinidamente, e só repetir o que é seguro repetir, como erros temporários e operações idempotentes. Um pagamento que falhou por tempo esgotado pode ter sido processado.