//JorgenHoc
← Todos os artigos
Async C#Por Jorge CalderónAtualizado 19 min read

CancellationToken em C# — Padrões Práticos

CancellationToken em C# com cada afirmação verificada por um sample executável: cancelamento cooperativo, distinguir timeout de cancelamento do chamador, tokens vinculados, semântica de Register e desligamento gracioso no ASP.NET Core.

#csharp#async#dotnet

Operações sem resposta que não podem ser interrompidas são uma fonte comum de vazamentos de recursos, má experiência do usuário e falhas em cascata em sistemas distribuídos. CancellationToken é a primitiva de cancelamento cooperativo do .NET — ele não mata threads, sinaliza uma intenção, e seu código decide como reagir.

O Que CancellationToken Resolve

Antes dos tokens de cancelamento, parar uma operação async exigia flags bool compartilhadas, eventos personalizados ou thread-abort (que é quebrado por design). O problema: os chamadores não tinham uma forma padrão de dizer a uma operação "pare o que está fazendo".

CancellationToken estabelece um contrato:

  • Produtor (CancellationTokenSource) decide quando cancelar
  • Consumidor (seu método async) verifica o token e reage apropriadamente
  • Framework (ASP.NET Core, EF Core, HttpClient) faz isso automaticamente quando você passa o token
// Sem cancelamento — o chamador não tem como parar isto; a única rede de
// segurança é o Timeout global do HttpClient, 100 segundos por padrão
public async Task<string> FetchDataAsync(string url)
{
    var response = await _httpClient.GetAsync(url);
    return await response.Content.ReadAsStringAsync();
}
 
