Start Debugging

Ограничение времени выполнения асинхронной операции с помощью CancellationTokenSource.CancelAfter в C#

Используйте CancellationTokenSource.CancelAfter для задания дедлайна асинхронных операций в .NET 11: конструктор против CancelAfter, связанные токены, обработка исключений, Task.WaitAsync, TryReset для пула объектов и тестируемые тайм-ауты с TimeProvider.

Чтобы задать дедлайн для асинхронной операции, нужны три вещи: CancellationTokenSource, вызов cts.CancelAfter(TimeSpan.FromSeconds(5)) и передача cts.Token в каждый асинхронный метод цепочки. Когда дедлайн истекает, токен срабатывает, любой последующий await выбрасывает OperationCanceledException, которое вы перехватываете. В этой статье рассматривается полный паттерн для .NET 11 (Microsoft.NET.Sdk 11.0.0, C# 14): когда использовать перегрузку конструктора вместо CancelAfter, как комбинировать тайм-аут с внешним CancellationToken, как отличить тайм-аут от отмены вызывающим кодом, Task.WaitAsync для кода, который нельзя изменить, TryReset для пула объектов, а также как сделать тайм-ауты тестируемыми с помощью TimeProvider.

Операция, которая зависает навсегда

Вызов HttpClient без явного дедлайна ожидает столько, сколько потребуется серверу. HttpClient.Timeout по умолчанию равен 100 секундам — достаточно, чтобы при высокой нагрузке переполнить очередь запросов. Кроме того, HttpClient.Timeout нельзя комбинировать с CancellationToken вызывающего кода, а при срабатывании он выбрасывает TaskCanceledException с InnerException типа TimeoutException — это другая форма по сравнению с кооперативной отменой. Ручной паттерн с CancelAfter обеспечивает как соблюдение дедлайна, так и возможность композиции.

// .NET 11, C# 14 -- опасно: HttpClient.Timeout по умолчанию равен 100 секундам
using HttpClient http = new();
string json = await http.GetStringAsync("https://slow-api.example.com/data");

CancelAfter в одном шаге

// .NET 11, C# 14
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5));

try
{
    using HttpClient http = new();
    string json = await http.GetStringAsync(
        "https://slow-api.example.com/data", cts.Token);
    Console.WriteLine(json);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
    Console.WriteLine("Запрос завершился по тайм-ауту через 5 секунд.");
}

CancelAfter планирует внутренний однократный таймер. Когда таймер срабатывает, он вызывает Cancel() на CTS, что переводит состояние токена из “не запрошено” в “запрошено”. Любой await внутри GetStringAsync, который проверяет токен, выбрасывает OperationCanceledException. Условие when гарантирует, что вы перехватите исключение только в том случае, если сработал ваш собственный CTS, а не какая-либо другая отмена от фреймворка или вызывающего кода.

Перегрузка конструктора против CancelAfter

// .NET 11, C# 14 -- эквивалентны для фиксированного однократного дедлайна:
using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));

using var cts2 = new CancellationTokenSource();
cts2.CancelAfter(TimeSpan.FromSeconds(5));

Используйте перегрузку конструктора, когда дедлайн фиксирован в момент создания объекта и не изменится. Предпочитайте CancelAfter, когда вы создаёте CTS заранее, а дедлайн определяете позже, или когда нужно сбросить его в ходе операции — например, watchdog, обновляющий своё окно при каждом успешном heartbeat:

// .NET 11, C# 14 -- паттерн watchdog со сбросом дедлайна при каждом пакете
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10));

await foreach (Packet packet in ReadPacketsAsync(cts.Token))
{
    Process(packet);
    cts.CancelAfter(TimeSpan.FromSeconds(10));  // сбросить 10-секундное окно
}

Каждый вызов CancelAfter заменяет ранее запланированное время через ITimer.Change внутри — без новых выделений памяти. Новый отсчёт начинается с момента вызова.

Комбинирование тайм-аута с внешним CancellationToken

В ASP.NET Core HttpContext.RequestAborted срабатывает при отключении клиента. В BackgroundService stoppingToken срабатывает при завершении работы. Тайм-аут должен отменять операцию при истечении дедлайна или при отмене вызывающим кодом. Используйте CancellationTokenSource.CreateLinkedTokenSource:

// .NET 11, C# 14
public async Task<string> FetchWithDeadlineAsync(
    string url,
    CancellationToken callerToken = default)
{
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(
        callerToken, timeoutCts.Token);

    using HttpClient http = new();
    return await http.GetStringAsync(url, linked.Token);
}

Передавайте linked.Token во все последующие вызовы. Связанный токен отменяется, если срабатывает любой из источников. CreateLinkedTokenSource принимает произвольное количество токенов; возвращённый CTS отменяется, как только любой из них отменяется.

Всегда вызывайте Dispose как для тайм-аутного CTS, так и для связанного. Неосвобождённые экземпляры CTS с ожидающими таймерами не собираются сборщиком мусора до срабатывания таймера.

Различение тайм-аута и отмены вызывающим кодом

Оба случая выбрасывают OperationCanceledException. Чтобы передать вызывающему коду осмысленную информацию об ошибке, преобразуйте настоящий тайм-аут в TimeoutException:

// .NET 11, C# 14
catch (OperationCanceledException ex)
{
    if (timeoutCts.IsCancellationRequested)
        throw new TimeoutException(
            "Операция завершилась по тайм-ауту через 5 секунд.", ex);

    // Вызывающий код отменил -- перебрасываем без обёртки, чтобы токен
    // вызывающего кода распространился без изменений
    throw;
}

Это важно, когда вызывающий код не знает о внутреннем CancellationTokenSource. Вызывающий код, перехватывающий TimeoutException, не должен инспектировать токены, которые он никогда не создавал.

При использовании связанного CTS проверяйте timeoutCts.IsCancellationRequested напрямую. Не сравнивайте ex.CancellationToken со связанным токеном — ex.CancellationToken содержит тот составной токен, который сработал первым (вызывающего кода, тайм-аутный или связанный), что зависит от состояния гонки.

Task.WaitAsync для кода без параметра токена

Не все API принимают CancellationToken. В .NET 6 добавлен Task.WaitAsync для таких случаев:

// .NET 11, C# 14 -- обёртка для устаревшего API без токена
Task<string> slowWork = LegacyApiAsync();

try
{
    string result = await slowWork.WaitAsync(TimeSpan.FromSeconds(5));
}
catch (TimeoutException)
{
    // Дедлайн истёк. slowWork ВСЁ ЕЩЁ ВЫПОЛНЯЕТСЯ в фоне.
    Console.WriteLine("Прекращаем ожидание через 5 секунд.");
}

WaitAsync(TimeSpan) выбрасывает TimeoutException (не OperationCanceledException) и не отменяет базовую задачу. Работа продолжается до самостоятельного завершения. Используйте WaitAsync только тогда, когда не можете передать токен в вызываемый код; потребление ресурсов продолжается в любом случае.

Комбинированная перегрузка принимает как дедлайн, так и CancellationToken:

// .NET 11, C# 14 -- поддерживает отмену И ограничение по времени
await slowWork.WaitAsync(TimeSpan.FromSeconds(5), callerToken);

Эта перегрузка выбрасывает TimeoutException, если первым истекает дедлайн, или TaskCanceledException (подкласс OperationCanceledException), если первым срабатывает токен.

Сброс и отключение существующего тайм-аута

CancelAfter можно вызывать многократно. Каждый вызов заменяет ранее запланированный дедлайн:

// .NET 11, C# 14
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10));   // начальный дедлайн: 10с

// Продлить: перезапустить таймер с окном 5 секунд с текущего момента
cts.CancelAfter(TimeSpan.FromSeconds(5));

// Полностью отключить тайм-аут без отмены CTS:
cts.CancelAfter(Timeout.InfiniteTimeSpan);   // больше не срабатывает

Timeout.InfiniteTimeSpan равно -1 миллисекундам, что отключает внутренний таймер без перевода CTS в состояние отмены.

После отмены CTS вызов CancelAfter не имеет никакого эффекта. Проверяйте IsCancellationRequested заранее, если отмена могла уже произойти.

TryReset для пула объектов

В высокопроизводительном коде, выполняющем множество коротких операций, создание нового CancellationTokenSource на каждый запрос приводит к выделениям памяти. В .NET 6 добавлен TryReset для повторного использования:

// .NET 11, C# 14 -- паттерн пула CTS
private CancellationTokenSource _cts = new();

