MÓDULO 04 · CONCEITO 13 DE 14

Cancelamento, timeout e propagação de contexto

context.Context, CancellationToken, asyncio.CancelledError — por que exception-based cancellation é frágil e por que cancelamento explícito venceu.

Tempo de leitura ~22 min Pré-requisito Conceitos 04, 05, 06, 12 Próximo Escolhendo o modelo: I/O-bound vs CPU-bound

Sistemas concorrentes precisam parar coisas. Usuário fechou a aba — o backend deveria parar de processar a request dele. Um circuit breaker abriu — todas as chamadas em vôo para aquele serviço deveriam ser interrompidas. Um timeout de 30 segundos foi atingido — a operação inteira deveria abortar. Um job em background foi revogado — o worker deveria largar imediatamente. Esse é o domínio do cancelamento, e construir cancelamento bem-feito é uma das coisas mais subestimadamente difíceis em concorrência.

A história tem duas eras. Na primeira, anos 1990 a 2000, linguagens tentaram cancelamento por exception: você chama Thread.interrupt() em Java, ou Thread.Abort() em .NET, e o runtime levanta uma exceção dentro da thread cancelada. A ideia parecia elegante — cancelamento usa o mecanismo já existente de erro. Em prática, é desastre. A exceção pode interromper qualquer instrução, deixando estado incoerente: lock parcialmente liberado, arquivo aberto não fechado, transação pendente. Thread.Abort() foi formalmente deprecated em .NET Framework 4.5 e removido em .NET Core. Java Thread.stop() foi deprecated em 1998 e nunca foi removido por compatibilidade — mas você não deve usar.

A segunda era começa em 2010, quando .NET 4 introduziu CancellationToken: um objeto leve que carrega apenas a sinalização "cancelamento foi pedido". Você passa o token explicitamente para todas as funções que podem demorar, e elas verificam regularmente se foi sinalizado. Cancelamento é cooperativo: o código alvo decide quando e como reagir. Em 2014, Sameer Ajmani publicou em golang.org/x/net/context um padrão similar para Go; em 2016, virou stdlib (context.Context em Go 1.7). Python asyncio formalizou em 2014. Em 2026, cancelamento explícito propagado por contexto é o padrão consolidado em todas as linguagens modernas.

Esse conceito mostra como fazer cancelamento direito. Os ingredientes conceituais: token/contexto explícito, propagação end-to-end pela call stack, verificação cooperativa nos pontos certos, cleanup apropriado quando cancelado. Os ingredientes práticos: padrões idiomáticos em Go, C#, Python; padrões de timeout e deadline; e armadilhas que matam sistemas em produção.

Por que exception-based cancellation falha

Cancelamento por exception assíncrona é tentador porque parece uniforme. A exceção pode acontecer a qualquer hora; o código já sabe lidar com exceptions; cleanup com finally garante recursos liberados. Mas três problemas estruturais destroem essa elegância em prática.

Estado incoerente entre instruções

A exceção pode ser injetada entre quaisquer duas instruções. Se o código está no meio de "atualizar dois campos relacionados", você fica com um campo atualizado e outro não. Se está no meio de "remover do índice X e adicionar no índice Y", o item some. Mesmo com finally bem escrito, garantir invariantes sob interrupção arbitrária é praticamente impossível em código não-trivial. Java thread interruption funciona porque é cooperativa em pontos específicos (InterruptedException é checked); abort verdadeiro (Thread.stop) é o que foi deprecated.

Locks vazados

Se a exceção interrompe uma thread enquanto ela tem lock, o runtime tem que decidir: liberar o lock automaticamente (estado protegido pelo lock pode estar incoerente) ou manter lock (deadlock garantido se outra thread espera). Não há escolha boa. Thread.Abort() em .NET tentava liberar, com resultado imprevisível.

Composição quebrada

Em código concorrente real, cancelamento precisa propagar: cancelar uma operação cancela suas sub-operações. Exception assíncrona não tem mecanismo de propagação claro — se thread A foi interrompida, threads que A criou continuam vivas. Cancelamento explícito (token/contexto) propaga naturalmente porque é dado passado por valor.

