O pacote context: propagação de cancelamento e prazos

O pacote context: propagação de cancelamento e prazos

O pacote context em Go: Background e TODO, WithCancel, WithTimeout e WithDeadline, a árvore em que o filho nunca vive mais que o pai, WithValue com chaves de tipo próprio, context em handlers e no banco, WithoutCancel e AfterFunc do Go 1.21 e por que nunca guardar contexto numa struct.
Golang

• • 17 min de leitura

Imagine um servidor HTTP recebendo uma requisição que desencadeia três operações: uma consulta ao banco de dados, uma chamada a uma API externa e uma busca em cache. Cada operação acontece em sua própria goroutine. No meio do caminho, o cliente cancela a requisição — fecha a conexão ou atinge o timeout do lado dele.

Sem um mecanismo de propagação, as três operações continuariam executando, consumindo recursos, fazendo queries e chamadas de rede para produzir um resultado que nunca será usado. Em um servidor sob carga, esse desperdício acumula e degrada toda a aplicação.

O pacote context resolve exatamente esse problema. Ele fornece um objeto que carrega um sinal de cancelamento, um prazo e valores arbitrários, e pode ser propagado por toda a árvore de chamadas de uma requisição — através de fronteiras de goroutines, chamadas HTTP, consultas a banco de dados e qualquer outra operação que aceite um contexto.

A interface Context

O contexto é definido por uma interface simples de quatro métodos:

type Context interface {
    // Retorna o prazo final, se houver
    Deadline() (deadline time.Time, ok bool)

    // Channel fechado quando o contexto é cancelado
    Done() <-chan struct{}

    // Motivo do cancelamento — nil se ainda não cancelado
    Err() error

    // Valor associado a uma chave — para dados de requisição
    Value(key any) any
}

Quatro implementações concretas formam a base do sistema: context.Background(), context.TODO(), e os contextos derivados criados por WithCancel, WithDeadline, WithTimeout e WithValue.

Os contextos raiz

Todo contexto começa a partir de um dos dois contextos raiz:

// Background — contexto raiz para a aplicação inteira
// Nunca é cancelado, não tem deadline, não tem valores
ctx := context.Background()

// TODO — placeholder para código onde o contexto ainda não foi definido
// Semanticamente indica "preciso adicionar context aqui depois"
ctx := context.TODO()

context.Background() é o ponto de partida em main, em testes e em goroutines de nível superior. context.TODO() é uma marcação intencional para código em transição: funcionalmente idêntico ao Background, ele existe para que uma busca por context.TODO no projeto encontre todos os pontos que ainda precisam receber o contexto do chamador.

WithCancel: cancelamento manual

package main

import (
    "context"
    "fmt"
    "time"
)

func monitorarSistema(ctx context.Context, nome string) {
    ticker := time.NewTicker(200 * time.Millisecond)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            fmt.Printf("[%s] sistema operacional\n", nome)
        case <-ctx.Done():
            fmt.Printf("[%s] monitoramento encerrado: %v\n", nome, ctx.Err())
            return
        }
    }
}

func main() {
    ctx, cancelar := context.WithCancel(context.Background())

    go monitorarSistema(ctx, "CPU")
    go monitorarSistema(ctx, "Memória")
    go monitorarSistema(ctx, "Disco")

    time.Sleep(600 * time.Millisecond)

    fmt.Println("cancelando todos os monitores...")
    cancelar() // propaga para todas as goroutines simultaneamente

    time.Sleep(100 * time.Millisecond)
    fmt.Println("encerrado")
}

cancelar() fecha o channel Done() do contexto e de todos os seus filhos simultaneamente. Todas as goroutines que aguardam <-ctx.Done() desbloqueiam ao mesmo tempo — um broadcast eficiente sem nenhuma comunicação adicional.

WithTimeout e WithDeadline

WithTimeout recebe uma duração relativa ao momento atual. WithDeadline recebe um instante absoluto no tempo. Ambos cancelam automaticamente o contexto quando o prazo é atingido:

package main

import (
    "context"
    "fmt"
    "time"
)

func buscarDados(ctx context.Context, fonte string, latencia time.Duration) (string, error) {
    select {
    case <-time.After(latencia):
        return fmt.Sprintf("dados de %s", fonte), nil
    case <-ctx.Done():
        return "", fmt.Errorf("%s: %w", fonte, ctx.Err())
    }
}