// Com cancelamento — respeita a intenção do chamador
public async Task<string> FetchDataAsync(string url, CancellationToken cancellationToken)
{
    var response = await _httpClient.GetAsync(url, cancellationToken);
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

O insight principal: o cancelamento é cooperativo. O token não pode forçar seu código a parar. Você deve observá-lo.

Cada Afirmação Aqui É Verificada, Não Narrada

O cancelamento cooperativo tem uma propriedade útil: cada afirmação sobre ele é verificável como um fato concreto — o loop parou?, em que estado a tarefa terminou?, que tipo de exceção saiu?, de quem é o token que ela carrega? samples/cancellationtoken-csharp transforma as afirmações deste artigo em asserções, sem corridas de timing: cada token é cancelado antes do trabalho que deveria parar, ou o trabalho espera infinitamente de modo que só o cancelamento pode encerrá-lo.

DemoVerifica
Cooperativoum loop que nunca lê o token processa 5/5 itens depois de Cancel(); a versão que o observa processa 0/5 e sua tarefa termina Canceled, não Faulted
Drenagem graciosaum loop while (!token.IsCancellationRequested) termina naturalmente — o chamador vê RanToCompletion, sem exceção
Tokens vinculadoso filtro when (callerToken.IsCancellationRequested) separa "meu timeout" de "o chamador cancelou" nas duas direções
Registero callback dispara exatamente uma vez com duas chamadas a Cancel(); um registro descartado nunca dispara; registrar em um token já cancelado executa o callback sincronamente
Task.Runcom um token pré-cancelado o delegate nunca executa; await lança TaskCanceledException carregando o token do chamador
HttpClientTimeoutTaskCanceledException com TimeoutException interna; um token do chamador → sem TimeoutException interna, e a exceção carrega o token do chamador
DescarteCancel() depois de Dispose() lança ObjectDisposedException
Saída de console do sample: um loop que ignora um token cancelado processa 5 de 5 itens enquanto a versão que o observa processa 0 e termina Canceled; um loop de drenagem com verificação booleana termina RanToCompletion; tokens vinculados classificam timeout versus cancelamento do chamador nos dois ramos; Register dispara exatamente uma vez, um registro descartado nunca dispara, e registrar em um token já cancelado executa sincronamente; Task.Run com um token pré-cancelado nunca executa o delegate; HttpClient.Timeout produz TaskCanceledException com TimeoutException interna enquanto um token do chamador não; e Cancel depois de Dispose lança ObjectDisposedException. Todas as verificações passaram.
A execução do sample: 16 asserções, terminando com o resumo de uma linha — o token nunca para nada; quem para é o código que o observa.

O demo do HttpClient merece uma nota: para provar o comportamento do timeout sem instabilidade de rede, o sample inicia um TcpListener em uma porta de loopback que aceita a conexão e nunca responde — uma forma determinística de fazer uma requisição HTTP ficar pendurada.

CancellationTokenSource — Criando Tokens

CancellationTokenSource (CTS) é o controlador. Ele cria o token e mantém a capacidade de cancelá-lo.

// Cancelamento manual básico
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
 
// Passar token para o trabalho
var workTask = DoWorkAsync(token);
 
// Cancelar de outra thread / ação do usuário
cts.Cancel(); // sinaliza o cancelamento; os callbacks registrados rodam NESTA thread, agora
// cts.CancelAsync(); // .NET 8+ — retorna um Task em vez de executar os callbacks inline
 
await workTask; // lança OperationCanceledException

Ciclo de Vida e Descarte

// CancellationTokenSource implementa IDisposable
// Sempre descartar quando você é o proprietário do source
using var cts = new CancellationTokenSource();
 
// Ou em um try/finally se não usar 'using'
var cts = new CancellationTokenSource();
try
{
    await DoWorkAsync(cts.Token);
}
finally
{
    cts.Dispose(); // libera o WaitHandle interno se alocado
}

Uma correção de uma versão anterior deste artigo: ele mostrava await using var cts = new CancellationTokenSource() com a afirmação de que o CTS ganhou IAsyncDisposable no .NET 6. Não ganhou — essa linha não compila, o que descobri da forma direta ao portar os snippets do artigo para o projeto de sample. CancellationTokenSource é IDisposable comum; use using.

⚠️

Chamar Cancel() após Dispose() lança ObjectDisposedException — o demo 7 do sample verifica exatamente isso. Se múltiplos componentes compartilham um CTS, coordene cuidadosamente a propriedade.

Passando Tokens Pela Cadeia de Chamadas

Esta é a prática mais importante. Todo método async que realiza I/O, aguarda ou itera em um loop deve aceitar e encaminhar um CancellationToken.

// Camada de repositório
public async Task<Order> GetOrderAsync(int orderId, CancellationToken cancellationToken = default)
{
    return await _dbContext.Orders
        .Include(o => o.Items)
        .FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
}
 
// Camada de serviço — passa o token adiante
public async Task<OrderDto> ProcessOrderAsync(int orderId, CancellationToken cancellationToken = default)
{
    var order = await _orderRepo.GetOrderAsync(orderId, cancellationToken);
    
    // Verificar antes de operação custosa
    cancellationToken.ThrowIfCancellationRequested();
    
    var enriched = await _enrichmentService.EnrichAsync(order, cancellationToken);
    return _mapper.Map<OrderDto>(enriched);
}
 
// Controlador ASP.NET Core — o framework passa cancellationToken automaticamente
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken cancellationToken)
{
    var order = await _orderService.ProcessOrderAsync(id, cancellationToken);
    return order is null ? NotFound() : Ok(order);
}

O parâmetro = default permite que chamadores que não se importam com o cancelamento o omitam — CancellationToken.None é o padrão, que nunca dispara.

A Convenção = default

// Estas são equivalentes — CancellationToken.None nunca dispara
await DoWorkAsync();
await DoWorkAsync(CancellationToken.None);
await DoWorkAsync(default);
await DoWorkAsync(default(CancellationToken));

Tokens com Timeout — CancelAfter

O caso de uso mais comum: cancelar uma operação se demorar muito.

// CancelAfter arma um timer sobre o source existente (disponível desde o .NET Framework 4.5)
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(30));
 
try
{
    var result = await FetchDataAsync(cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
    // Distingue timeout de cancelamento do usuário
    throw new TimeoutException("Operação excedeu 30s");
}
// Sobrecarga do construtor — define timeout na criação
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
 
try
{
    await ProcessAsync(cts.Token);
}
catch (OperationCanceledException)
{
    _logger.LogWarning("Operação excedeu o tempo limite de 30 segundos");
    throw;
}

Adicionando um Timeout a um Token Existente

// CreateLinkedTokenSource + CancelAfter: cancela quando o token upstream dispara
// OU quando o timeout expira, o que acontecer primeiro
using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));

