CancellationToken и таймауты

↑ 2.3 Асинхронность и многопоточность · ← Предыдущая · → Следующая

Обязательно~6 мин чтенияУровень: middle

Зачем это на собесе

Отмена и таймауты — признак зрелого бэкенд-кода. Спросят, как прервать долгий запрос, когда клиент закрыл соединение, как задать таймаут на операцию, чем CancellationTokenSource отличается от токена и что делать с OperationCanceledException.

Объяснение

Кооперативная отмена

Отмена в .NET кооперативная: одна сторона просит остановиться, другая сама проверяет и завершается.

  • CancellationTokenSource (CTS) — источник: вызывает Cancel().
  • CancellationToken — токен: передаётся в методы и проверяется.
using var cts = new CancellationTokenSource();
var task = ProcessAsync(cts.Token);
cts.Cancel();                                   // просьба остановиться
try { await task; } catch (OperationCanceledException) { /* штатная отмена */ }
 
async Task ProcessAsync(CancellationToken ct)
{
    foreach (var item in items)
    {
        ct.ThrowIfCancellationRequested();       // проверка между шагами
        await HandleAsync(item, ct);             // передаём токен вниз
    }
}

Что делать в коде

  • Принимать CancellationToken ct последним параметром (по умолчанию = default) во всех асинхронных методах.
  • Передавать его в каждый вложенный вызов: HttpClient, EF Core (ToListAsync(ct)), Task.Delay, Stream.ReadAsync.
  • Проверять в собственных циклах: ct.ThrowIfCancellationRequested() или ct.IsCancellationRequested.
  • OperationCanceledException (и TaskCanceledException) — штатная реакция на отмену, не ошибка: не логируйте как error.

В ASP.NET Core

app.MapGet("/api/report/{id}", async (Guid id, IReportService svc, CancellationToken ct) =>
    Results.File(await svc.BuildAsync(id, ct), "application/pdf"));

Токен запроса (HttpContext.RequestAborted) срабатывает, если клиент закрыл соединение. В контроллерах и Minimal API его можно просто добавить параметром.

Таймауты

// 1) на группу операций: связанный токен
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);   // отмена клиента ИЛИ таймаут
var data = await client.GetAsync(url, linked.Token);
 
// 2) CancelAfter
cts.CancelAfter(TimeSpan.FromSeconds(30));
 
// 3) .NET 6+: ограничить время ожидания задачи (сама операция не отменяется!)
var result = await slowTask.WaitAsync(TimeSpan.FromSeconds(5), ct);
 
// 4) HttpClient.Timeout (по умолчанию 100 с) и политики Polly / Microsoft.Extensions.Http.Resilience
builder.Services.AddHttpClient<Api>().AddStandardResilienceHandler();

Различать причину: catch (OperationCanceledException) when (timeout.IsCancellationRequested) — таймаут; when (ct.IsCancellationRequested) — отмена вызывающего.

Регистрация обратных вызовов и освобождение ресурсов

await using var registration = ct.Register(() => connection.Cancel());   // отменить нативный ресурс

CancellationTokenSource держит таймер и регистрации: освобождайте (Dispose), особенно при CancelAfter.

Отмена в фоновых сервисах

BackgroundService.ExecuteAsync(CancellationToken stoppingToken) — токен срабатывает при остановке приложения (SIGTERM). Все циклы и задержки должны его учитывать, иначе будет принудительное завершение по истечении ShutdownTimeout.

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
    while (await timer.WaitForNextTickAsync(stoppingToken))
        await DoWorkAsync(stoppingToken);
}

Нюансы и подводные камни

  • Отмена ничего не гарантирует: если код не проверяет токен, он продолжит работу. Метод без токена нельзя отменить.
  • WaitAsync(timeout) не отменяет саму операцию: она продолжит выполняться в фоне. Для реальной остановки нужен токен в самой операции.
  • Отмена после commit транзакции или отправки сообщения не откатывает результат: продумайте идемпотентность.
  • Task.Delay без токена — «зависший» таймер и задержка остановки приложения.
  • Ловить OperationCanceledException слишком широко (catch (Exception)) — глушить отмену; отделяйте.
  • CancellationToken.None в цепочке «разрывает» отмену: клиент ушёл, а запрос к БД продолжается.
  • Не передавайте токен запроса в фоновую работу, которая должна пережить запрос: он отменится вместе с запросом.

Практика

  • Добавить токен во все методы сервиса и проверить, что закрытие соединения клиентом обрывает запрос в БД.
  • Реализовать метод с общим таймаутом на несколько вложенных вызовов через CreateLinkedTokenSource.
  • Найти в проекте Task.Delay без токена и исправить.

Вопросы с ответами

Связанные темы