func main() {
    // WithTimeout — deadline relativo
    ctx, cancelar := context.WithTimeout(context.Background(), 300*time.Millisecond)
    defer cancelar()

    dados, err := buscarDados(ctx, "banco_primario", 200*time.Millisecond)
    if err != nil {
        fmt.Println("Erro:", err)
    } else {
        fmt.Println("Sucesso:", dados)
    }

    // WithDeadline — deadline absoluto
    prazo := time.Now().Add(100 * time.Millisecond)
    ctxDL, cancelDL := context.WithDeadline(context.Background(), prazo)
    defer cancelDL()

    dados, err = buscarDados(ctxDL, "banco_secundario", 300*time.Millisecond)
    if err != nil {
        fmt.Println("Erro:", err) // context deadline exceeded
    } else {
        fmt.Println("Sucesso:", dados)
    }

    // Verificando o deadline
    if dl, ok := ctxDL.Deadline(); ok {
        fmt.Printf("Deadline foi: %s\n", dl.Format("15:04:05.000"))
    }
}

A árvore de contextos

Contextos formam uma árvore hierárquica. Cancelar um contexto pai cancela todos os filhos — mas cancelar um filho não afeta o pai nem os irmãos:

package main

import (
    "context"
    "fmt"
    "time"
)

func executar(ctx context.Context, nome string, duracao time.Duration) {
    select {
    case <-time.After(duracao):
        fmt.Printf("[%s] concluído normalmente\n", nome)
    case <-ctx.Done():
        fmt.Printf("[%s] cancelado: %v\n", nome, ctx.Err())
    }
}

func main() {
    // Raiz
    raiz, cancelarRaiz := context.WithCancel(context.Background())
    defer cancelarRaiz()

    // Filho A — timeout de 400ms
    filhoA, cancelarA := context.WithTimeout(raiz, 400*time.Millisecond)
    defer cancelarA()

    // Filho B — timeout de 800ms
    filhoB, cancelarB := context.WithTimeout(raiz, 800*time.Millisecond)
    defer cancelarB()

    // Neto de A — timeout de 200ms
    neto, cancelarNeto := context.WithTimeout(filhoA, 200*time.Millisecond)
    defer cancelarNeto()

    go executar(filhoA, "filho-A", 600*time.Millisecond)  // será cancelado por filhoA
    go executar(filhoB, "filho-B", 600*time.Millisecond)  // concluirá normalmente
    go executar(neto, "neto", 600*time.Millisecond)       // será cancelado por neto (200ms)

    time.Sleep(1 * time.Second)
    // neto cancela em 200ms
    // filhoA cancela em 400ms
    // filhoB conclui normalmente em 600ms (antes dos 800ms)
}

WithValue: transportando dados de requisição

context.WithValue associa um par chave-valor ao contexto. Valores propagam pela árvore — filhos herdam os valores dos pais:

package main

import (
    "context"
    "fmt"
)

// Tipos próprios para chaves — evita colisões com outros pacotes
type chaveContexto string

const (
    ChaveRequisicaoID chaveContexto = "requisicao_id"
    ChaveUsuarioID    chaveContexto = "usuario_id"
    ChavePapel        chaveContexto = "papel"
)

func middleware(ctx context.Context, reqID, userID, papel string) context.Context {
    ctx = context.WithValue(ctx, ChaveRequisicaoID, reqID)
    ctx = context.WithValue(ctx, ChaveUsuarioID, userID)
    ctx = context.WithValue(ctx, ChavePapel, papel)
    return ctx
}

func obterRequisicaoID(ctx context.Context) string {
    v, _ := ctx.Value(ChaveRequisicaoID).(string)
    return v
}

func obterUsuarioID(ctx context.Context) string {
    v, _ := ctx.Value(ChaveUsuarioID).(string)
    return v
}

func processarPedido(ctx context.Context, pedidoID int) {
    reqID := obterRequisicaoID(ctx)
    userID := obterUsuarioID(ctx)
    papel, _ := ctx.Value(ChavePapel).(string)

    fmt.Printf("[req:%s] usuário %s (papel:%s) processando pedido %d\n",
        reqID, userID, papel, pedidoID)
}

func main() {
    ctx := context.Background()
    ctx = middleware(ctx, "req-abc-123", "user-42", "admin")

    processarPedido(ctx, 1001)
    processarPedido(ctx, 1002)
}