(Uma versão anterior deste artigo chamava isto de "TimeoutToken do .NET 8" — essa API não existe. As duas peças são antigas: CreateLinkedTokenSource chegou com a TPL no .NET Framework 4, CancelAfter no 4.5.)

💡

Prefira CancelAfter a criar um novo CTS com timeout no construtor quando você já tem um CTS — ele reutiliza a instância existente em vez de alocar uma nova.

IsCancellationRequested vs ThrowIfCancellationRequested

Duas formas de verificar o cancelamento — cada uma tem seu lugar.

public async Task ProcessItemsAsync(IEnumerable<Item> items, CancellationToken cancellationToken)
{
    foreach (var item in items)
    {
        // Opção 1: ThrowIfCancellationRequested — lança OperationCanceledException
        // Usar dentro de loops ou entre etapas onde você quer abortar imediatamente
        cancellationToken.ThrowIfCancellationRequested();
        
        await ProcessItemAsync(item, cancellationToken);
    }
}
public async Task ProcessWithCleanupAsync(CancellationToken cancellationToken)
{
    while (!cancellationToken.IsCancellationRequested)
    {
        // Opção 2: IsCancellationRequested — verificação booleana, sem exceção
        // Usar quando você quer fazer limpeza antes de retornar
        var batch = await ReadBatchAsync(cancellationToken);
        
        if (batch.Count == 0)
            break;
            
        await ProcessBatchAsync(batch, cancellationToken);
    }
    
    // Termina naturalmente — o chamador vê uma tarefa concluída, não cancelada
    // Apropriado para cenários de desligamento gracioso
}

A diferença entre os dois é visível em Task.Status, e o sample verifica os dois lados: a versão com ThrowIfCancellationRequested termina em Canceled (a máquina de estados async trata OperationCanceledException como caso especial — a tarefa não fica Faulted), enquanto a drenagem com verificação booleana termina em RanToCompletion como se nada tivesse acontecido. Escolha conforme qual dessas duas histórias você quer que o chamador veja.

Quando Usar Cada Um

CenárioRecomendado
Corpo do loop entre iteraçõesThrowIfCancellationRequested()
Condição do loop worker!IsCancellationRequested
Antes de trabalho CPU custosoThrowIfCancellationRequested()
Drenagem graciosa / limpezaIsCancellationRequested
Passar para APIs awaitablePassar o token diretamente
Após um awaitable concluirThrowIfCancellationRequested() opcional
// Prático: verificar antes de trabalho custoso, passar através de awaits
public async Task<byte[]> CompressAndUploadAsync(
    Stream data,
    CancellationToken cancellationToken)
{
    // Verificar antes de trabalho intensivo em CPU
    cancellationToken.ThrowIfCancellationRequested();
    
    var compressed = await CompressAsync(data, cancellationToken);
    
    // Não é necessário verificar novamente — CompressAsync já fez isso internamente
    // Mas se houver uma lacuna entre awaits sem verificação interna:
    cancellationToken.ThrowIfCancellationRequested();
    
    return await _storage.UploadAsync(compressed, cancellationToken);
}

Tokens Vinculados — CreateLinkedTokenSource

Sistemas reais frequentemente têm múltiplas fontes de cancelamento: timeout da requisição, cancelamento do usuário, desligamento do servidor. CreateLinkedTokenSource os combina em um único token.

public async Task<SearchResult> SearchAsync(
    string query,
    CancellationToken requestCancellationToken) // da requisição HTTP
{
    // Impor um timeout adicional por operação
    // O token vinculado dispara se QUALQUER fonte disparar primeiro
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        requestCancellationToken,
        timeoutCts.Token);
 
    try
    {
        return await _searchEngine.SearchAsync(query, linkedCts.Token);
    }
    catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
    {
        // A requisição não foi cancelada — deve ser nosso timeout
        throw new TimeoutException($"Busca excedeu 5s para a consulta: {query}");
    }
    // Se requestCancellationToken disparou, OperationCanceledException se propaga naturalmente
}