Lições aprendidas. Linguagens que tentaram exception-async cancellation depois reconheceram o erro: .NET deprecou Abort, Java deprecou stop, Python nunca implementou (usa cooperativa desde sempre). O modelo cooperativo venceu porque é semanticamente honesto: código que pode ser cancelado é código que sabe que pode, e tem oportunidade de proteger invariantes.

O modelo cooperativo — token explícito

O modelo moderno em todas as linguagens tem três ingredientes:

A propriedade central: cancelamento é dado, não exceção injetada. Quando seu código vê o sinal, ele decide quando e como reagir. Geralmente, propaga (passando o token adiante) e levanta uma exceção do tipo apropriado (OperationCanceledException, context.Canceled, asyncio.CancelledError). Mas essa exceção é sua, levantada em ponto seguro, não injetada de fora.

context.Context em Go

Sameer Ajmani publicou em julho de 2014 no Go blog o artigo Go Concurrency Patterns: Context, propondo um pacote pequeno para resolver propagação de cancelamento em servidores Go. O pacote era golang.org/x/net/context, mais tarde promovido para context na stdlib em Go 1.7 (2016). Em 2026, é uma das primitivas mais usadas em código Go idiomático — você raramente passa cinco minutos em uma codebase Go séria sem encontrar ctx context.Context como primeiro parâmetro de função.

// Construtor de contexto raiz
ctx := context.Background()    // sem parent, sem cancelamento
ctx := context.TODO()          // placeholder em código WIP

// Derivação com cancelamento manual
ctx, cancel := context.WithCancel(parent)
defer cancel()                  // libera recursos do context (idempotente)

// Derivação com timeout
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

// Derivação com deadline
ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))
defer cancel()

// Verificar cancelamento
select {
case <-ctx.Done():
    return ctx.Err()    // context.Canceled ou context.DeadlineExceeded
default:
    // continuar
}

// Em chamadas longas, passe ctx adiante:
func operacao(ctx context.Context, params Params) error {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    // ...
}

A API tem três funções de derivação. WithCancel retorna função de cancelamento manual que você chama (e deveria sempre chamar via defer mesmo se contexto não foi cancelado — para liberar recursos internos). WithTimeout é cancelamento automático após duração. WithDeadline é cancelamento em timestamp absoluto. Há também WithValue para passar valores no contexto, que é controverso e desencorajado para qualquer coisa além de identificadores de tracing.

A propagação acontece por convenção forte: toda função que pode demorar aceita ctx context.Context como primeiro parâmetro. HTTP client, queries de banco, RPCs, chamadas gRPC — todos no Go ecosystem aceitam contexto. Quando um contexto é cancelado, todas as operações que ele atravessa sabem disso e abortam.

CancellationToken em .NET

Microsoft introduziu CancellationToken em .NET 4 (2010) junto com a Task Parallel Library. O design é similar ao Go context, com diferenças sintáticas e semânticas menores.

using System.Threading;

// Source — onde cancelamento é emitido
using var cts = new CancellationTokenSource();

// Cancelamento por timeout
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

// Cancelamento manual em código:
cts.Cancel();
// Programado para o futuro:
cts.CancelAfter(TimeSpan.FromSeconds(5));

// Token: passa adiante
CancellationToken ct = cts.Token;

// Combinando dois tokens (linked)
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
    ct1, ct2, ct3);
// linked.Token é cancelado se qualquer um dos pais for

// Verificar cancelamento
ct.ThrowIfCancellationRequested();   // levanta OperationCanceledException
if (ct.IsCancellationRequested) { /* limpar e sair */ }

// Esperar com timeout via cancelamento
await Task.Delay(TimeSpan.FromSeconds(10), ct);   // honra ct

// Registrar callback
ct.Register(() => Console.WriteLine("cancelado!"));

