CancellationToken in ASP.NET Core: se il client se ne va, il server deve saperlo

· 5 min di lettura

Scritto per .NET 10 · ASP.NET Core 10 · EF Core 10

#tech#dotnet

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.

OrdersController.cs
[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.

Annullamento nei thread gestitiDocumentazione ufficiale · learn.microsoft.com

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:

OrderExport.cs
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.

HttpContext.RequestAbortedDocumentazione ufficiale · ASP.NET Core 10 · learn.microsoft.com Binding dei parametri nelle Minimal APIDocumentazione ufficiale · ASP.NET Core 10 · learn.microsoft.com
OrdersController.cs
[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.

OrderService.cs
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.

OrderRepository.cs
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 CoreDocumentazione ufficiale · learn.microsoft.com

Quando 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:

ReportService.cs
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.

Process.WaitForExitAsyncDocumentazione ufficiale · .NET 10 · learn.microsoft.com Task.WaitAsyncDocumentazione ufficiale · .NET 10 · learn.microsoft.com

Per fermare davvero il lavoro serve il modo che l’API offre, qui Kill, e Register lo collega al token:

ReportService.cs
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.

Registrare callback per le richieste di annullamentoDocumentazione ufficiale · learn.microsoft.com

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.

OrderService.cs
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.

CancellationTokenSource.CreateLinkedTokenSourceDocumentazione ufficiale · .NET 10 · learn.microsoft.com

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:

OrderSync.cs
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 eccezioniDocumentazione ufficiale · learn.microsoft.com

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:

OrderService.cs
try
{
    return await repo.SearchAsync(f, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    canceled.Add(1); // Counter<long>
    throw;
}

In breve

  1. Dichiaralo nel controller.
  2. Passalo fino alla query.
  3. 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 @alessandroaiello.dev