Essa classificação é fácil de inverter sutilmente, então o demo 3 do sample verifica os dois ramos: com um token do chamador limpo e um timeout disparado ele classifica "timeout", e com um chamador cancelado classifica "chamador" — os mesmos filtros catch acima.

Múltiplas Fontes Upstream

// Combinar três fontes: requisição + cancelamento do usuário + desligamento global
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
    httpContext.RequestAborted,        // requisição HTTP cancelada
    userCancellationToken,             // ação explícita do usuário
    _appLifetime.ApplicationStopping); // servidor encerrando
 
await DoLongOperationAsync(linkedCts.Token);
⚠️

CreateLinkedTokenSource aloca um novo CancellationTokenSource. Sempre descarte-o, especialmente em caminhos de alto throughput como endpoints API frequentes.

Register() — Callbacks de Limpeza

Register() anexa um callback que dispara quando o cancelamento é solicitado. Útil para cancelar operações não canceláveis ou liberar recursos.

public async Task<string> PollWithCallbackAsync(
    Func<Task<string?>> pollFunc,
    CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource<string>(
        TaskCreationOptions.RunContinuationsAsynchronously);
 
    // Registrar limpeza: cancelar o TaskCompletionSource quando o token disparar
    using var registration = cancellationToken.Register(() =>
    {
        tcs.TrySetCanceled(cancellationToken);
    });
 
    // Iniciar polling em background
    _ = Task.Run(async () =>
    {
        while (!tcs.Task.IsCompleted)
        {
            var result = await pollFunc();
            if (result is not null)
            {
                tcs.TrySetResult(result);
                return;
            }
            await Task.Delay(500); // intervalo de polling
        }
    });
 
    return await tcs.Task;
}
// Conectar API legada baseada em callbacks ao cancelamento
public Task WaitForEventAsync(LegacyEventSource source, CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
    void OnEvent(object? sender, EventArgs e) => tcs.TrySetResult();
 
    source.EventOccurred += OnEvent;
 
    // Limpar inscrição se cancelado
    cancellationToken.Register(() =>
    {
        source.EventOccurred -= OnEvent;
        tcs.TrySetCanceled(cancellationToken);
    });
 
    return tcs.Task;
}

Register() Retorna um Descartável

// O CancellationTokenRegistration deve ser descartado quando não for mais necessário
// para evitar manter callbacks vivos por mais tempo do que o necessário
using var registration = cancellationToken.Register(() => DoCleanup());
 
// Sem 'using', o callback vive até que o SOURCE seja descartado
// Geralmente está ok para operações de curta duração, mas pode vazar em operações longas

Três comportamentos de Register que vale a pena saber de cor, todos verificados pelo demo 4 do sample: o callback dispara exatamente uma vez mesmo que Cancel() seja chamado duas vezes; um registro descartado nunca dispara; e registrar em um token já cancelado executa o callback sincronamente, na sua thread atual, dentro da própria chamada a Register — o que significa que um callback que pega um lock pode causar um deadlock ali mesmo.

Desligamento Gracioso no ASP.NET Core

O ASP.NET Core expõe eventos do ciclo de vida da aplicação através de IHostApplicationLifetime. Estes são instâncias de CancellationToken pré-configuradas.

// Program.cs — configurar timeout de desligamento
builder.Services.Configure<HostOptions>(options =>
{
    // Dar 30s para os serviços em background terminarem antes de forçar o encerramento
    options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});
// Serviço em background — desligamento gracioso adequado
public class OrderProcessingService : BackgroundService
{
    private readonly ILogger<OrderProcessingService> _logger;
    private readonly IOrderQueue _queue;
 
    public OrderProcessingService(ILogger<OrderProcessingService> logger, IOrderQueue queue)
    {
        _logger = logger;
        _queue = queue;
    }
 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Processamento de pedidos iniciado");
 
        // stoppingToken é fornecido pelo host — dispara no SIGTERM/Ctrl+C
        await foreach (var order in _queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                await ProcessOrderAsync(order, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                // Não registrar como erro — isso é esperado no desligamento
                _logger.LogInformation("Desligamento solicitado, parando processamento de pedidos");
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Falha ao processar pedido {OrderId}", order.Id);
                // Continuar processando o próximo pedido
            }
        }
 