Convenção: toda API async em .NET aceita CancellationToken como último parâmetro (não primeiro como em Go), tipicamente com default CancellationToken.None. HttpClient, Stream.ReadAsync, SqlCommand.ExecuteReaderAsync — todos honram cancelamento.

Diferença sutil de Go: em .NET, quando uma operação é cancelada, ela levanta OperationCanceledException (ou TaskCanceledException, subclasse). Você lida com try/catch ou deixa propagar. Em Go, ela retorna erro context.Canceled que você verifica explicitamente. Estilos diferentes; semântica equivalente.

Cancelamento em asyncio

Python asyncio tem cancelamento desde o início (PEP 3156, 2014). O modelo é diferente: não há "token" explícito; cada Task tem método cancel(), que agenda asyncio.CancelledError para ser levantada no próximo await da Task.

import asyncio

async def operacao_longa():
    try:
        await asyncio.sleep(10)
        return "feito"
    except asyncio.CancelledError:
        # cleanup necessário
        print("cancelado, limpando…")
        raise   # re-raise é importante!

async def main():
    task = asyncio.create_task(operacao_longa())
    await asyncio.sleep(1)
    task.cancel()
    try:
        result = await task
    except asyncio.CancelledError:
        print("foi cancelado")

A regra de ouro: sempre re-raise CancelledError depois de fazer cleanup. Se você captura e não re-raise, a Task "engole" o cancelamento — o caller acha que terminou normalmente. É bug clássico.

Python 3.11 (out/2022) adicionou asyncio.timeout como context manager — sintaticamente mais limpo que o antigo asyncio.wait_for:

# Python 3.11+
async def operacao_com_timeout():
    try:
        async with asyncio.timeout(5.0):
            return await operacao_longa()
    except asyncio.TimeoutError:
        # operacao_longa foi cancelada
        return None

# Cancelamento manual via cancel scope
async def operacao_cancelavel():
    async with asyncio.timeout(None) as cm:
        # cm.reschedule() permite ajustar deadline em tempo de execução
        cm.reschedule(asyncio.get_event_loop().time() + 10)
        return await operacao_longa()

asyncio.timeout é parte da infraestrutura de structured concurrency vista no conceito 05. Junto com asyncio.TaskGroup (também 3.11+), forma a forma idiomática moderna de fazer concorrência em Python — escopo léxico, cancelamento que propaga, erros agregados.

Timeout vs deadline — escolha sutil mas importante

Há duas formas conceituais de pedir "pare em N segundos":

Timeout — duração relativa

"Trabalhe por no máximo 5 segundos a partir de agora." Cada chamada que aceita timeout reinicia o relógio. Se você chama A com timeout 5s, e A chama B com timeout 5s, B pode demorar até 5s mais — A pode passar de 5s no total.

Deadline — timestamp absoluto

"Termine antes de timestamp T." Toda chamada na cadeia recebe o mesmo deadline. Se A começa às 10:00:00 com deadline 10:00:05, e A chama B, B sabe que tem deadline 10:00:05 — não "5 segundos a partir de agora". Total honra end-to-end.

Para chamadas que cruzam serviços, deadline é claramente superior. gRPC carrega deadline em metadata da chamada por padrão — toda chamada distribuída já tem deadline propagado. Em chamadas locais, timeout é mais comum por simplicidade. Em Go, WithDeadline e WithTimeout existem; WithTimeout(parent, d) é açúcar sintático para WithDeadline(parent, time.Now().Add(d)).

Convenção pragmática: use timeout no nível de cada chamada individual; carregue deadline no contexto compartilhado para a operação inteira. Cada chamada respeita o menor entre seu próprio timeout e o deadline do contexto.

Padrões idiomáticos de cancelamento

Race entre operação e timeout

Padrão clássico: dispare uma operação; se ela não terminar em X tempo, cancele e use fallback. Em todas as linguagens é a base do "circuit breaker" e do "graceful degradation".

// Go
ctx, cancel := context.WithTimeout(parent, 100*time.Millisecond)
defer cancel()
resultado, err := chamadaExterna(ctx, params)
if errors.Is(err, context.DeadlineExceeded) {
    return cacheResult, nil   // fallback
}

