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:
-
Source: a entidade que pode emitir cancelamento.
Em .NET,
CancellationTokenSource; em Go, funçãocancelretornada porcontext.WithCancel; em Python, a Task pai comtask.cancel(). -
Token / Context: objeto passado por valor
que carrega "quero ser notificado se cancelar". Em .NET,
CancellationToken; em Go,context.Context; em Python, implícito no Task atual e emasyncio.timeout. - Verificação cooperativa: o código que pode ser cancelado verifica o token periodicamente. Em loops longos, em pontos de I/O, em chamadas que aceitam token explicitamente.
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).
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.
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.
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.
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:
- Cliente fecha conexão ou cancela request quando timeout local.
-
Servidor monitora
request.Context().Done()em Go, ouHttpContext.RequestAbortedem ASP.NET, ou similares em outras stacks. - Servidor propaga para suas próprias chamadas (banco, RPCs downstream).
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
- 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.
-
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) ouasyncio.wait FIRST_COMPLETED(Python) ou select sobre canais (Go). Garanta que a chamada primária é cancelada quando o fallback é usado — recursos não vazam. -
Force um leak de cancelamento e diagnostique.
Em Python, escreva
try/except CancelledErrorque 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 ignoractx.Done(); rode timeout e veja a goroutine sobreviver. Useruntime.NumGoroutinepara confirmar leak.
Referências para aprofundar
- livro Concurrency in C# Cookbook — Stephen Cleary (2ª ed., 2019).
- livro Concurrency in Go — Katherine Cox-Buday (2017).
- livro Asynchronous Programming with Python — Caleb Hattingh (2020).
- artigo Go Concurrency Patterns: Context — Sameer Ajmani (2014).
- artigo Timeouts and Cancellation for Humans — Nathaniel J. Smith (2018).
- artigo Cooperative cancellation — Stephen Cleary.
- artigo The cancel-and-cleanup pattern in Go — Dave Cheney.
- docs Go context package.
- docs .NET — Cancellation in Managed Threads.
- docs Python — Task Cancellation.
- docs gRPC — Deadlines.
- vídeo Cancellation in async code — Stephen Toub (.NET Conf).