        _logger.LogInformation("Processamento de pedidos parado");
    }
 
    private async Task ProcessOrderAsync(Order order, CancellationToken cancellationToken)
    {
        // Todas as chamadas descendentes recebem o stoppingToken
        await _orderService.ValidateAsync(order, cancellationToken);
        await _orderService.FulfillAsync(order, cancellationToken);
        await _notificationService.SendConfirmationAsync(order, cancellationToken);
    }
}

IHostApplicationLifetime para Código que Não É BackgroundService

public class DataSyncService
{
    private readonly IHostApplicationLifetime _lifetime;
    
    public DataSyncService(IHostApplicationLifetime lifetime)
    {
        _lifetime = lifetime;
    }
 
    public async Task SyncAsync(CancellationToken userCancellationToken)
    {
        // Combinar cancelamento de requisição do usuário com desligamento da aplicação
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            userCancellationToken,
            _lifetime.ApplicationStopping);
 
        await PerformSyncAsync(linkedCts.Token);
    }
}

Cancelamento de Requisições em Controllers

// ASP.NET Core vincula automaticamente CancellationToken a HttpContext.RequestAborted
[ApiController]
[Route("api/[controller]")]
public class ReportsController : ControllerBase
{
    [HttpGet("{id}/generate")]
    public async Task<IActionResult> GenerateReport(
        int id,
        CancellationToken cancellationToken) // vinculado automaticamente de HttpContext.RequestAborted
    {
        try
        {
            var report = await _reportService.GenerateAsync(id, cancellationToken);
            return Ok(report);
        }
        catch (OperationCanceledException)
        {
            // Cliente desconectou — ninguém jamais lerá esta resposta.
            // 499 é uma convenção do nginx, NÃO algo que o ASP.NET Core produza ou
            // trate por você; retorná-lo aqui apenas rotula a requisição nos seus
            // próprios logs e métricas em vez de um 200 ou 500 enganoso.
            return StatusCode(499);
        }
    }
}

Cancelando Requisições HttpClient

HttpClient aceita CancellationToken em todos os métodos de requisição. Passe-o sempre.

public class WeatherService
{
    private readonly HttpClient _httpClient;
 
    public WeatherService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }
 
    public async Task<WeatherData> GetWeatherAsync(
        string city,
        CancellationToken cancellationToken)
    {
        // Cancelar se o chamador cancelar OU se a requisição demorar > 10s
        using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cancellationToken,
            timeoutCts.Token);
 
        try
        {
            var response = await _httpClient.GetAsync(
                $"/weather/{city}",
                linkedCts.Token);
 
            response.EnsureSuccessStatusCode();
 
            return await response.Content.ReadFromJsonAsync<WeatherData>(linkedCts.Token)
                ?? throw new InvalidOperationException("Resposta vazia");
        }
        catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
        {
            throw new TimeoutException($"Requisição de clima para {city} excedeu o tempo limite");
        }
    }
}

Timeout Global do HttpClient vs Cancelamento por Requisição

// HttpClient.Timeout — aplica a TODAS as requisições daquela instância do cliente
// CancellationToken — por requisição
// AMBOS emergem como TaskCanceledException (que deriva de OperationCanceledException).
// Desde o .NET 5 o timeout carrega uma TimeoutException interna — essa é a verificação
// que a Microsoft adicionou justamente porque antes os dois casos eram indistinguíveis:
 
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
    // HttpClient.Timeout disparou
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
    // Chamador cancelou
}

O demo 6 do sample verifica os dois ramos contra um servidor local que aceita a conexão e nunca responde: o caso do Timeout tem a TimeoutException interna, o caso do token do chamador não — e no caso do chamador ex.CancellationToken é igual ao token do chamador (verifiquei no .NET 10; também vale desde o .NET 5, quando o token passou a ser propagado para a exceção).

Cancelamento de Consultas EF Core

O Entity Framework Core passa o CancellationToken para o driver do banco de dados. A consulta é cancelada no nível do banco de dados — sem desperdiçar recursos de BD.

public class ProductRepository
{
    private readonly AppDbContext _context;
 
    public ProductRepository(AppDbContext context)
    {
        _context = context;
    }
 