Regras importantes para WithValue:

Use tipos próprios como chave — nunca strings ou ints diretamente — para evitar colisões entre pacotes diferentes. Use WithValue apenas para dados de escopo de requisição: IDs de rastreamento, tokens de autenticação, metadados de telemetria. Nunca use para passar parâmetros opcionais de funções — isso torna as dependências implícitas e dificulta testes.

Três acréscimos recentes completam o pacote. context.WithCancelCause (Go 1.20) permite cancelar informando o motivo, lido depois com context.Cause. context.WithoutCancel (Go 1.21) cria um contexto que mantém os valores do pai mas não é cancelado junto com ele — útil para uma tarefa de fundo, como gravar uma auditoria, que precisa terminar mesmo depois que a requisição acabou. E context.AfterFunc (Go 1.21) registra uma função para rodar quando o contexto for cancelado, sem precisar de uma goroutine esperando em <-ctx.Done().

context em handlers HTTP

O caso de uso mais comum em produção. O net/http injeta automaticamente um contexto em cada requisição, que é cancelado quando a conexão do cliente se encerra:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "time"
)

type chaveHTTP string

const ChaveRequestID chaveHTTP = "request_id"

// Middleware que injeta request ID e timeout
func comContexto(prox http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Timeout de 5 segundos por requisição
        ctx, cancelar := context.WithTimeout(r.Context(), 5*time.Second)
        defer cancelar()

        // Injeta request ID
        reqID := r.Header.Get("X-Request-ID")
        if reqID == "" {
            reqID = fmt.Sprintf("req-%d", time.Now().UnixNano())
        }
        ctx = context.WithValue(ctx, ChaveRequestID, reqID)

        prox.ServeHTTP(w, r.WithContext(ctx))
    })
}

func consultarBancoDados(ctx context.Context, query string) (map[string]any, error) {
    // Simula consulta que respeita o contexto
    select {
    case <-time.After(200 * time.Millisecond):
        return map[string]any{"query": query, "resultado": "dados"}, nil
    case <-ctx.Done():
        return nil, fmt.Errorf("consulta cancelada: %w", ctx.Err())
    }
}

