Context: propagando cancelamento e deadlines

1. Introdução ao pacote context

Antes da introdução do pacote context na biblioteca padrão do Go, o gerenciamento de concorrência era feito manualmente com canais e variáveis compartilhadas. Isso gerava código propenso a vazamentos de goroutines e difícil de manter. Por exemplo, para cancelar uma operação concorrente, era comum usar um canal done como sinalizador:

func operacaoLonga(done <-chan struct{}) {
    select {
    case <-done:
        return
    case <-time.After(5 * time.Second):
        // processamento
    }
}

O pacote context padronizou essa comunicação, fornecendo uma interface clara para propagar cancelamentos, deadlines e valores entre goroutines. A interface context.Context define quatro métodos essenciais:

  • Done(): retorna um canal que é fechado quando o contexto é cancelado ou expira
  • Deadline(): retorna o deadline absoluto, se existir
  • Err(): retorna o motivo do cancelamento (Canceled ou DeadlineExceeded)
  • Value(): recupera valores associados ao contexto

2. Criando contextos pai e filho

Todo contexto em Go deriva de um contexto raiz. As funções context.Background() e context.TODO() criam esses contextos iniciais:

ctx := context.Background() // contexto vazio, nunca cancelado
ctx2 := context.TODO()      // placeholder para quando o contexto ainda não está definido

A hierarquia de contextos permite criar contextos filhos que herdam o comportamento do pai. Quando um contexto pai é cancelado, todos os seus descendentes também são cancelados automaticamente:

func main() {
    pai, cancel := context.WithCancel(context.Background())
    filho := context.WithValue(pai, "user", "admin")

    cancel() // cancela pai e também filho
    fmt.Println(filho.Err()) // imprime "context canceled"
}

Use Background() para o contexto raiz da aplicação e TODO() apenas temporariamente durante o desenvolvimento, até definir o contexto adequado.

3. Cancelamento explícito com context.WithCancel

A função context.WithCancel recebe um contexto pai e retorna um novo contexto filho e uma função cancel. Quando cancel é chamada, o canal Done() do contexto filho é fechado, sinalizando todas as goroutines que escutam esse canal:

func processarDados(ctx context.Context, dados []int) <-chan int {
    resultados := make(chan int)

    go func() {
        defer close(resultados)
        for _, d := range dados {
            select {
            case <-ctx.Done():
                fmt.Println("Processamento cancelado:", ctx.Err())
                return
            case resultados <- d * 2:
                time.Sleep(100 * time.Millisecond)
            }
        }
    }()

    return resultados
}

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

    dados := []int{1, 2, 3, 4, 5}
    ch := processarDados(ctx, dados)

    // Cancela após receber 3 resultados
    for i := 0; i < 3; i++ {
        fmt.Println(<-ch)
    }
    cancel() // propaga cancelamento para a goroutine
}

A propagação em cascata é automática: se o pai for cancelado, todos os filhos também são, independentemente de onde o cancelamento ocorre.

4. Deadlines e timeouts com context.WithDeadline e context.WithTimeout

WithDeadline define um momento absoluto para o cancelamento, enquanto WithTimeout é um atalho que recebe uma duração relativa:

// Deadline absoluto: 2 segundos a partir de agora
deadline := time.Now().Add(2 * time.Second)
ctxDeadline, cancelDeadline := context.WithDeadline(context.Background(), deadline)
defer cancelDeadline()

// Timeout relativo: mesmo efeito, mas mais conciso
ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 2*time.Second)
defer cancelTimeout()

Em chamadas de rede, timeouts são essenciais para evitar bloqueios eternos:

func buscarDadosAPI(ctx context.Context, url string) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    dados, err := buscarDadosAPI(ctx, "https://api.exemplo.com/dados")
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            fmt.Println("Timeout excedido")
        } else {
            fmt.Println("Erro:", err)
        }
        return
    }
    fmt.Println("Dados recebidos:", len(dados))
}

É importante distinguir os erros: DeadlineExceeded indica que o tempo expirou, enquanto Canceled indica cancelamento explícito.

5. Propagação de cancelamento em chamadas encadeadas

O contexto deve ser passado explicitamente entre funções e serviços para garantir que cancelamentos se propaguem corretamente. Em pipelines de goroutines, cada estágio deve respeitar o contexto:

func etapa1(ctx context.Context, entrada <-chan int) <-chan int {
    saida := make(chan int)
    go func() {
        defer close(saida)
        for v := range entrada {
            select {
            case <-ctx.Done():
                return
            case saida <- v * 2:
            }
        }
    }()
    return saida
}

func etapa2(ctx context.Context, entrada <-chan int) <-chan int {
    saida := make(chan int)
    go func() {
        defer close(saida)
        for v := range entrada {
            select {
            case <-ctx.Done():
                return
            case saida <- v + 1:
            }
        }
    }()
    return saida
}

Em servidores HTTP, o contexto da requisição já carrega informações de cancelamento. Se o cliente desconectar, o contexto é cancelado automaticamente:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    resultado, err := operacaoLonga(ctx)
    if err != nil {
        if errors.Is(err, context.Canceled) {
            return // cliente desconectou, não precisa responder
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    fmt.Fprintln(w, resultado)
}

6. Valores no contexto: context.WithValue

WithValue permite associar pares chave-valor ao contexto, úteis para propagar informações como IDs de requisição, tokens de autenticação ou metadados:

type contextKey string

const (
    keyRequestID contextKey = "request_id"
    keyUserID    contextKey = "user_id"
)

func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := context.WithValue(r.Context(), keyRequestID, uuid.New().String())
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func handler(w http.ResponseWriter, r *http.Request) {
    requestID := r.Context().Value(keyRequestID).(string)
    fmt.Println("Processando requisição:", requestID)
}

Evite usar valores no contexto para passar parâmetros opcionais de funções ou dados mutáveis. O contexto deve ser imutável e os valores devem ser usados apenas para metadados de escopo amplo.

7. Boas práticas e armadilhas comuns

Nunca armazene contextos em structs — sempre passe como primeiro parâmetro das funções:

// ❌ Ruim: contexto armazenado em struct
type Service struct {
    ctx context.Context
}

// ✅ Bom: contexto passado como parâmetro
func (s *Service) Processar(ctx context.Context, dados []byte) error {
    // ...
}

Verifique ctx.Err() antes de operações longas para evitar trabalho desnecessário:

func operacaoPesada(ctx context.Context) error {
    if err := ctx.Err(); err != nil {
        return err
    }
    // operação longa aqui
}

Testando cancelamento em testes unitários:

func TestOperacaoComCancelamento(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    cancel() // cancela imediatamente

    err := operacaoPesada(ctx)
    if !errors.Is(err, context.Canceled) {
        t.Errorf("esperava cancelamento, obteve: %v", err)
    }
}

Lembre-se de sempre chamar a função cancel para liberar recursos, mesmo que o contexto não seja cancelado explicitamente. Use defer cancel() imediatamente após criar o contexto.

Referências