    // Passar token para todos os métodos async do EF Core
    public async Task<List<Product>> GetActiveProductsAsync(
        string category,
        CancellationToken cancellationToken = default)
    {
        return await _context.Products
            .Where(p => p.Category == category && p.IsActive)
            .OrderBy(p => p.Name)
            .AsNoTracking()
            .ToListAsync(cancellationToken); // cancela a consulta no BD
    }
 
    public async Task<int> BulkUpdatePricesAsync(
        string category,
        decimal multiplier,
        CancellationToken cancellationToken = default)
    {
        // ExecuteUpdateAsync cancela se o token disparar durante a operação
        return await _context.Products
            .Where(p => p.Category == category)
            .ExecuteUpdateAsync(
                setters => setters.SetProperty(p => p.Price, p => p.Price * multiplier),
                cancellationToken);
    }
 
    public async Task<Product?> FindWithRetryAsync(
        int id,
        CancellationToken cancellationToken = default)
    {
        for (int attempt = 0; attempt < 3; attempt++)
        {
            try
            {
                return await _context.Products.FindAsync(
                    new object[] { id },
                    cancellationToken);
            }
            catch (OperationCanceledException)
            {
                throw; // nunca suprimir o cancelamento
            }
            catch (Exception ex) when (attempt < 2)
            {
                _logger.LogWarning(ex, "Tentativa {Attempt} falhou, tentando novamente", attempt + 1);
                await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
            }
        }
 
        return null;
    }
}

Combinando Timeout de Operação + Cancelamento de Requisição

Um padrão completo para uso em produção: timeout por operação combinado com o cancelamento da requisição upstream.

public class ProductSearchService
{
    private readonly ISearchIndex _searchIndex;
    private readonly ILogger<ProductSearchService> _logger;
 
    // Timeout configurável por tipo de operação
    private static readonly TimeSpan SearchTimeout = TimeSpan.FromSeconds(3);
    private static readonly TimeSpan SuggestTimeout = TimeSpan.FromMilliseconds(500);
 
    public ProductSearchService(ISearchIndex searchIndex, ILogger<ProductSearchService> logger)
    {
        _searchIndex = searchIndex;
        _logger = logger;
    }
 
    public async Task<SearchResult> SearchAsync(
        SearchRequest request,
        CancellationToken requestCancellationToken = default)
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestCancellationToken);
        cts.CancelAfter(SearchTimeout);
 
        try
        {
            return await _searchIndex.SearchAsync(request, cts.Token);
        }
        catch (OperationCanceledException) when (requestCancellationToken.IsCancellationRequested)
        {
            _logger.LogInformation("Busca cancelada pela requisição para a consulta: {Query}", request.Query);
            throw; // relançar como está, propagar upstream
        }
        catch (OperationCanceledException)
        {
            // Nosso timeout disparou, não o cancelamento da requisição
            _logger.LogWarning("Busca excedeu o tempo limite após {Timeout}s para a consulta: {Query}",
                SearchTimeout.TotalSeconds, request.Query);
            throw new SearchTimeoutException(request.Query, SearchTimeout);
        }
    }
 
    public async Task<IReadOnlyList<string>> GetSuggestionsAsync(
        string prefix,
        CancellationToken requestCancellationToken = default)
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestCancellationToken);
        cts.CancelAfter(SuggestTimeout);
 
        try
        {
            return await _searchIndex.GetSuggestionsAsync(prefix, cts.Token);
        }
        catch (OperationCanceledException)
        {
            // Sugestões não são críticas — retornar vazio em timeout ou cancelamento
            _logger.LogDebug("Sugestões excederam o tempo ou foram canceladas para o prefixo: {Prefix}", prefix);
            return Array.Empty<string>();
        }
    }
}

Erros Comuns

Nunca Suprimir OperationCanceledException

// ERRADO — esconde o cancelamento, o chamador acha que o trabalho foi concluído normalmente
try
{
    await DoWorkAsync(cancellationToken);
}
catch (OperationCanceledException)
{
    // Suprimido! O chamador não tem ideia de que o cancelamento ocorreu
}
 
