Las operaciones que no responden y no pueden interrumpirse son una fuente común de fugas de recursos, mala experiencia de usuario y fallos en cascada en sistemas distribuidos. CancellationToken es la primitiva de cancelación cooperativa de .NET — no mata hilos, señala una intención, y tu código decide cómo reaccionar.
Qué Resuelve CancellationToken
Antes de los tokens de cancelación, detener una operación async requería flags bool compartidos, eventos personalizados o thread-abort (que está roto por diseño). El problema: los llamadores no tenían una forma estándar de decirle a una operación "detente".
CancellationToken establece un contrato:
- Productor (
CancellationTokenSource) decide cuándo cancelar - Consumidor (tu método async) verifica el token y reacciona apropiadamente
- Framework (ASP.NET Core, EF Core,
HttpClient) hace esto automáticamente cuando pasas el token
// Sin cancelación — el llamador no tiene forma de detener esto; la única red de
// seguridad es el Timeout global de HttpClient, 100 segundos por defecto
public async Task<string> FetchDataAsync(string url)
{
var response = await _httpClient.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}
// Con cancelación — respeta la intención del llamador
public async Task<string> FetchDataAsync(string url, CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync(url, cancellationToken);
return await response.Content.ReadAsStringAsync(cancellationToken);
}La idea clave: la cancelación es cooperativa. El token no puede forzar que tu código se detenga. Debes observarlo.
Cada Afirmación Aquí Está Verificada, No Narrada
La cancelación cooperativa tiene una propiedad útil: cada afirmación sobre ella es
comprobable como un hecho duro — ¿se detuvo el bucle?, ¿en qué estado terminó la tarea?,
¿qué tipo de excepción salió?, ¿de quién es el token que lleva?
samples/cancellationtoken-csharp
convierte las afirmaciones de este artículo en aserciones, sin carreras de timing: cada
token se cancela antes del trabajo que debe detener, o el trabajo espera infinitamente
de modo que solo la cancelación puede terminarlo.
| Demo | Verifica |
|---|---|
| Cooperativa | un bucle que nunca lee el token procesa 5/5 elementos después de Cancel(); la versión que lo observa procesa 0/5 y su tarea termina Canceled, no Faulted |
| Drenaje gracioso | un bucle while (!token.IsCancellationRequested) termina naturalmente — el llamador ve RanToCompletion, sin excepción |
| Tokens enlazados | el filtro when (callerToken.IsCancellationRequested) separa "mi timeout" de "el llamador canceló" en ambas direcciones |
Register | el callback se ejecuta exactamente una vez con dos llamadas a Cancel(); una registración dispuesta nunca se ejecuta; registrar en un token ya cancelado ejecuta el callback síncronamente |
Task.Run | con un token pre-cancelado el delegado nunca se ejecuta; await lanza TaskCanceledException llevando el token del llamador |
HttpClient | Timeout → TaskCanceledException con TimeoutException interna; un token del llamador → sin TimeoutException interna, y la excepción lleva el token del llamador |
| Disposición | Cancel() después de Dispose() lanza ObjectDisposedException |

El demo de HttpClient merece una nota: para probar el comportamiento del timeout sin
inestabilidad de red, el sample inicia un TcpListener en un puerto de loopback que
acepta la conexión y nunca responde — una forma determinista de hacer que una solicitud
HTTP se quede colgada.
CancellationTokenSource — Creando Tokens
CancellationTokenSource (CTS) es el controlador. Crea el token y mantiene la capacidad de cancelarlo.
// Cancelación manual básica
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
// Pasar token al trabajo
var workTask = DoWorkAsync(token);
// Cancelar desde otro hilo / acción del usuario
cts.Cancel(); // señala la cancelación; los callbacks registrados corren en ESTE hilo, ahora
// cts.CancelAsync(); // .NET 8+ — devuelve un Task en lugar de ejecutar los callbacks inline
await workTask; // lanza OperationCanceledExceptionCiclo de Vida y Disposición
// CancellationTokenSource implementa IDisposable
// Siempre disponer cuando eres el propietario del source
using var cts = new CancellationTokenSource();
// O en un try/finally si no usas 'using'
var cts = new CancellationTokenSource();
try
{
await DoWorkAsync(cts.Token);
}
finally
{
cts.Dispose(); // libera el WaitHandle interno si fue asignado
}Una corrección de una versión anterior de este artículo: mostraba
await using var cts = new CancellationTokenSource() con la afirmación de que CTS ganó
IAsyncDisposable en .NET 6. No es así — esa línea no compila, cosa que descubrí de
la forma directa al portar los snippets del artículo al proyecto de sample.
CancellationTokenSource es IDisposable normal; usa using.
Llamar a Cancel() después de Dispose() lanza ObjectDisposedException — el demo 7 del sample verifica exactamente esto. Si múltiples componentes comparten un CTS, coordina cuidadosamente la propiedad.
Pasando Tokens a Través de la Cadena de Llamadas
Esta es la práctica más importante. Cada método async que realiza I/O, espera o itera en un bucle debe aceptar y reenviar un CancellationToken.
// Capa de repositorio
public async Task<Order> GetOrderAsync(int orderId, CancellationToken cancellationToken = default)
{
return await _dbContext.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
}
// Capa de servicio — pasa el token hacia abajo
public async Task<OrderDto> ProcessOrderAsync(int orderId, CancellationToken cancellationToken = default)
{
var order = await _orderRepo.GetOrderAsync(orderId, cancellationToken);
// Verificar antes de una operación costosa
cancellationToken.ThrowIfCancellationRequested();
var enriched = await _enrichmentService.EnrichAsync(order, cancellationToken);
return _mapper.Map<OrderDto>(enriched);
}
// Controlador ASP.NET Core — el framework pasa cancellationToken automáticamente
[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);
}El parámetro = default permite a los llamadores que no les importa la cancelación omitirlo — CancellationToken.None es el valor por defecto, que nunca se activa.
La Convención = default
// Estas son equivalentes — CancellationToken.None nunca se activa
await DoWorkAsync();
await DoWorkAsync(CancellationToken.None);
await DoWorkAsync(default);
await DoWorkAsync(default(CancellationToken));Tokens con Timeout — CancelAfter
El caso de uso más común: cancelar una operación si tarda demasiado.
// CancelAfter arma un timer sobre el source existente (disponible desde .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 cancelación del usuario
throw new TimeoutException("La operación superó 30s");
}// Sobrecarga del constructor — establece timeout en la creación
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
await ProcessAsync(cts.Token);
}
catch (OperationCanceledException)
{
_logger.LogWarning("La operación excedió el tiempo de espera de 30 segundos");
throw;
}Agregar un Timeout a un Token Existente
// CreateLinkedTokenSource + CancelAfter: cancela cuando el token upstream se activa
// O cuando el timeout expira, lo que ocurra primero
using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));(Una versión anterior de este artículo llamaba a esto un "TimeoutToken de .NET 8" — no
existe tal API. Ambas piezas son antiguas: CreateLinkedTokenSource llegó con la TPL en
.NET Framework 4, CancelAfter en 4.5.)
Prefiere CancelAfter sobre crear un nuevo CTS con timeout en el constructor cuando ya tienes un CTS — reutiliza la instancia existente en lugar de asignar una nueva.
IsCancellationRequested vs ThrowIfCancellationRequested
Dos formas de verificar la cancelación — cada una tiene su lugar.
public async Task ProcessItemsAsync(IEnumerable<Item> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
// Opción 1: ThrowIfCancellationRequested — lanza OperationCanceledException
// Usar dentro de bucles o entre etapas donde quieres abortar inmediatamente
cancellationToken.ThrowIfCancellationRequested();
await ProcessItemAsync(item, cancellationToken);
}
}public async Task ProcessWithCleanupAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
// Opción 2: IsCancellationRequested — verificación booleana, sin excepción
// Usar cuando quieres hacer limpieza antes de retornar
var batch = await ReadBatchAsync(cancellationToken);
if (batch.Count == 0)
break;
await ProcessBatchAsync(batch, cancellationToken);
}
// Termina naturalmente — el llamador ve una tarea completada, no cancelada
// Apropiado para escenarios de apagado gracioso
}La diferencia entre las dos es visible en Task.Status, y el sample verifica ambos
lados: la versión con ThrowIfCancellationRequested termina en Canceled (la máquina de
estados async trata OperationCanceledException como caso especial — la tarea no queda
Faulted), mientras que el drenaje con verificación booleana termina en
RanToCompletion como si nada hubiera pasado. Elige según cuál de esas dos historias
quieres que vea el llamador.
Cuándo Usar Cada Uno
| Escenario | Recomendado |
|---|---|
| Cuerpo del bucle entre iteraciones | ThrowIfCancellationRequested() |
| Condición del bucle worker | !IsCancellationRequested |
| Antes de trabajo CPU costoso | ThrowIfCancellationRequested() |
| Drenaje gracioso / limpieza | IsCancellationRequested |
| Pasar a APIs awaitable | Pasar el token directamente |
| Después de que completa un awaitable | ThrowIfCancellationRequested() opcional |
// Práctico: verificar antes de trabajo costoso, pasar a través de awaits
public async Task<byte[]> CompressAndUploadAsync(
Stream data,
CancellationToken cancellationToken)
{
// Verificar antes de trabajo intensivo en CPU
cancellationToken.ThrowIfCancellationRequested();
var compressed = await CompressAsync(data, cancellationToken);
// No es necesario verificar de nuevo — CompressAsync ya lo hizo internamente
// Pero si hay una brecha entre awaits sin verificación interna:
cancellationToken.ThrowIfCancellationRequested();
return await _storage.UploadAsync(compressed, cancellationToken);
}Tokens Enlazados — CreateLinkedTokenSource
Los sistemas reales suelen tener múltiples fuentes de cancelación: timeout de solicitud, cancelación del usuario, apagado del servidor. CreateLinkedTokenSource los combina en un único token.
public async Task<SearchResult> SearchAsync(
string query,
CancellationToken requestCancellationToken) // de la solicitud HTTP
{
// Imponer un timeout adicional por operación
// El token enlazado se activa si CUALQUIERA de las fuentes se activa primero
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)
{
// La solicitud no fue cancelada — debe ser nuestro timeout
throw new TimeoutException($"La búsqueda superó 5s para la consulta: {query}");
}
// Si requestCancellationToken se activó, OperationCanceledException se propaga naturalmente
}Esta clasificación es fácil de invertir sutilmente, así que el demo 3 del sample verifica
ambas ramas: con un token del llamador limpio y un timeout activado clasifica "timeout",
y con un llamador cancelado clasifica "llamador" — los mismos filtros catch de arriba.
Múltiples Fuentes Upstream
// Combinar tres fuentes: solicitud + cancelación usuario + apagado global
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
httpContext.RequestAborted, // solicitud HTTP cancelada
userCancellationToken, // acción explícita del usuario
_appLifetime.ApplicationStopping); // servidor apagándose
await DoLongOperationAsync(linkedCts.Token);CreateLinkedTokenSource asigna un nuevo CancellationTokenSource. Siempre dispónlo, especialmente en rutas de alto rendimiento como endpoints API calientes.
Register() — Callbacks de Limpieza
Register() adjunta un callback que se ejecuta cuando se solicita la cancelación. Útil para cancelar operaciones no cancelables o liberar recursos.
public async Task<string> PollWithCallbackAsync(
Func<Task<string?>> pollFunc,
CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<string>(
TaskCreationOptions.RunContinuationsAsynchronously);
// Registrar limpieza: cancelar el TaskCompletionSource cuando el token se active
using var registration = cancellationToken.Register(() =>
{
tcs.TrySetCanceled(cancellationToken);
});
// Iniciar polling en 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 legacy basada en callbacks con cancelación
public Task WaitForEventAsync(LegacyEventSource source, CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void OnEvent(object? sender, EventArgs e) => tcs.TrySetResult();
source.EventOccurred += OnEvent;
// Limpiar suscripción si se cancela
cancellationToken.Register(() =>
{
source.EventOccurred -= OnEvent;
tcs.TrySetCanceled(cancellationToken);
});
return tcs.Task;
}Register() Devuelve un Disposable
// El CancellationTokenRegistration debe ser dispuesto cuando ya no se necesite
// para evitar mantener callbacks vivos más tiempo del necesario
using var registration = cancellationToken.Register(() => DoCleanup());
// Sin 'using', el callback vive hasta que el SOURCE sea dispuesto
// Esto suele estar bien para operaciones de corta duración, pero puede causar fugas para operaciones largasTres comportamientos de Register que vale la pena saber de memoria, todos verificados
por el demo 4 del sample: el callback se ejecuta exactamente una vez aunque Cancel() se
llame dos veces; una registración dispuesta nunca se ejecuta; y registrar en un token ya
cancelado ejecuta el callback síncronamente, en tu hilo actual, dentro de la propia
llamada a Register — lo que significa que un callback que toma un lock puede
provocar un deadlock ahí mismo.
Apagado Gracioso en ASP.NET Core
ASP.NET Core expone eventos del ciclo de vida de la aplicación a través de IHostApplicationLifetime. Estos son instancias de CancellationToken pre-conectadas.
// Program.cs — configurar timeout de apagado
builder.Services.Configure<HostOptions>(options =>
{
// Dar 30s a los servicios en background para terminar antes de forzar el cierre
options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});// Servicio en background — apagado gracioso adecuado
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("Procesamiento de órdenes iniciado");
// stoppingToken es proporcionado por el host — se activa con SIGTERM/Ctrl+C
await foreach (var order in _queue.ReadAllAsync(stoppingToken))
{
try
{
await ProcessOrderAsync(order, stoppingToken);
}
catch (OperationCanceledException)
{
// No registrar como error — esto es esperado en el apagado
_logger.LogInformation("Apagado solicitado, deteniendo procesamiento de órdenes");
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al procesar la orden {OrderId}", order.Id);
// Continuar procesando la siguiente orden
}
}
_logger.LogInformation("Procesamiento de órdenes detenido");
}
private async Task ProcessOrderAsync(Order order, CancellationToken cancellationToken)
{
// Todas las llamadas descendentes reciben el stoppingToken
await _orderService.ValidateAsync(order, cancellationToken);
await _orderService.FulfillAsync(order, cancellationToken);
await _notificationService.SendConfirmationAsync(order, cancellationToken);
}
}IHostApplicationLifetime para Código que No Es BackgroundService
public class DataSyncService
{
private readonly IHostApplicationLifetime _lifetime;
public DataSyncService(IHostApplicationLifetime lifetime)
{
_lifetime = lifetime;
}
public async Task SyncAsync(CancellationToken userCancellationToken)
{
// Combinar cancelación de solicitud del usuario con apagado de la aplicación
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
userCancellationToken,
_lifetime.ApplicationStopping);
await PerformSyncAsync(linkedCts.Token);
}
}Cancelación de Solicitudes en Controladores
// ASP.NET Core enlaza automáticamente CancellationToken a HttpContext.RequestAborted
[ApiController]
[Route("api/[controller]")]
public class ReportsController : ControllerBase
{
[HttpGet("{id}/generate")]
public async Task<IActionResult> GenerateReport(
int id,
CancellationToken cancellationToken) // enlazado automáticamente desde HttpContext.RequestAborted
{
try
{
var report = await _reportService.GenerateAsync(id, cancellationToken);
return Ok(report);
}
catch (OperationCanceledException)
{
// El cliente se desconectó — nadie leerá jamás esta respuesta.
// 499 es una convención de nginx, NO algo que ASP.NET Core produzca o
// maneje por ti; devolverlo aquí solo etiqueta la solicitud en tus
// propios logs y métricas en lugar de un 200 o 500 engañoso.
return StatusCode(499);
}
}
}Cancelando Solicitudes HttpClient
HttpClient acepta CancellationToken en todos los métodos de solicitud. Pásalo siempre.
public class WeatherService
{
private readonly HttpClient _httpClient;
public WeatherService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<WeatherData> GetWeatherAsync(
string city,
CancellationToken cancellationToken)
{
// Cancelar si el llamador cancela O si la solicitud tarda > 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("Respuesta vacía");
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
throw new TimeoutException($"La solicitud de clima para {city} agotó el tiempo");
}
}
}Timeout Global de HttpClient vs Cancelación por Solicitud
// HttpClient.Timeout — aplica a TODAS las solicitudes de esa instancia del cliente
// CancellationToken — por solicitud
// AMBOS emergen como TaskCanceledException (que deriva de OperationCanceledException).
// Desde .NET 5 el timeout lleva una TimeoutException interna — esa es la verificación
// que Microsoft agregó precisamente porque antes los dos casos eran indistinguibles:
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
// Se activó HttpClient.Timeout
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
// El llamador canceló
}El demo 6 del sample verifica ambas ramas contra un servidor local que acepta la conexión
y nunca responde: el caso de Timeout tiene la TimeoutException interna, el caso del
token del llamador no — y en el caso del llamador ex.CancellationToken es igual al
token del llamador (lo verifiqué en .NET 10; también aplica desde .NET 5, donde el token
empezó a propagarse a la excepción).
Cancelación de Consultas EF Core
Entity Framework Core pasa el CancellationToken al driver de la base de datos. La consulta se cancela a nivel de base de datos — sin desperdiciar recursos de BD.
public class ProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
{
_context = context;
}
// Pasar token a todos los métodos async de 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 la consulta de BD
}
public async Task<int> BulkUpdatePricesAsync(
string category,
decimal multiplier,
CancellationToken cancellationToken = default)
{
// ExecuteUpdateAsync cancela si el token se activa durante la operación
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 la cancelación
}
catch (Exception ex) when (attempt < 2)
{
_logger.LogWarning(ex, "El intento {Attempt} falló, reintentando", attempt + 1);
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
}
}
return null;
}
}Combinando Timeout de Operación + Cancelación de Solicitud
Un patrón completo para uso en producción: timeout por operación combinado con la cancelación de la solicitud upstream.
public class ProductSearchService
{
private readonly ISearchIndex _searchIndex;
private readonly ILogger<ProductSearchService> _logger;
// Timeout configurable por tipo de operación
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("Búsqueda cancelada por la solicitud para la consulta: {Query}", request.Query);
throw; // re-lanzar tal cual, propagar upstream
}
catch (OperationCanceledException)
{
// Nuestro timeout se activó, no la cancelación de la solicitud
_logger.LogWarning("La búsqueda agotó el tiempo después de {Timeout}s para la 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)
{
// Las sugerencias no son críticas — devolver vacío en timeout o cancelación
_logger.LogDebug("Las sugerencias agotaron el tiempo o fueron canceladas para el prefijo: {Prefix}", prefix);
return Array.Empty<string>();
}
}
}Errores Comunes
Nunca Suprimir OperationCanceledException
// MAL — oculta la cancelación, el llamador cree que el trabajo completó normalmente
try
{
await DoWorkAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// ¡Suprimido! El llamador no tiene idea de que ocurrió la cancelación
}
// CORRECTO — registrar si es necesario, pero siempre re-lanzar
catch (OperationCanceledException ex)
{
_logger.LogInformation("El trabajo fue cancelado");
throw; // preservar la excepción original y la traza de pila
}No Envolver Innecesariamente
// MAL — pierde la semántica de cancelación
catch (OperationCanceledException ex)
{
throw new ApplicationException("La operación falló", ex); // ¡malo!
}
// CORRECTO — solo envolver si agregas contexto real, y usar un tipo
// que aún comunique cancelación o usar la excepción original
catch (OperationCanceledException)
{
throw; // o dejar que se propague naturalmente
}Evitar Task.Run Sin Reenviar el Token
// MAL — fire-and-forget ignora la cancelación
var task = Task.Run(() => HeavyComputation()); // ¡sin token!
// CORRECTO — token pasado a Task.Run Y al trabajo
var task = Task.Run(() => HeavyComputation(cancellationToken), cancellationToken);
// El token externo cancela la programación de la tarea; el interno cancela el trabajo en síEl efecto del token externo es observable: el demo 5 del sample pasa un token ya
cancelado a Task.Run y verifica que el delegado nunca se ejecuta — la tarea pasa
directamente a Canceled y await lanza TaskCanceledException llevando ese token.
No Usar CancellationToken para Control de Flujo
// MAL — CancellationToken es para cancelación, no para ramificaciones
if (cancellationToken.IsCancellationRequested)
{
return GetCachedResult(); // ¡usando cancelación como "ruta rápida"!
}
// CORRECTO — verificar cancelación, luego lanzar o retornar limpiamente
cancellationToken.ThrowIfCancellationRequested();
return await FetchFreshResult(cancellationToken);Referencia de Decisión
Qué Patrón Usar
| Situación | Patrón |
|---|---|
| Timeout simple en una operación | new CancellationTokenSource(timeout) |
| Agregar timeout a token existente | CreateLinkedTokenSource + CancelAfter |
| Solicitud HTTP cancelada por el cliente | HttpContext.RequestAborted pasado a través |
| Apagado de servicio en background | BackgroundService.stoppingToken |
| Múltiples fuentes de cancelación | CreateLinkedTokenSource(token1, token2, ...) |
| Limpieza en cancelación | cancellationToken.Register(callback) |
| Verificar en bucle cerrado | ThrowIfCancellationRequested() |
| Drenaje gracioso sin excepción | IsCancellationRequested como condición del bucle |
Decisión de Manejo de Excepciones
| Escenario | Acción |
|---|---|
| Cancelación esperada (cliente desconectado) | Capturar, registrar debug/info, devolver 499 o vacío |
| Timeout es excepcional para esta operación | Capturar, envolver en excepción de dominio, registrar warning |
| Worker en background cancelado en apagado | Capturar, registrar info, salir del bucle limpiamente |
| Propagando a través de middleware/pipeline | Re-lanzar (throw;) sin envolver |
| Bucle de reintentos | Capturar otras excepciones, re-lanzar cancelación |
Resumen
CancellationToken funciona bien cuando lo tratas de forma consistente: aceptarlo en cada método async, reenviarlo a cada llamada awaitable, verificar entre etapas con ThrowIfCancellationRequested, y nunca suprimir OperationCanceledException. El patrón de token enlazado maneja la necesidad real de combinar múltiples fuentes de cancelación — ciclo de vida de la solicitud, timeout por operación y apagado de la aplicación — en un único token que cualquier librería descendente puede usar sin conocer el origen.
La diferencia entre un servicio resiliente y uno que pierde recursos bajo carga frecuentemente se reduce a si la cancelación está conectada de extremo a extremo.
Cada afirmación de comportamiento de arriba está verificada por
samples/cancellationtoken-csharp
— clónalo y ejecuta dotnet run si quieres ver alguna fallar en un runtime futuro.
La cancelación se solapa con dos temas vecinos que vale la pena leer a continuación.
OperationCanceledException es una excepción como cualquier otra, así que las reglas de
manejo de excepciones en async — en particular
alrededor de Task.WhenAll y el trabajo fire-and-forget — aplican directamente a las
operaciones canceladas. Y si estás transmitiendo resultados en lugar de devolver una
lista, IAsyncEnumerable<T> tiene su propio mecanismo de
cancelación que no se comporta igual que un método async normal.