Cancelamento aninhado

Operação composta: dois sub-trabalhos em paralelo, mas se o pai é cancelado, ambos param. errgroup.WithContext em Go, asyncio.TaskGroup em Python, CancellationTokenSource.CreateLinkedTokenSource em C# resolvem isso.

Cleanup em cancelamento — defer/finally

Quando cancelado, recursos precisam ser liberados. Em Go, defer garante; em C# e Python, using/ async with e finally garantem. Pattern essencial: nunca tenha "abriu, não fechou ainda" sem proteção de cleanup.

Verificar regularmente em loop longo

Loop CPU-bound não é cancelado automaticamente — o ponto de verificação tem que ser explícito.

// Go — em loop longo
for i := 0; i < 1_000_000; i++ {
    if i%1000 == 0 {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
    }
    // trabalho pesado...
}

// C# — equivalente
for (int i = 0; i < 1_000_000; i++) {
    if (i % 1000 == 0) {
        ct.ThrowIfCancellationRequested();
    }
    // trabalho pesado...
}

A frequência da verificação é trade-off: muito frequente adiciona overhead; muito raro deixa cancelamento sem efeito por tempo. "A cada 1000 iterações" é heurística razoável para maioria dos casos.

Combinando timeouts em cadeia

Servidor recebe request com timeout 10s. Faz duas chamadas sequenciais. Cada uma deveria respeitar o tempo restante, não ter seu próprio timeout fixo de 5s (que poderia somar 10s e ainda ter trabalho local). Solução: derivar contexto com timeout do contexto pai.

// Go
func handler(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    // chamada 1: usa o ctx — herda deadline restante
    if err := chamarServico1(ctx); err != nil { return err }

    // chamada 2: também herda — não reinicia
    if err := chamarServico2(ctx); err != nil { return err }

    return nil
}

O mesmo padrão nas três linguagens — operação cancelável

Considere: buscar três URLs em paralelo, com timeout de 5 segundos para o conjunto, retornando assim que a primeira responde com sucesso (cancela as outras).

C# — Linked CancellationTokenSource + Task.WhenAny
using System.Net.Http;

async Task<string> PrimeiraRespostaAsync(
    string[] urls, TimeSpan timeout, CancellationToken externo) {

    using var cts = CancellationTokenSource
        .CreateLinkedTokenSource(externo);
    cts.CancelAfter(timeout);

    using var http = new HttpClient();
    var tarefas = urls.Select(url =>
        http.GetStringAsync(url, cts.Token)).ToList();

    var primeira = await Task.WhenAny(tarefas);
    cts.Cancel();   // cancela as outras imediatamente

    return await primeira;   // ou propaga TaskCanceledException
}

CreateLinkedTokenSource combina cancelamento externo (request HTTP cancelada pelo cliente) com timeout local. Task.WhenAny retorna a primeira que completa; cts.Cancel() cancela as restantes. As outras tarefas vão lançar TaskCanceledException ao serem awaited — você ignora ou loga.

Python — asyncio.wait com FIRST_COMPLETED + timeout
import asyncio
import aiohttp

async def primeira_resposta(urls: list[str], timeout: float) -> str:
    async with aiohttp.ClientSession() as session:
        async def buscar(url):
            async with session.get(url) as resp:
                return await resp.text()

        try:
            async with asyncio.timeout(timeout):
                tarefas = {asyncio.create_task(buscar(u)) for u in urls}
                done, pending = await asyncio.wait(
                    tarefas,
                    return_when=asyncio.FIRST_COMPLETED
                )
                # Cancela as pendentes
                for t in pending:
                    t.cancel()
                # Aguarda elas terminarem (CancelledError silenciado)
                await asyncio.gather(*pending, return_exceptions=True)
                return done.pop().result()
        except asyncio.TimeoutError:
            raise RuntimeError("nenhum respondeu em tempo")