// CORRETO — registrar se necessário, mas sempre relançar
catch (OperationCanceledException ex)
{
    _logger.LogInformation("Trabalho foi cancelado");
    throw; // preservar a exceção original e o stack trace
}

Não Envolver Desnecessariamente

// ERRADO — perde a semântica de cancelamento
catch (OperationCanceledException ex)
{
    throw new ApplicationException("Operação falhou", ex); // ruim!
}
 
// CORRETO — envolver apenas se estiver adicionando contexto real, e usar um tipo
// que ainda comunique cancelamento ou usar a exceção original
catch (OperationCanceledException)
{
    throw; // ou deixar propagar naturalmente
}

Evitar Task.Run Sem Encaminhar o Token

// ERRADO — fire-and-forget ignora o cancelamento
var task = Task.Run(() => HeavyComputation()); // sem token!
 
// CORRETO — token passado para Task.Run E para o trabalho
var task = Task.Run(() => HeavyComputation(cancellationToken), cancellationToken);
// O token externo cancela o agendamento da tarefa; o interno cancela o trabalho em si

O efeito do token externo é observável: o demo 5 do sample passa um token já cancelado para Task.Run e verifica que o delegate nunca executa — a tarefa vai direto para Canceled e await lança TaskCanceledException carregando esse token.

Não Usar CancellationToken para Controle de Fluxo

// ERRADO — CancellationToken é para cancelamento, não para ramificações
if (cancellationToken.IsCancellationRequested)
{
    return GetCachedResult(); // usando cancelamento como "caminho rápido"!
}
 
// CORRETO — verificar cancelamento, então lançar ou retornar limpo
cancellationToken.ThrowIfCancellationRequested();
return await FetchFreshResult(cancellationToken);

Referência de Decisão

Qual Padrão Usar

SituaçãoPadrão
Timeout simples em uma operaçãonew CancellationTokenSource(timeout)
Adicionar timeout a token existenteCreateLinkedTokenSource + CancelAfter
Requisição HTTP cancelada pelo clienteHttpContext.RequestAborted passado adiante
Desligamento de serviço em backgroundBackgroundService.stoppingToken
Múltiplas fontes de cancelamentoCreateLinkedTokenSource(token1, token2, ...)
Limpeza no cancelamentocancellationToken.Register(callback)
Verificar em loop fechadoThrowIfCancellationRequested()
Drenagem graciosa sem exceçãoIsCancellationRequested como condição do loop

Decisão de Tratamento de Exceções

CenárioAção
Cancelamento esperado (cliente desconectado)Capturar, registrar debug/info, retornar 499 ou vazio
Timeout é excepcional para esta operaçãoCapturar, envolver em exceção de domínio, registrar warning
Worker em background cancelado no desligamentoCapturar, registrar info, sair do loop limpo
Propagando por middleware/pipelineRelançar (throw;) sem envolver
Loop de retryCapturar outras exceções, relançar cancelamento

Resumo

CancellationToken funciona bem quando você o trata de forma consistente: aceitá-lo em todo método async, encaminhá-lo para toda chamada awaitable, verificar entre etapas com ThrowIfCancellationRequested, e nunca suprimir OperationCanceledException. O padrão de token vinculado lida com a necessidade real de combinar múltiplas fontes de cancelamento — ciclo de vida da requisição, timeout por operação e desligamento da aplicação — em um único token que qualquer biblioteca descendente pode usar sem conhecer a origem.

A diferença entre um serviço resiliente e um que vaza recursos sob carga muitas vezes se resume a se o cancelamento está conectado de ponta a ponta.

Cada afirmação de comportamento acima é verificada por samples/cancellationtoken-csharp — clone-o e execute dotnet run se quiser ver alguma falhar em um runtime futuro.

O cancelamento se sobrepõe a dois temas vizinhos que valem a leitura em seguida. OperationCanceledException é uma exceção como qualquer outra, então as regras de tratamento de exceções em async — em especial em torno de Task.WhenAll e trabalho fire-and-forget — se aplicam diretamente a operações canceladas. E se você está transmitindo resultados em vez de retornar uma lista, IAsyncEnumerable<T> tem seu próprio mecanismo de cancelamento, que não se comporta como um método async comum.

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados