CancellationToken in ASP.NET Core: se il client se ne va, il server deve saperlo
L’utente chiude la pagina o annulla una ricerca: il browser interrompe la richiesta. Ma se l’endpoint non lo sa, la query sul database continua fino alla fine. CPU, connessioni e memoria vengono spese per una risposta che nessuno leggerà.
Su un endpoint di ricerca molto usato questo non è un dettaglio. Basta che qualche utente digiti in fretta, con una richiesta per ogni tasto, e il database si ritrova a eseguire query di cui tutti i risultati, tranne l’ultimo, sono già stati scartati.
E non si tratta solo di CPU: ogni query occupa una connessione. Che il limite sia il pool dell’applicazione o il numero di sessioni che il database accetta, on-premise o in cloud, le connessioni sono comunque limitate. Quando sono tutte impegnate, anche le richieste nuove, quelle che qualcuno sta aspettando, restano in coda.
Il punto di partenza
Un controller come tanti: riceve un filtro, chiama il servizio, restituisce il risultato.
[HttpGet]
public async Task<IActionResult> Search([FromQuery] OrderFilter filter)
{
var result = await orders.SearchAsync(filter);
return Ok(result);
}
Servizio e repository arrivano fino al database, ma nessuno sa che il client ha chiuso la connessione.
Come funziona: nessuno viene fermato a forza
In .NET l’annullamento è cooperativo. Chi decide di annullare ha un
CancellationTokenSource; chi lavora riceve solo il CancellationToken, che può
osservare ma non attivare. Annullare non interrompe niente: segna il token e avvisa
chi si è registrato. Il lavoro si ferma solo dove qualcuno controlla.
Le API asincrone di .NET (EF Core, HttpClient, gli stream) lo controllano da sole:
basta passargli il token. Nel codice nostro, per esempio in un ciclo lungo, lo
controlliamo noi:
foreach (var row in rows)
{
ct.ThrowIfCancellationRequested();
Process(row);
}
Per questo il token va passato a mano lungo tutta la catena: non c’è un meccanismo che lo propaghi da solo.
1. Nel controller il token arriva gratis
Basta dichiarare un parametro di tipo CancellationToken. ASP.NET Core lo collega da
solo a HttpContext.RequestAborted, che si attiva quando il client chiude la
connessione. Lo stesso vale per un parametro CancellationToken in una Minimal API.
[HttpGet]
public async Task<IActionResult> Search(
[FromQuery] OrderFilter filter, CancellationToken ct)
{
var result = await orders.SearchAsync(filter, ct);
return Ok(result);
}
Il server se ne accorge quando la connessione si chiude; con HTTP/2 basta che il client annulli la singola richiesta. Dietro un reverse proxy dipende dal proxy: il backend lo scopre solo se il proxy, a sua volta, chiude la richiesta verso di lui.
2. Ogni layer lo inoltra
Il token deve stare nella firma di ogni metodo che attraversa, interfacce comprese. Se un layer se lo dimentica, da lì in giù nessuno sa più dell’annullamento: il lavoro va avanti fino alla fine, anche se il client se n’è già andato.
public sealed class OrderService(IOrderRepository repo) : IOrderService
{
public async Task<IReadOnlyList<OrderDto>> SearchAsync(
OrderFilter filter, CancellationToken ct)
{
var list = await repo.SearchAsync(filter, ct);
return [.. list.Select(o => o.ToDto())];
}
}
3. Fino alla query
Con Entity Framework Core l’ultimo passaggio è il più importante: il token passato a
ToListAsync arriva al driver del database, che annulla la query in corso.
public sealed class OrderRepository(AppDbContext db) : IOrderRepository
{
public Task<List<Order>> SearchAsync(OrderFilter f, CancellationToken ct) =>
db.Orders
.AsNoTracking()
.Where(o => o.Total >= f.MinTotal)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(ct);
}
Annullare non vuol dire solo smettere di aspettare: il driver avvisa il database. SQL Server riceve un segnale di attention sulla stessa connessione, PostgreSQL una richiesta di cancel su una connessione separata. La query si ferma lato server e la connessione torna libera nel pool. Se e come il token venga rispettato, però, dipende dal provider del database.
Programmazione asincrona in EF CoreQuando il token ferma solo l’attesa
Non tutto il codice che chiamiamo sa fermarsi: librerie vecchie, SDK di terze parti, processi esterni. Un caso tipico è un report PDF prodotto da un tool a riga di comando:
using var process = Process.Start("report-tool", $"--order {orderId}")!;
await process.WaitForExitAsync(ct);
Il token c’è, ma qui serve solo a smettere di aspettare. Se il client se ne va,
WaitForExitAsync lancia l’eccezione e la richiesta finisce, ma il processo continua a
girare e a consumare CPU. Lo stesso vale per WaitAsync(ct) su qualsiasi Task:
interrompe l’attesa, non il lavoro.
Per fermare davvero il lavoro serve il modo che l’API offre, qui Kill, e Register
lo collega al token:
using var process = Process.Start("report-tool", $"--order {orderId}")!;
using var reg = ct.Register(() => process.Kill(entireProcessTree: true));
await process.WaitForExitAsync(ct);
Quando il token scatta, la callback chiude il processo e quelli che ha avviato. Lo
using toglie la registrazione appena il lavoro è finito: se il token scattasse dopo,
la callback non partirebbe più. Con un SDK il principio è lo stesso: si cerca il suo
Cancel() o Abort() e lo si registra sul token.
Non tutto va annullato
| Operazione | Il token del client? |
|---|---|
| Letture e ricerche | Sì: se il client se ne va, il risultato non serve più. |
| Scritture che devono finire (pagamenti, ordini) | No: CancellationToken.None. Un pagamento a metà è peggio di una risposta che nessuno legge. |
| Lavori lunghi in background | Il token del servizio che li esegue, non quello della richiesta. |
Un limite di tempo tutto nostro
A volte serve anche un timeout proprio, per esempio su un servizio esterno lento. Un token collegato si annulla quando scatta uno qualsiasi dei due: il client che se ne va o il nostro limite.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(5));
var quote = await pricing.GetQuoteAsync(order, cts.Token);
Lo using serve: la sorgente collegata resta registrata sul token del client finché
non viene rilasciata.
Occhio ai catch generici
Quando il token scatta, il lavoro in corso si interrompe con una
OperationCanceledException. ASP.NET Core la riconosce come richiesta annullata, ma
un catch (Exception) scritto da noi la tratta come un errore qualsiasi: finisce nei
log come fallimento e magari viene incapsulata in un’altra eccezione.
Il filtro when la lascia passare:
try
{
await gateway.SendAsync(order, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Invio fallito");
throw new OrderSyncException(order.Id, ex);
}
Istruzioni di gestione delle eccezioni
Contare gli annullamenti, ma a parte
Le richieste annullate vale la pena contarle, ma senza confonderle con i timeout,
che restano errori veri. Il rischio è concreto: anche un timeout arriva come
OperationCanceledException (quello di HttpClient lancia una
TaskCanceledException, che ne deriva), e così il nostro limite di 5 secondi. Il
filtro when (ct.IsCancellationRequested) cattura solo gli annullamenti partiti dal
token della richiesta:
try
{
return await repo.SearchAsync(f, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
canceled.Add(1); // Counter<long>
throw;
}
In breve
- Dichiaralo nel controller.
- Passalo fino alla query.
- Non annullare ciò che deve finire.
Codice semplificato a scopo di esempio: nomi e struttura servono a dare l'idea, non sono da copiare così.
Guarda il carosello su Instagram