public async Task ProcessRequestAsync()
{
    if (!_cts.TryReset())
        _cts = new CancellationTokenSource();

    _cts.CancelAfter(TimeSpan.FromSeconds(5));

    await DoWorkAsync(_cts.Token);
}

TryReset возвращает true, если CTS не был отменён и его можно безопасно переиспользовать; false — если был отменён и требуется новый экземпляр. Метод не является потокобезопасным при одновременных запросах на отмену — вызывайте его только после завершения предыдущей операции, единственным владельцем. ASP.NET Core использует этот паттерн внутри Kestrel.

Тестируемые тайм-ауты с TimeProvider

Ждать 5 реальных секунд в модульном тесте медленно и ненадёжно. TimeProvider, введённый в .NET 8 и стабильный в .NET 11, делает внутренний таймер внедряемым:

// .NET 11, C# 14
public async Task<string> FetchAsync(
    string url,
    TimeProvider? clock = null)
{
    clock ??= TimeProvider.System;
    using var cts = clock.CreateCancellationTokenSource(TimeSpan.FromSeconds(5));

    using HttpClient http = new();
    return await http.GetStringAsync(url, cts.Token);
}

В тестах внедряйте FakeTimeProvider из NuGet-пакета Microsoft.Extensions.TimeProvider.Testing:

// xUnit, .NET 11, C# 14
[Fact]
public async Task FetchAsync_ThrowsOnTimeout()
{
    var clock = new FakeTimeProvider();
    Task<string> fetch = FetchAsync("https://slow.example.com", clock);

    clock.Advance(TimeSpan.FromSeconds(10));  // мгновенно перематываем на 10 секунд

    await Assert.ThrowsAsync<OperationCanceledException>(() => fetch);
}

Отмена срабатывает синхронно при перемотке часов. Без Task.Delay. Без нестабильного времени выполнения тестов.

Освобождение CTS

CancellationTokenSource реализует IDisposable, а не IAsyncDisposable. Метода DisposeAsync не существует. При вызове CancelAfter в очереди таймеров пула потоков регистрируется таймер. Этот таймер удерживает ссылку на CTS, предотвращая сборку мусора до его срабатывания.

Всегда освобождайте ресурсы:

// Правильно: гарантированная очистка даже при исключении
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await DoWorkAsync(cts.Token);
// cts.Dispose() вызывается здесь; таймер отменяется и освобождается

При освобождении отменяется ожидающий таймер, освобождаются дескрипторы ожидания и CTS удаляется из любой цепочки CreateLinkedTokenSource, к которой он принадлежит. На сервере, обрабатывающем тысячи одновременных запросов, утечки экземпляров CTS приводят к накоплению таймеров.

Типичные ошибки

HttpClient.Timeout конкурирует с вашим токеном. HttpClient.Timeout по умолчанию равен 100 секундам. Если вы также передаёте токен с CancelAfter, работают два таймера. При срабатывании HttpClient.Timeout выбрасывается TaskCanceledException, где ex.InnerException is TimeoutException; при срабатывании вашего токена — OperationCanceledException, где ex.CancellationToken == cts.Token. Отключите одно из двух: либо установите HttpClient.Timeout в Timeout.InfiniteTimeSpan и управляйте дедлайном самостоятельно, либо полагайтесь на HttpClient.Timeout и откажитесь от ручного CTS. Подробнее см. Fix: TaskCanceledException в HttpClient.

CancelAfter после Cancel не имеет эффекта. После перевода CTS в состояние отмены вызов CancelAfter ничего не делает. Отмена — это однонаправленный переход.

Маршалинг на поток пользовательского интерфейса. В WinForms, WPF или MAUI продолжение после отменённого await выполняется на потоке пула, захватившем завершение, а не на потоке пользовательского интерфейса. Если вы обновляете элементы UI в блоке catch, явно переключитесь на нужный поток. В ASP.NET Core SynchronizationContext не установлен, поэтому ConfigureAwait(false) не имеет эффекта.

Не передавайте один токен в параллельные ветви непреднамеренно. При распределении работы с помощью Task.WhenAll и передаче одного cts.Token во все ветви первая отмена отменит все остальные. Обычно это желаемое поведение для общего дедлайна, но делайте это осознанно.

Связанные статьи

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад