Select, timeouts e cancelamento de operações

Select, timeouts e cancelamento de operações

Select, timeouts e cancelamento em Go: select como event loop, time.After e os timers do Go 1.23, tickers, context com cancelamento, prazo e timeout, o prazo que é um instante e não uma cota, context.Cause, encerramento gracioso com NotifyContext e retry com backoff e jitter.
Golang

• • 15 min de leitura

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

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.

Comentários

Mais em Golang

Variáveis de ambiente e configuração com Viper
Variáveis de ambiente e configuração com Viper

Configuração em Go com os.LookupEnv, godotenv e Viper: arquivo YAML, variáveis…

Ponteiros: conceito, uso e quando evitar
Ponteiros: conceito, uso e quando evitar

Ponteiros em Go: os operadores & e *, o nil que entra em pânico e o que não…

sync.WaitGroup e sync.Mutex: coordenando goroutines
sync.WaitGroup e sync.Mutex: coordenando goroutines

WaitGroup e Mutex em Go: Add antes de go e o wg.Go do Go 1.25, Mutex e RWMutex…