asyncio.timeout envolve o conjunto inteiro; asyncio.wait com FIRST_COMPLETED retorna assim que uma termina. Cancelar pendentes é essencial para liberar conexões. gather com return_exceptions=True consome CancelledError dos pendentes silenciosamente.

Go — context.WithTimeout + select com canal de resposta
package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "time"
)

func primeiraResposta(ctx context.Context, urls []string, timeout time.Duration) (string, error) {
    ctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()   // garante cleanup ao sair

    type resultado struct {
        body string
        err  error
    }
    out := make(chan resultado, len(urls))

    for _, url := range urls {
        go func(u string) {
            req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                out <- resultado{err: err}
                return
            }
            defer resp.Body.Close()
            body, err := io.ReadAll(resp.Body)
            out <- resultado{body: string(body), err: err}
        }(url)
    }

    select {
    case r := <-out:
        cancel()       // cancela as outras goroutines
        return r.body, r.err
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

Buffered channel (size = len(urls)) evita goroutines bloqueadas tentando enviar quando o select já saiu. cancel() via defer + chamada explícita após receber primeira resposta. As outras goroutines pegam ctx.Done() e abortam suas requests HTTP — liberando conexões corretamente.

Anti-padrões comuns

Engolir cancelamento sem propagar

# Python — BUG
try:
    await operacao_longa()
except asyncio.CancelledError:
    print("cancelei!")
    # esqueceu de re-raise — caller acha que tudo deu certo

Sempre re-raise CancelledError depois do cleanup. Engolir silenciosamente confunde caller e quebra propagação.

Não passar cancelamento adiante

// Go — BUG
func handler(ctx context.Context) error {
    return chamarServico()   // não passou ctx!
}

func chamarServico() error {
    resp, _ := http.Get(url)   // sem context — não cancela
    // ...
}

O contexto vira useless se você não propaga. Toda função que pode demorar aceita ctx; toda chamada para função que aceita passa o ctx atual.

Reusar token cancelado

// C# — BUG
public class Servico {
    private readonly CancellationTokenSource _cts = new();

    public async Task ExecutarAsync() {
        // ... usa _cts.Token ...
    }

    public void Cancelar() {
        _cts.Cancel();
        // _cts agora está "queimado" — todas as chamadas futuras
        // que usarem _cts.Token vão ser canceladas imediatamente
    }
}

CancellationTokenSource é one-shot. Depois de cancelado, fica cancelado. Se você precisa de cancelamento repetível, crie novo CTS para cada operação.

Timeout só no topo

// Go — não tão bom
func handler() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    fazerTrabalho(ctx)
}

func fazerTrabalho(ctx context.Context) {
    chamarBanco()           // não usa ctx — pode demorar 2 min
    chamarServicoExterno()  // idem
}

Timeout só funciona se cada chamada na cadeia honra o contexto. "Timeout no topo + funções que ignoram" é ilusão de proteção.

armadilha em produção

O caso mais comum de "timeout não funciona em produção": biblioteca legada que não aceita context/CancellationToken e bloqueia indefinidamente. Você adiciona timeout no chamador, mas a thread/goroutine fica travada na chamada bloqueante. Solução: envolver em Task.Run com token (.NET), ou asyncio.run_in_executor (Python), ou goroutine separada com canal de resultado (Go) — para pelo menos liberar a thread chamadora. Mas cuidado: a operação interna não é realmente cancelada, só ignorada.

Cancelamento em sistemas distribuídos

Em sistemas distribuídos, cancelamento ganha dimensão extra: cancelar uma chamada local é fácil; cancelar uma chamada para outro serviço é difícil porque você precisa propagar para o remoto. gRPC resolve isso elegantemente: o canal gRPC carrega deadline no metadata da request; o servidor remoto recebe e respeita; quando o deadline expira no cliente, o canal é fechado e o servidor remoto pega ctx.Done() e aborta.

HTTP tradicional não tem cancelamento built-in. Você fechar a conexão TCP pode fazer o servidor abortar (se ele detecta), mas não é garantido. Em sistemas modernos baseados em HTTP, cancelamento end-to-end requer:

Sem essa cadeia completa, cliente cancela mas servidor continua trabalhando — recursos desperdiçados. A SRE de Google chama isso de "server doing useless work" e é causa identificada de capacidade desperdiçada em frota grande.

Como praticar

  1. Construa um servidor HTTP que cancela trabalho quando cliente desconecta. Em Go ou .NET, escreva endpoint que faz query lenta no banco. Adicione context propagation end-to-end. Inicie request via curl, cancele com Ctrl+C. Observe nos logs do servidor que a query foi cancelada — banco recebeu sinal de cancelamento via SQL cancellation token. Compare com versão sem propagação: servidor termina query mesmo com cliente sumiu.
  2. Implemente race com fallback. Função que chama API primária com timeout de 100ms; se não responder, chama API de fallback. Use Task.WhenAny (.NET) ou asyncio.wait FIRST_COMPLETED (Python) ou select sobre canais (Go). Garanta que a chamada primária é cancelada quando o fallback é usado — recursos não vazam.
  3. Force um leak de cancelamento e diagnostique. Em Python, escreva try/except CancelledError que captura mas não re-raise. Crie task; cancele; observe que ela "termina normalmente" mesmo cancelada. Adicione re-raise; observe que agora propaga corretamente. Em Go, crie goroutine que ignora ctx.Done(); rode timeout e veja a goroutine sobreviver. Use runtime.NumGoroutine para confirmar leak.

Referências para aprofundar

  1. livro Concurrency in C# Cookbook — Stephen Cleary (2ª ed., 2019). Cap. 10 (Cancellation) é tratamento mais completo de CancellationToken em livro. Cleary também tem série de blog posts sobre o tópico.
  2. livro Concurrency in Go — Katherine Cox-Buday (2017). Cap. 4 cobre context.Context com profundidade. Padrões de cancelamento end-to-end em Go.
  3. livro Asynchronous Programming with Python — Caleb Hattingh (2020). Aborda CancelledError, cancellation propagation e padrões de timeout em asyncio. Atualizado para 3.10+.
  4. artigo Go Concurrency Patterns: Context — Sameer Ajmani (2014). go.dev/blog/context — O artigo que introduziu o padrão. Lê-se em vinte minutos; eminente didática sobre por que e como.
  5. artigo Timeouts and Cancellation for Humans — Nathaniel J. Smith (2018). vorpus.org/blog/timeouts-and-cancellation-for-humans — Smith argumenta por que cancelamento estruturado (TaskGroup, Trio) supera padrão fragmentado. Conexão direta com structured concurrency (conceito 05).
  6. artigo Cooperative cancellation — Stephen Cleary. blog.stephencleary.com — Série explicando CancellationToken bem, incluindo armadilhas comuns e quando usar cada API.
  7. artigo The cancel-and-cleanup pattern in Go — Dave Cheney. dave.cheney.net — Padrões idiomáticos para garantir cleanup correto em código que usa context.
  8. docs Go context package. pkg.go.dev/context — Documentação canônica. Lê-se em meia hora; cobre todas as funções e a justificativa de design.
  9. docs .NET — Cancellation in Managed Threads. learn.microsoft.com/en-us/dotnet/standard/threading/cancellation-in-managed-threads — Documentação oficial do CancellationToken. Cobre patterns e os modos linked.
  10. docs Python — Task Cancellation. docs.python.org/3/library/asyncio-task.html#task-cancellation — Documentação oficial sobre asyncio cancellation. asyncio.timeout (3.11+) e o ciclo de vida.
  11. docs gRPC — Deadlines. grpc.io/docs/guides/deadlines — Como gRPC propaga deadlines automaticamente entre serviços. Boa referência para cancelamento distribuído.
  12. vídeo Cancellation in async code — Stephen Toub (.NET Conf). YouTube. Toub explora cancellation em .NET com profundidade técnica. Inclui state-of-the-art de 2024+ com OperationCanceledException refinements.