func handlerUsuario(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    reqID, _ := ctx.Value(ChaveRequestID).(string)

    dados, err := consultarBancoDados(ctx, "SELECT * FROM usuarios")
    if err != nil {
        log.Printf("[%s] erro: %v", reqID, err)
        http.Error(w, "erro interno", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Request-ID", reqID)
    json.NewEncoder(w).Encode(dados)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/usuarios", handlerUsuario)

    handler := comContexto(mux)

    servidor := &http.Server{
        Addr:    ":8080",
        Handler: handler,
    }

    log.Println("servidor em :8080")
    log.Fatal(servidor.ListenAndServe())
}

context com banco de dados

A biblioteca database/sql aceita context em todos os métodos de consulta, permitindo cancelar queries em andamento:

package main

import (
    "context"
    "database/sql"
    "fmt"
    "time"

    _ "github.com/lib/pq"
)

type RepositorioUsuario struct {
    db *sql.DB
}

func (r *RepositorioUsuario) BuscarPorID(ctx context.Context, id int) error {
    // Context propaga para o driver — cancela a query no banco se necessário
    row := r.db.QueryRowContext(ctx,
        "SELECT id, nome, email FROM usuarios WHERE id = $1", id)

    var (
        userID int
        nome   string
        email  string
    )

    if err := row.Scan(&userID, &nome, &email); err != nil {
        return fmt.Errorf("BuscarPorID: %w", err)
    }

    fmt.Printf("usuário: %d %s <%s>\n", userID, nome, email)
    return nil
}

func (r *RepositorioUsuario) ListarAtivos(ctx context.Context) error {
    rows, err := r.db.QueryContext(ctx,
        "SELECT id, nome FROM usuarios WHERE ativo = true ORDER BY nome")
    if err != nil {
        return fmt.Errorf("ListarAtivos: %w", err)
    }
    defer rows.Close()

    for rows.Next() {
        // Verifica cancelamento entre linhas
        select {
        case <-ctx.Done():
            return fmt.Errorf("ListarAtivos cancelado: %w", ctx.Err())
        default:
        }

        var id int
        var nome string
        if err := rows.Scan(&id, &nome); err != nil {
            return err
        }
        fmt.Printf("%d: %s\n", id, nome)
    }

    return rows.Err()
}

func exemplo() {
    // Exemplo de uso com timeout
    ctx, cancelar := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancelar()

    repo := &RepositorioUsuario{ /* db inicializado */ }
    if err := repo.BuscarPorID(ctx, 42); err != nil {
        fmt.Println("erro:", err)
    }
}

Boas práticas consolidadas

Context deve ser o primeiro parâmetro, sempre nomeado ctx:

// Correto
func ProcessarPedido(ctx context.Context, pedidoID int) error

// Errado
func ProcessarPedido(pedidoID int, ctx context.Context) error
func ProcessarPedido(c context.Context, pedidoID int) error

Nunca armazene context em structs. Context deve fluir pela pilha de chamadas como parâmetro de função, não ser armazenado e reutilizado:

// Errado
type Servico struct {
    ctx context.Context
}

// Correto — ctx como parâmetro de método
func (s *Servico) Executar(ctx context.Context) error

Sempre chame cancelar. Mesmo que o contexto já tenha sido cancelado:

ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelar() // sempre, mesmo se cancelado antes

Propague context, nunca crie novos a partir de Background em funções internas:

// Errado — cria contexto independente, ignora cancelamento do chamador
func buscar(id int) {
    ctx := context.Background() // perde o contexto da requisição
    db.QueryContext(ctx, ...)
}

// Correto — propaga o contexto recebido
func buscar(ctx context.Context, id int) {
    db.QueryContext(ctx, ...)
}

O context resolve um problema que aparece em todo serviço: parar o trabalho que deixou de interessar a alguém. Ele funciona porque segue regras rígidas — o filho nunca vive mais que o pai, o cancelamento desce pela árvore e nunca sobe, e cada operação carrega o seu próprio contexto, recebido como primeiro parâmetro. As exceções têm ferramentas próprias: WithoutCancel para o trabalho de fundo que precisa sobreviver à requisição, Cause para saber por que algo parou, e tipos não exportados como chave para os poucos valores que de fato pertencem à requisição.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um handler de pedidos responde ao cliente e dispara uma goroutine que grava um registro de auditoria usando r.Context(), para aproveitar o request ID guardado nele. Nos logs, a auditoria falha quase sempre com context canceled, embora o banco esteja saudável. Por quê, e como manter o request ID sem herdar o cancelamento?

Ver resposta

✓ Resposta: O contexto de uma requisição no net/http é cancelado quando o cliente desconecta e também quando o handler retorna. A goroutine de auditoria começa, o handler escreve a resposta e termina, e dali a instantes o contexto que ela carrega já está cancelado; qualquer consulta ao banco com ele falha na hora. Num teste com httptest, uma goroutine que conferiu r.Context().Err() 50 ms depois da resposta encontrou context canceled. A solução, desde o Go 1.21, é context.WithoutCancel(r.Context()): o contexto novo mantém todos os valores do pai, request ID incluído, mas não é cancelado junto com ele; no mesmo teste, ele continuou com Err() igual a nil. Como ele também perde o prazo do pai, a goroutine de fundo deve ganhar um prazo próprio, com context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second), para que uma auditoria travada não fique pendurada para sempre. Antes do 1.21, o caminho era copiar os valores necessários para um contexto novo, derivado de Background.

Exercício 2

Uma função recebe um contexto com prazo de 1 segundo e, para uma chamada que sabe ser lenta, cria context.WithTimeout(ctx, 10*time.Second), "dando mais tempo para ela". A chamada continua estourando em 1 segundo. Explique o comportamento e o que ele revela sobre o desenho da árvore de contextos.

Ver resposta

✓ Resposta: Um contexto filho nunca vive mais que o pai. WithTimeout define como prazo do filho o menor entre o pedido e o do pai; como o pai vence em 1 segundo, o filho herda esse prazo, e os 10 segundos são ignorados. Num teste com pai de 1 segundo e filho de 10, filho.Deadline() devolveu exatamente o mesmo instante que pai.Deadline(). É o que torna a árvore confiável: quem está em cima, como o servidor, o balanceador ou o cliente, fixa o orçamento de tempo, e nada lá embaixo consegue estourá-lo sem que ninguém perceba. A consequência de desenho é que "dar mais tempo" a uma operação não é decisão de quem a chama lá no fundo. Se a operação legitimamente precisa de 10 segundos, ou o prazo de cima está errado para aquela rota, ou ela não deveria acontecer dentro da requisição, e sim numa tarefa assíncrona, com um contexto próprio. O inverso funciona sempre: encurtar o prazo num filho é o uso normal, como no exemplo do artigo.

Exercício 3

Dois pacotes do mesmo projeto guardam dados no contexto com context.WithValue(ctx, "usuario", ...), um o nome e outro o ID. Um deles passa a ler o valor errado. Explique a colisão e mostre por que a recomendação do artigo, a de tipos próprios como chave, resolve de verdade.

Ver resposta

✓ Resposta: ctx.Value(chave) sobe pela cadeia de contextos e devolve o primeiro valor cuja chave seja igual à pedida, e a igualdade de dois valores de interface compara o tipo dinâmico e o valor. Com strings, "usuario" de um pacote e "usuario" do outro são o mesmo tipo e o mesmo valor, então quem gravou por último esconde o outro, e cada pacote pode ler o dado do vizinho. Com tipos próprios, a colisão desaparece mesmo com o mesmo texto: num teste, chaveA("usuario") guardou "ana", chaveB("usuario") guardou "bia", cada leitura devolveu o seu valor, e ctx.Value("usuario"), com string pura, devolveu nil. A forma mais robusta vai um passo além: um tipo não exportado, como type chaveUsuario struct{}, e funções exportadas para gravar e ler, como ComUsuario(ctx, u) e UsuarioDe(ctx). Assim nenhum outro pacote consegue sequer construir a chave, e o tipo do valor fica garantido por uma única função.

Exercício 4

Um serviço guarda o contexto num campo da struct, preenchido no construtor com o contexto da primeira requisição, e todos os métodos o usam. Depois de alguns minutos no ar, toda chamada ao banco falha com context canceled. Explique a falha e por que o artigo manda passar o contexto como parâmetro.

Ver resposta

✓ Resposta: O contexto guardado pertence à primeira requisição e foi cancelado quando ela terminou, como mostra o exercício 1. A partir daí, todo método que o usa carrega um contexto morto, e cada operação no banco falha antes de começar. O defeito não aparece em teste, porque o teste costuma usar Background, e só se manifesta quando a primeira requisição real acaba. Mais grave que a falha é o que ela revela: um contexto descreve uma operação, com o seu prazo, o seu cancelamento e os seus valores, e guardá-lo numa struct de vida longa mistura operações diferentes sob o mesmo orçamento. Passá-lo como primeiro parâmetro, func (s *Servico) Buscar(ctx context.Context, id int), faz cada chamada carregar o contexto de quem a originou, e o cancelamento de uma requisição afeta só o trabalho dela. A exceção documentada é quando a própria struct representa uma operação, como um objeto de requisição de curta duração. Para um serviço, repositório ou cliente, a regra é sem exceção.

Exercício 5

Uma biblioteca antiga tem a função Gerar(dados) (Relatorio, error), que não aceita contexto e pode levar minutos. Um handler precisa respeitar o cancelamento da requisição ao chamá-la. Como fazer, e qual é a limitação que nenhum truque do lado de quem chama consegue resolver?

Ver resposta

✓ Resposta: O handler roda Gerar numa goroutine que envia o resultado por um canal com buffer de 1, e espera com select entre esse canal e ctx.Done(). Se o contexto for cancelado primeiro, o handler devolve ctx.Err() e responde na hora, e o buffer de 1 garante que a goroutine consiga entregar o resultado mais tarde e terminar, sem ficar presa num envio. A limitação é que isso só faz o handler parar de esperar: a função continua executando até o fim, consumindo CPU, memória e talvez conexões, porque nada no Go permite interromper uma goroutine por fora. Sob carga, pedidos cancelados continuam gerando relatórios que ninguém vai ler. A solução definitiva está do lado da biblioteca: uma versão GerarContext(ctx, dados) que verifique ctx.Err() entre as etapas e repasse o contexto às operações de I/O. Enquanto ela não existe, vale ao menos limitar quantas execuções simultâneas o serviço aceita, para que os cancelamentos não virem acúmulo de trabalho órfão.

Comentários

Mais em Golang

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…

Interfaces: contratos implícitos e polimorfismo
Interfaces: contratos implícitos e polimorfismo

Interfaces em Go: implementação implícita, any, type assertion e type switch…

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…