Перейти к содержанию

9. Отмена и таймауты

О главе

Цель: понять по коду рантайма, как устроена кооперативная отмена: что такое CancellationTokenSource и токен, где и в каком потоке выполняются обработчики Register, как Task.Delay реагирует на токен, чем связанные токены отличаются от обычных и что нужно освобождать. Научиться делать таймауты так, чтобы не оставлять после себя таймеры и обработчики.

Лабораторная: start/ — таймаут, предсказание шагов и порядка обработчиков, два TODO (связанные токены, таймаут для чужой задачи). final/ — шесть опытов: таймаут и тип исключения, Register (порядок, поток, исключения, CancelAsync), Canceled против Faulted и HttpClient, связанные токены и утечка, WaitAsync против WhenAny + Delay, источник с таймером. Код — в конце главы.

Статус: ✅ проверено на Ubuntu 26.04 (2 ядра), рантайм 10.0.12. Листинги CoreLib — декомпиляция System.Private.CoreLib 10.0.12 (.\tools\disasm.ps1 -Assembly corelib -Type …). Опыты 3 (HttpClient) и 4–5 используют отражение к внутренним полям рантайма (детали реализации). Перепроверено на Windows 11 (8 ядер): вывод совпадает, отличаются только номера потоков и миллисекунды.

Отмена в .NET кооперативная: ничто не прерывает код силой. Тот, кто хочет остановить работу, поднимает флажок. Работа сама периодически смотрит на флажок и сама заканчивается. Исключения для этого нужны только затем, чтобы быстро размотать стек.

9.1. Устройство: источник, токен, регистрации

Две разные вещи с похожими именами:

CoreLib 10.0.12: CancellationToken и CancellationTokenSource (поля, сокращено)
public readonly struct CancellationToken : IEquatable<CancellationToken>
{
    private readonly CancellationTokenSource _source;

    public bool CanBeCanceled => _source != null;

    public void ThrowIfCancellationRequested()
    {
        if (IsCancellationRequested)
        {
            ThrowOperationCanceledException();
        }
    }

    private void ThrowOperationCanceledException()
    {
        throw new OperationCanceledException(SR.OperationCanceled, this);
    }
}

public class CancellationTokenSource : IDisposable
{
    private volatile States _state;                    // NotCanceledState → NotifyingState → NotifyingCompleteState
    private volatile ITimer _timer;                    // таймер для CancelAfter и конструктора с задержкой
    private volatile ManualResetEvent _kernelEvent;    // создаётся лениво, при обращении к WaitHandle
    private Registrations _registrations;              // список обработчиков, добавленных через Register

    public bool IsCancellationRequested => _state != States.NotCanceledState;
}
  • Строки 1–3: токен — структура с единственным полем, ссылкой на источник. Копировать и передавать токен ничего не стоит. Сам он ничего не умеет, только читает состояние источника. default(CancellationToken) — токен без источника: CanBeCanceled == false (строка 5), отменить его нельзя.
  • Строки 7–12: ThrowIfCancellationRequested — точка проверки в вашем коде. Бросает OperationCanceledException с этим токеном (строка 17).
  • Строки 23–26: источник хранит состояние, необязательный таймер и список зарегистрированных обработчиков. Метод Cancel() на нём — единственное, что умеет менять состояние.

Отмена источника — три шага:

CoreLib 10.0.12: Cancel → NotifyCancellation → ExecuteCallbackHandlers (сокращено)
private void NotifyCancellation(bool throwOnFirstException)
{
    if (TransitionToCancellationRequested())
    {
        ExecuteCallbackHandlers(throwOnFirstException);
    }
}

private bool TransitionToCancellationRequested()
{
    if (!IsCancellationRequested && Interlocked.CompareExchange(ref _state, States.NotifyingState, States.NotCanceledState) == States.NotCanceledState)
    {
        ITimer timer = _timer;
        if (timer != null)
        {
            _timer = null;
            timer.Dispose();
        }
        _kernelEvent?.Set();
        return true;
    }
    return false;
}

private void ExecuteCallbackHandlers(bool throwOnFirstException)
{
    Registrations registrations = Interlocked.Exchange(ref _registrations, null);
    ...
    List<Exception> list = null;
    try
    {
        while (true)
        {
            ...                                            // взять ПЕРВЫЙ обработчик из списка и убрать его оттуда
            try
            {
                if (callbacks.SynchronizationContext != null)
                {
                    callbacks.SynchronizationContext.Send(…, callbacks);
                }
                else
                {
                    callbacks.ExecuteCallback();           // на потоке, который вызвал Cancel
                }
            }
            catch (Exception item) when (!throwOnFirstException)
            {
                (list ?? (list = new List<Exception>())).Add(item);
            }
        }
    }
    finally
    {
        _state = States.NotifyingCompleteState;
        ...
    }
    if (list == null)
    {
        return;
    }
    throw new AggregateException(list);
}
  • Строка 11: переход «не отменён → уведомляем» делается атомарно. Из нескольких одновременных Cancel() выиграет один, остальные ничего не сделают.
  • Строки 13–18: таймер источника (если был) останавливается.
  • Строка 19: если кто-то брал WaitHandle, он взводится.
  • Строки 32–45: обработчики выполняются в цикле, прямо в потоке, вызвавшем Cancel(). Отдельного потока или очереди тут нет. Пока цикл не закончится, Cancel() не вернётся.
  • Строки 46–49: исключение обработчика не прерывает цикл: оно копится в списке, остальные обработчики всё равно выполнятся.
  • Строки 57–61: в конце все накопленные исключения выбрасываются одним AggregateException, из Cancel().

Порядок обработчиков определяет Register:

CoreLib 10.0.12: CancellationTokenSource.Register (сокращено)
internal CancellationTokenRegistration Register(Delegate callback, object stateForCallback, SynchronizationContext syncContext, ExecutionContext executionContext)
{
    if (!IsCancellationRequested)
    {
        ...
        callbackNode = new CallbackNode(registrations);
        callbackNode.Callback = callback;
        ...
        registrations.EnterLock();
        try
        {
            id = (callbackNode.Id = registrations.NextAvailableId++);
            callbackNode.Next = registrations.Callbacks;
            ...
            registrations.Callbacks = callbackNode;
        }
        finally
        {
            registrations.ExitLock();
        }
        if (!IsCancellationRequested || !registrations.Unregister(id, callbackNode))
        {
            return new CancellationTokenRegistration(id, callbackNode);
        }
    }
    Invoke(callback, stateForCallback, this);
    return default;
}
  • Строки 13 и 15: новый обработчик становится головой списка (Next — прежняя голова). Поэтому выполняются обработчики в обратном порядке: последним зарегистрирован — первым выполнен.
  • Строки 21–24: возвращается CancellationTokenRegistration: ключ, по которому обработчик можно удалить (Dispose).
  • Строки 3 и 26–27: если токен уже отменён, обработчик выполняется сразу, прямо внутри Register, в потоке вызывающего.

Контексты, которые Register захватывает

token.Register(Action) вызывает внутреннюю версию с useSynchronizationContext: false, useExecutionContext: true: обработчик выполнится в захваченном ExecutionContext (глава 7), но не в SynchronizationContext. Register(callback, useSynchronizationContext: true) включает второе (тогда в ExecuteCallbackHandlers сработает ветка SynchronizationContext.Send, строки 37–39). UnsafeRegister не захватывает ни того, ни другого: так делает рантайм внутри (Task.Delay, WaitAsync, связанные токены).

9.2. Таймаут как отмена: кто бросает исключение

CancellationTokenSource(TimeSpan) и CancelAfter — это просто таймер, который вызовет Cancel():

CoreLib 10.0.12: таймер в источнике (сокращено)
private static readonly TimerCallback s_timerCallback = TimerCallback;

private void InitializeWithTimer(TimeSpan millisecondsDelay, TimeProvider timeProvider)
{
    ...
    _timer = new TimerQueueTimer(s_timerCallback, this, millisecondsDelay, Timeout.InfiniteTimeSpan, flowExecutionContext: false);
}

private static void TimerCallback(object state)
{
    ((CancellationTokenSource)state).NotifyCancellation(throwOnFirstException: false);
}

Это та же очередь таймеров, которую мы видели у Task.Delay в главе 1. Срабатывает таймер, и обработчики токена выполняются на потоке, который обслужил таймер (поток пула), в обычном Cancel() из §9.1.

Опыт: две формы отмены одного таймаута

final/Program.cs
Console.WriteLine("== 1. Таймаут как отмена ==");
using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(450)))
{
    Task work = DelayLoopAsync(cts.Token);
    try { await work; }
    catch (OperationCanceledException e)
    {
        Console.WriteLine($"  Delay-цикл: {e.GetType().Name}, Status={work.Status}, токен исключения тот же: {e.CancellationToken == cts.Token}");
    }
}
using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)))
{
    Task work = SpinLoopAsync(cts.Token);
    try { await work; }
    catch (OperationCanceledException e)
    {
        Console.WriteLine($"  цикл с проверкой: {e.GetType().Name}, Status={work.Status}");
    }
}
1
2
3
4
5
6
7
== 1. Таймаут как отмена ==
  шаг 0
  шаг 1
  шаг 2
  шаг 3
  Delay-цикл: TaskCanceledException, Status=Canceled, токен исключения тот же: True
  цикл с проверкой: OperationCanceledException, Status=Canceled

Таймаут 450 мс, шаги по 100 мс: напечатано четыре шага (строки 2–5 вывода), на пятом метод оказался внутри Task.Delay, и тот прервался.

  • Строка 6 (строки 13–20 кода): исключение бросил Task.Delay, и это TaskCanceledException. Задача work завершилась в состоянии Canceled, e.CancellationToken — наш токен.
  • Строка 7 (строки 22–29): цикл, который сам вызывает ct.ThrowIfCancellationRequested(), бросает именно OperationCanceledException (§9.1, строка 17).

TaskCanceledException — наследник OperationCanceledException, поэтому catch (OperationCanceledException) ловит оба. Кто из них прилетит, зависит от того, где отмену заметили. Как Task.Delay её замечает:

CoreLib 10.0.12: Task.Delay(…, CancellationToken) → DelayPromiseWithCancellation (сокращено)
private static Task Delay(uint millisecondsDelay, TimeProvider timeProvider, CancellationToken cancellationToken)
{
    if (!cancellationToken.IsCancellationRequested)
    {
        if (millisecondsDelay != 0)
        {
            if (!cancellationToken.CanBeCanceled)
            {
                return new DelayPromise(millisecondsDelay, timeProvider);
            }
            return new DelayPromiseWithCancellation(millisecondsDelay, timeProvider, cancellationToken);
        }
        return CompletedTask;
    }
    return FromCanceled(cancellationToken);
}

// DelayPromiseWithCancellation
_registration = token.UnsafeRegister((object state, CancellationToken cancellationToken) =>
{
    DelayPromiseWithCancellation delayPromiseWithCancellation = (DelayPromiseWithCancellation)state;
    delayPromiseWithCancellation.AtomicStateUpdate(64, 0);
    if (delayPromiseWithCancellation.TrySetCanceled(cancellationToken))
    {
        delayPromiseWithCancellation.Cleanup();
    }
}, this);

protected override void Cleanup()
{
    _registration.Dispose();
    base.Cleanup();
}
  • Строки 3, 15: токен уже отменён к моменту вызова — готовая отменённая задача, даже таймер не создаётся.
  • Строки 7–9: токен нельзя отменить (default) — обычный DelayPromise без регистрации.
  • Строки 11 и 19–27: иначе у токена регистрируется обработчик (UnsafeRegister). Когда токен отменят, обработчик завершает задачу Delay как Canceled (TrySetCanceled(token)) и освобождает таймер и регистрацию (Cleanup). Это тот же механизм завершения, что в главе 4, только вместо результата — отмена. Продолжение await Task.Delay(…, ct) потом получает отменённую задачу, и await бросает TaskCanceledException (глава 8, §8.3).
  • Строки 29–33: регистрация снимается после завершения Delay. Если бы не снималась, на каждом вызове накапливались бы обработчики на токене.

Предскажите

В start/Program.cs у строк стоят комментарии PREDICT. Запишите: сколько шагов напечатается при таймауте 450 мс, какой тип исключения прилетит и в каком порядке выполнятся обработчики A и B. Потом запускайте:

cd chapters
dotnet run -c Release --project 09-cancellation\start

9.3. Обработчики Register: кто, где и в каком порядке

Всё, что §9.1 говорит о выполнении обработчиков, проверим опытом:

final/Program.cs
Console.WriteLine("== 2. Register ==");
using (var cts = new CancellationTokenSource())
{
    cts.Token.Register(() => Console.WriteLine($"  обработчик A (поток {Environment.CurrentManagedThreadId})"));
    cts.Token.Register(() => Console.WriteLine($"  обработчик B (поток {Environment.CurrentManagedThreadId})"));
    cts.Token.Register(() => throw new InvalidOperationException("из обработчика C"));
    Console.WriteLine($"  Cancel() из потока {Environment.CurrentManagedThreadId}");
    try { cts.Cancel(); }
    catch (AggregateException e) { Console.WriteLine($"  Cancel() бросил {e.GetType().Name}: {string.Join("; ", e.InnerExceptions.Select(x => x.Message))}"); }
    Console.WriteLine($"  Register на уже отменённом токене: ");
    cts.Token.Register(() => Console.WriteLine($"    выполнен сразу, внутри Register (поток {Environment.CurrentManagedThreadId})"));
}
using (var cts = new CancellationTokenSource())
{
    var sw = Stopwatch.StartNew();
    cts.Token.Register(() => Thread.Sleep(300));
    cts.Cancel();
    Console.WriteLine($"  Cancel() с медленным обработчиком вернулся через {sw.ElapsedMilliseconds} мс");
}
using (var cts = new CancellationTokenSource())
{
    var sw = Stopwatch.StartNew();
    cts.Token.Register(() => Thread.Sleep(300));
    Task cancel = cts.CancelAsync();
    Console.WriteLine($"  CancelAsync() вернул задачу через {sw.ElapsedMilliseconds} мс, обработчик ещё работает: {!cancel.IsCompleted}");
    await cancel;
    Console.WriteLine($"  задача CancelAsync завершилась через {sw.ElapsedMilliseconds} мс");
}
== 2. Register ==
  Cancel() из потока 5
  обработчик B (поток 5)
  обработчик A (поток 5)
  Cancel() бросил AggregateException: из обработчика C
  Register на уже отменённом токене: 
    выполнен сразу, внутри Register (поток 5)
  Cancel() с медленным обработчиком вернулся через 301 мс
  CancelAsync() вернул задачу через 0 мс, обработчик ещё работает: True
  задача CancelAsync завершилась через 300 мс
  • Строки 3–4 вывода: порядок B, A: обратный регистрации (§9.1, голова списка). Обработчик C, зарегистрированный последним, выполнился первым и бросил исключение (строка 38 кода), но B и A всё равно отработали.
  • Строка 5: исключение C дошло до вызывающего одним AggregateException (строки 57–61 листинга §9.1). Если вы ожидаете, что Cancel() не бросает, а в обработчиках может быть исключение, оборачивайте вызов в try.
  • Строки 6–7: Register на уже отменённом токене выполнил обработчик немедленно, внутри себя (строки 26–27 листинга Register).
  • Строка 8: обработчик в 300 мс держит вызвавшего Cancel() 301 мс. Номера потоков во всех строках одинаковы: потока для обработчиков не создаётся.
  • Строки 9–10: CancelAsync() (.NET 8+) возвращает задачу сразу (0 мс), а обработчики выполняет на потоке пула:
CoreLib 10.0.12: CancellationTokenSource.CancelAsync (сокращено)
public Task CancelAsync()
{
    ...
    if (TransitionToCancellationRequested())
    {
        Registrations registrations = Volatile.Read(in _registrations);
        if (registrations != null)
        {
            ...
            if (flag)                                         // обработчики есть
            {
                return Task.Factory.StartNew((object s) =>
                {
                    ((CancellationTokenSource)s).ExecuteCallbackHandlers(throwOnFirstException: false);
                }, this, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
            }
        }
    }
    return Task.CompletedTask;
}
  • Строка 4: сам переход в состояние «отменён» (IsCancellationRequested == true) происходит синхронно, до возврата из метода.
  • Строки 12–15: обработчики уходят в пул той же функцией ExecuteCallbackHandlers. Исключения обработчиков попадут уже в возвращённую задачу, как AggregateException.

Значит правило такое: обработчик Register должен быть быстрым и неблокирующим. Если нет, вызывайте CancelAsync() или переносите тяжёлую работу в Task.Run внутри обработчика. Особенно тяжёлые случаи: обработчик, который берёт блокировку, удерживаемую вызвавшим Cancel() (та же история, что с синхронными продолжениями SetResult из главы 5), и обработчик, который сам блокируется на ожидании.

Факт: обработчики выполняются в обратном порядке регистрации

В первой версии курса про порядок не говорилось. Проверено опытом и кодом: LIFO (строки 3–4 вывода, голова списка в Register). Но это деталь реализации: в коде это просто список со вставкой в голову, и полагаться на порядок не стоит. Надёжно только то, что все обработчики выполнятся до возврата из Cancel() и что исключения соберутся в AggregateException.

9.4. Canceled против Faulted; таймаут против отмены вызывающим

Статус задачи при OperationCanceledException зависит от места (глава 8, §8.7). Для async-метода достаточно самого исключения: любое OperationCanceledException даёт Canceled. Поэтому в catch нужно отличать:

final/Program.cs
Console.WriteLine("== 3. Canceled против Faulted, таймаут против отмены ==");
using (var request = new CancellationTokenSource())
using (var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)))
using (var linked = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, request.Token))
{
    try { await DelayLoopAsync(linked.Token); }
    catch (OperationCanceledException) when (request.IsCancellationRequested) { Console.WriteLine("  отменил вызывающий: не ошибка"); }
    catch (OperationCanceledException) when (timeout.IsCancellationRequested) { Console.WriteLine("  сработал таймаут операции"); }
}
1
2
3
== 3. Canceled против Faulted, таймаут против отмены ==
  шаг 0
  сработал таймаут операции

Фильтр when проверяет, чей именно токен отменён (строки 69–70 кода). Без when вы проглотили бы отмену, пришедшую не от вашего токена (скажем, внутренний таймаут чужого вызова), и скрыли бы баг. Стандартный шаблон:

1
2
3
4
5
try { await OperationAsync(ct); }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    // нас отменили сознательно: не ошибка, на уровне Error не логируем
}

HttpClient: тот же тип исключения для двух разных причин

final/Program.cs
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();                                                    // принимает соединения, но никогда не отвечает
using (var http = new HttpClient { Timeout = TimeSpan.FromMilliseconds(200) })
{
    string url = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/";
    try { await http.GetAsync(url); }
    catch (OperationCanceledException e) { Console.WriteLine($"  HttpClient.Timeout: {e.GetType().Name}, InnerException = {e.InnerException?.GetType().Name}"); }
    using var byCaller = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
    try { await http.GetAsync(url, byCaller.Token); }
    catch (OperationCanceledException e) { Console.WriteLine($"  отмена токеном вызывающего: {e.GetType().Name}, InnerException = {e.InnerException?.GetType().Name ?? "нет"}"); }
}
listener.Stop();
  HttpClient.Timeout: TaskCanceledException, InnerException = TimeoutException
  отмена токеном вызывающего: TaskCanceledException, InnerException = TaskCanceledException

Сервер в опыте (TcpListener) принимает соединение и ничего не отвечает.

  • Строка 1: собственный таймаут HttpClient.Timeout даёт TaskCanceledException с вложенным TimeoutException. Это то, как отличить его от отмены: смотреть InnerException.
  • Строка 2: отмена токеном вызывающего — тоже TaskCanceledException, но внутри не TimeoutException.

В первой версии курса это было одной фразой («HttpClient при таймауте бросает TaskCanceledException»). Теперь видно, как различать без токена: e.InnerException is TimeoutException. Универсальный способ остаётся тем же, что выше: проверять свой токен в when.

9.5. Связанные токены

CreateLinkedTokenSource(token1, token2) создаёт новый источник, который отменяется, когда отменён любой из родителей. Как:

CoreLib 10.0.12: связанные источники
private sealed class Linked2CancellationTokenSource : CancellationTokenSource
{
    private readonly CancellationTokenRegistration _reg1;
    private readonly CancellationTokenRegistration _reg2;

    internal Linked2CancellationTokenSource(CancellationToken token1, CancellationToken token2)
    {
        _reg1 = token1.UnsafeRegister(LinkedNCancellationTokenSource.s_linkedTokenCancelDelegate, this);
        _reg2 = token2.UnsafeRegister(LinkedNCancellationTokenSource.s_linkedTokenCancelDelegate, this);
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing && !_disposed)
        {
            _reg1.Dispose();
            _reg2.Dispose();
            base.Dispose(disposing);
        }
    }
}

// LinkedNCancellationTokenSource
internal static readonly Action<object> s_linkedTokenCancelDelegate = (object s) =>
{
    ((CancellationTokenSource)s).NotifyCancellation(throwOnFirstException: false);
};
  • Строки 8–9: связывание — это два обработчика, зарегистрированных на родительских токенах. Каждый вызывает NotifyCancellation на новом источнике. Особого «механизма связывания» нет.
  • Строки 12–20: Dispose связанного источника снимает эти регистрации. Без Dispose обработчик остаётся в списке родителя, а значит, родитель держит ссылку на связанный источник.
  • Для одного, двух и более токенов в .NET свои классы (Linked1, Linked2, LinkedN), логика у всех такая же.
final/Program.cs
Console.WriteLine("== 4. Связанные токены ==");
using (var parent = new CancellationTokenSource())
{
    Console.WriteLine($"  обработчиков на родителе до связывания: {Probes.RegistrationsOn(parent)}");
    var linked = CancellationTokenSource.CreateLinkedTokenSource(parent.Token);
    Console.WriteLine($"  после CreateLinkedTokenSource: {Probes.RegistrationsOn(parent)}");
    linked.Dispose();
    Console.WriteLine($"  после linked.Dispose(): {Probes.RegistrationsOn(parent)}");

    const int N = 100_000;
    long before = Memory();
    var kept = new List<CancellationTokenSource>(N);
    for (int i = 0; i < N; i++)
        kept.Add(CancellationTokenSource.CreateLinkedTokenSource(parent.Token));
    kept.Clear();                                                    // ссылки на связанные источники выброшены, но не освобождены
    Console.WriteLine($"  {N:N0} связанных источников без Dispose: обработчиков на родителе {Probes.RegistrationsOn(parent):N0}, память +{(Memory() - before) / 1024:N0} КБ");
    before = Memory();
    for (int i = 0; i < N; i++)
        CancellationTokenSource.CreateLinkedTokenSource(parent.Token).Dispose();
    Console.WriteLine($"  {N:N0} с Dispose: обработчиков на родителе {Probes.RegistrationsOn(parent):N0} (прежние остались), память +{(Memory() - before) / 1024:N0} КБ");
}
1
2
3
4
5
6
== 4. Связанные токены ==
  обработчиков на родителе до связывания: 0
  после CreateLinkedTokenSource: 1
  после linked.Dispose(): 0
  100,000 связанных источников без Dispose: обработчиков на родителе 100,000, память +14,062 КБ
  100,000 с Dispose: обработчиков на родителе 100,000 (прежние остались), память +0 КБ
  • Строки 2–4 вывода: связывание добавляет обработчик на родителя, Dispose убирает (Probes.RegistrationsOn считает узлы списка Registrations.Callbacks через отражение).
  • Строка 5: сто тысяч связанных источников, на которые мы сами потом потеряли ссылки, но не освободили: все сто тысяч обработчиков остались на родителе, и вместе с ними удерживаются сами источники — около 14 МБ после полной сборки мусора. Сборщик не может собрать связанный источник, пока родитель ссылается на него через список.
  • Строка 6: с Dispose ничего не накапливается. Прежние 100 000 обработчиков, конечно, остались: они не умерли, пока жив родитель.

Факт: связанный источник без Dispose — утечка на всё время жизни родителя

Если родительский токен долгоживущий (токен остановки приложения, RequestAborted на соединении, глобальный токен сервиса), а связанные источники создаются на каждый вызов, то без using обработчики накапливаются неограниченно. Только родитель может освободить их, когда сам будет собран или отменён. Правило: CreateLinkedTokenSource всегда в using.

Задание (TODO 1)

В start/Program.cs добавьте второй источник («токен запроса») и объедините его с таймаутом через CreateLinkedTokenSource. В catch различите, кто отменил: вызывающий или таймаут. Проверьте, что при request.Cancel() до таймаута вы попадаете в первую ветку.

9.6. WaitAsync против WhenAny + Delay

Задача, которую нужно ограничить по времени, но которая не принимает токен, — частый случай. Два способа:

1
2
3
4
5
6
// плохо: Delay остаётся в очереди таймеров до срока, даже если task давно завершилась
Task winner = await Task.WhenAny(task, Task.Delay(timeout));
if (winner != task) throw new TimeoutException();

// хорошо (.NET 6+)
await task.WaitAsync(timeout, ct);

Что делает WaitAsync:

CoreLib 10.0.12: Task.WaitAsync → CancellationPromise (сокращено)
private Task WaitAsync(uint millisecondsTimeout, TimeProvider timeProvider, CancellationToken cancellationToken)
{
    if (IsCompleted || (!cancellationToken.CanBeCanceled && millisecondsTimeout == uint.MaxValue))
    {
        return this;
    }
    if (cancellationToken.IsCancellationRequested)
    {
        return FromCanceled(cancellationToken);
    }
    if (millisecondsTimeout == 0)
    {
        return FromException(new TimeoutException());
    }
    return new CancellationPromise<VoidTaskResult>(this, millisecondsTimeout, timeProvider, cancellationToken);
}

// CancellationPromise<TResult>
internal CancellationPromise(Task source, uint millisecondsDelay, TimeProvider timeProvider, CancellationToken token)
{
    _task = source;
    source.AddCompletionAction(this);                         // подписаться на исходную задачу
    ... _timer = new TimerQueueTimer(…TrySetException(new TimeoutException())…)
    _registration = token.UnsafeRegister(…TrySetCanceled(cancellationToken)…, this);
}

private void Cleanup()
{
    _registration.Dispose();
    _timer?.Dispose();
    _task.RemoveContinuation(this);
}
  • Строки 3–6: задача уже завершена, или ограничений нет (бесконечный таймаут, токен без отмены) — возвращается сама исходная задача, без обёрток.
  • Строки 7–14: токен уже отменён — готовая отменённая задача; таймаут нулевой — готовая Faulted с TimeoutException.
  • Строки 19–25: всё остальное — новая задача-обещание CancellationPromise. Она подписывается на исходную (строка 22), заводит таймер (строка 23: по истечении завершает себя исключением TimeoutException) и регистрируется на токене (строка 24: завершает себя отменой).
  • Строки 27–32: Cleanup выполняется, как только CancellationPromise завершилась любым способом: таймер выключается, регистрация снимается, подписка на исходную задачу убирается. Ничего не остаётся.
  • Исходная задача нигде не отменяется. WaitAsync управляет только ожиданием. Сама работа продолжается.
final/Program.cs
Console.WriteLine("== 5. WaitAsync против WhenAny + Delay ==");
var swWait = Stopwatch.StartNew();
Task slow = SlowWorkAsync();
try { await slow.WaitAsync(TimeSpan.FromMilliseconds(200)); }
catch (TimeoutException) { Console.WriteLine($"  WaitAsync: TimeoutException через {swWait.ElapsedMilliseconds} мс, slow.Status={slow.Status}"); }
await slow;
Console.WriteLine($"  slow всё равно доработала: {swWait.ElapsedMilliseconds} мс");
{
    int baseline = Probes.TimersInQueue();
    for (int i = 0; i < 1000; i++)
        await Task.WhenAny(Task.CompletedTask, Task.Delay(TimeSpan.FromMinutes(5)));
    Console.WriteLine($"  1000 × WhenAny(задача, Delay(5 мин)): записей в очереди таймеров +{Probes.TimersInQueue() - baseline}");
    baseline = Probes.TimersInQueue();
    for (int i = 0; i < 1000; i++)
        await Task.CompletedTask.WaitAsync(TimeSpan.FromMinutes(5));
    Console.WriteLine($"  1000 × задача.WaitAsync(5 мин): записей в очереди таймеров +{Probes.TimersInQueue() - baseline}");
    baseline = Probes.TimersInQueue();
    for (int i = 0; i < 1000; i++)
    {
        using var stop = new CancellationTokenSource();
        Task delay = Task.Delay(TimeSpan.FromMinutes(5), stop.Token);
        await Task.WhenAny(Task.CompletedTask, delay);
        stop.Cancel();
    }
    Console.WriteLine($"  1000 × WhenAny + отмена Delay токеном: записей в очереди таймеров +{Probes.TimersInQueue() - baseline}");
}
1
2
3
4
5
6
== 5. WaitAsync против WhenAny + Delay ==
  WaitAsync: TimeoutException через 203 мс, slow.Status=WaitingForActivation
  slow всё равно доработала: 803 мс
  1000 × WhenAny(задача, Delay(5 мин)): записей в очереди таймеров +1000
  1000 × задача.WaitAsync(5 мин): записей в очереди таймеров +0
  1000 × WhenAny + отмена Delay токеном: записей в очереди таймеров +0
  • Строки 2–3: WaitAsync бросил TimeoutException через 203 мс, а slow в этот момент ещё работала (WaitingForActivation) и закончила через 803 мс. Таймаут ожидания не отменил работу.
  • Строка 4: ловушка WhenAny + Delay. Задача завершилась сразу, а таймер Delay(5 минут) остался в очереди таймеров: 1000 вызовов оставили 1000 записей (Probes.TimersInQueue считает записи во всех TimerQueue.Instances). Каждая живёт до срока и держит свою DelayPromise.
  • Строка 5: у WaitAsync после завершения записей нет (Cleanup).
  • Строка 6: если уж используете WhenAny, отмените Delay токеном после победы (stop.Cancel()): DelayPromiseWithCancellation.Cleanup уберёт таймер.

Чтобы остановить саму работу, а не только перестать её ждать, ей нужно передать токен: using var cts = new CancellationTokenSource(timeout); await WorkAsync(cts.Token);.

Факт: Task.WhenAny(task, Task.Delay(timeout)) — не только неудобно, но и дорого

В первой версии курса это было сказано словами («таймер остаётся висеть»). Теперь это число: на каждый такой вызов остаётся запись в очереди таймеров. Для частого вызова (на каждый запрос) под нагрузкой это тысячи висящих таймеров. WaitAsync и WhenAny с отменой Delay не оставляют ничего.

Задание (TODO 2)

В start/Program.cs ограничьте по времени (200 мс) задачу Task.Delay(1000), которая не принимает токен. Исключение какого типа вы получите? Как сделать так, чтобы работа тоже остановилась?

9.7. Что нужно освобождать

CoreLib 10.0.12: Dispose источника (сокращено)
protected virtual void Dispose(bool disposing)
{
    if (!disposing || _disposed)
    {
        return;
    }
    ITimer timer = _timer;
    if (timer != null)
    {
        _timer = null;
        timer.Dispose();
    }
    _registrations = null;
    if (_kernelEvent != null)
    {
        ...
        manualResetEvent.Dispose();
    }
    _disposed = true;
}
  • Строки 7–12: Dispose останавливает таймер. Он единственный ресурс, на который есть внешняя ссылка из очереди таймеров.
  • Строка 13: список обработчиков сбрасывается.
  • Строки 14–18: ManualResetEvent (если брали WaitHandle).
final/Program.cs
Console.WriteLine("== 6. CancellationTokenSource с таймером ==");
WeakReference notDisposed = MakeWithTimer(dispose: false);
WeakReference disposed = MakeWithTimer(dispose: true);
for (int i = 0; i < 3; i++) { GC.Collect(); GC.WaitForPendingFinalizers(); }
Console.WriteLine($"  источник на 30 с без Dispose жив после GC: {notDisposed.IsAlive}; с Dispose: {disposed.IsAlive}");
== 6. CancellationTokenSource с таймером ==
  источник на 30 с без Dispose жив после GC: True; с Dispose: False

Источник с таймером (new CancellationTokenSource(TimeSpan) или CancelAfter) без Dispose остаётся в памяти до срока, потому что очередь таймеров ссылается на него (§9.2). С Dispose таймер снят, источник собран.

Когда Dispose нужен, а когда нет:

Источник Что держит Dispose нужен?
new CancellationTokenSource() без таймера, без WaitHandle только объект нет, но привычка using безвредна
с таймером (TimeSpan, CancelAfter) запись в очереди таймеров до срока да
связанный (CreateLinkedTokenSource) обработчики на родителях да (§9.5)
взяли Token.WaitHandle ManualResetEvent да

9.8. Прочие подводные камни

  • Отмена не откатывает сделанное. Если вы отменили после начала записи в БД, транзакция уже могла зафиксироваться. Отмена означает «перестань дальше», а не «откати».
  • Не передать токен вглубь. Самая частая причина «не отменяется»: метод принимает токен, но не передаёт его вызовам внутри. Анализатор CA2016 подсказывает.
  • Проверять токен в CPU-цикле: ct.ThrowIfCancellationRequested() (как SpinLoopAsync в опыте 1). await с токеном сам проверит токен, а вот цикл без await отмену не заметит.
  • Task.Delay(Timeout.Infinite, ct) — способ «ждать до отмены». Из §9.2 видно, что для Infinite таймер не создаётся, остаётся одна регистрация на токене.
  • В ASP.NET Core токен HttpContext.RequestAborted срабатывает при обрыве клиентом. Передавайте его вниз, чтобы не работать зря. Это долгоживущий родитель из §9.5: связанные с ним источники освобождайте.

9.9. Итоги

  • Токен — структура с одной ссылкой на источник. Cancel() меняет состояние источника, останавливает его таймер и синхронно, в вызвавшем потоке выполняет все обработчики Register.
  • Обработчики выполняются в обратном порядке регистрации (деталь реализации), исключения собираются в один AggregateException. Register на уже отменённом токене выполняет обработчик сразу. Для тяжёлых обработчиков есть CancelAsync.
  • Таймаут источника — это таймер из очереди таймеров, который вызывает Cancel(). Task.Delay(…, ct) регистрируется на токене и завершает свою задачу как Canceled; цикл с ThrowIfCancellationRequested бросает OperationCanceledException сам.
  • OperationCanceledException даёт Canceled, а catch без фильтра when (ct.IsCancellationRequested) проглатывает чужие отмены. Таймаут HttpClient — TaskCanceledException с вложенным TimeoutException.
  • Связанный токен — два обработчика на родителях. Без Dispose они остаются там, пока жив родитель.
  • Таймаут для чужой задачи — WaitAsync (не оставляет таймеров, но работу не останавливает). WhenAny + Delay оставляет запись в очереди таймеров на каждый вызов.
  • Освобождать нужно источники с таймером и связанные.

Код лабораторной

Запуск из папки главы: dotnet run -c Release --project start или --project final.

start/Program.cs
// Глава 9, заготовка. Кооперативная отмена, таймаут и обработчики Register.
// PREDICT: сколько шагов успеет напечататься, какой тип исключения поймаем, в каком порядке выполнятся обработчики A, B?
// TODO 1 (§9.5): добавьте внешний «токен запроса» (второй CancellationTokenSource) и объедините его с таймаутом
//                через CreateLinkedTokenSource. Различите в catch, кто отменил: вызывающий или таймаут.
// TODO 2 (§9.6): добавьте таймаут 200 мс для задачи Task.Delay(1000) без токена. Чем он отличается от отмены токеном?

using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(450));      // таймаут как отмена

try
{
    await DoWorkAsync(cts.Token);
}
catch (OperationCanceledException e)
{
    Console.WriteLine($"отменено: {e.GetType().Name}");          // PREDICT:
}

Console.WriteLine();
using var second = new CancellationTokenSource();
second.Token.Register(() => Console.WriteLine("обработчик A"));
second.Token.Register(() => Console.WriteLine("обработчик B"));
Console.WriteLine($"Cancel() из потока {Environment.CurrentManagedThreadId}");
second.Cancel();                                                 // PREDICT: в каком порядке и на каком потоке?

static async Task DoWorkAsync(CancellationToken ct)
{
    for (int i = 0; i < 10; i++)
    {
        ct.ThrowIfCancellationRequested();            // точка проверки в CPU-работе
        await Task.Delay(100, ct);                    // Delay сам понимает токен
        Console.WriteLine($"шаг {i}");                // PREDICT: до какого шага дойдёт?
    }
}
final/Program.cs
// Глава 9, итог. Отмена и таймауты:
//   1) таймаут как отмена; кто бросает: Delay или ThrowIfCancellationRequested (§9.2);
//   2) Register: порядок, поток, исключения, уже отменённый токен, CancelAsync (§9.3);
//   3) Canceled против Faulted; как отличить таймаут от отмены вызывающим, HttpClient (§9.4);
//   4) связанные токены: оба источника, регистрации у родителя, утечка без Dispose (§9.5);
//   5) WaitAsync против WhenAny + Delay: что остаётся в очереди таймеров (§9.6);
//   6) CancellationTokenSource с таймером живёт до срока, пока его не освободят (§9.7).
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;

Console.WriteLine("== 1. Таймаут как отмена ==");
using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(450)))
{
    Task work = DelayLoopAsync(cts.Token);
    try { await work; }
    catch (OperationCanceledException e)
    {
        Console.WriteLine($"  Delay-цикл: {e.GetType().Name}, Status={work.Status}, токен исключения тот же: {e.CancellationToken == cts.Token}");
    }
}
using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)))
{
    Task work = SpinLoopAsync(cts.Token);
    try { await work; }
    catch (OperationCanceledException e)
    {
        Console.WriteLine($"  цикл с проверкой: {e.GetType().Name}, Status={work.Status}");
    }
}

Console.WriteLine();
Console.WriteLine("== 2. Register ==");
using (var cts = new CancellationTokenSource())
{
    cts.Token.Register(() => Console.WriteLine($"  обработчик A (поток {Environment.CurrentManagedThreadId})"));
    cts.Token.Register(() => Console.WriteLine($"  обработчик B (поток {Environment.CurrentManagedThreadId})"));
    cts.Token.Register(() => throw new InvalidOperationException("из обработчика C"));
    Console.WriteLine($"  Cancel() из потока {Environment.CurrentManagedThreadId}");
    try { cts.Cancel(); }
    catch (AggregateException e) { Console.WriteLine($"  Cancel() бросил {e.GetType().Name}: {string.Join("; ", e.InnerExceptions.Select(x => x.Message))}"); }
    Console.WriteLine($"  Register на уже отменённом токене: ");
    cts.Token.Register(() => Console.WriteLine($"    выполнен сразу, внутри Register (поток {Environment.CurrentManagedThreadId})"));
}
using (var cts = new CancellationTokenSource())
{
    var sw = Stopwatch.StartNew();
    cts.Token.Register(() => Thread.Sleep(300));
    cts.Cancel();
    Console.WriteLine($"  Cancel() с медленным обработчиком вернулся через {sw.ElapsedMilliseconds} мс");
}
using (var cts = new CancellationTokenSource())
{
    var sw = Stopwatch.StartNew();
    cts.Token.Register(() => Thread.Sleep(300));
    Task cancel = cts.CancelAsync();
    Console.WriteLine($"  CancelAsync() вернул задачу через {sw.ElapsedMilliseconds} мс, обработчик ещё работает: {!cancel.IsCompleted}");
    await cancel;
    Console.WriteLine($"  задача CancelAsync завершилась через {sw.ElapsedMilliseconds} мс");
}

Console.WriteLine();
Console.WriteLine("== 3. Canceled против Faulted, таймаут против отмены ==");
using (var request = new CancellationTokenSource())
using (var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)))
using (var linked = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, request.Token))
{
    try { await DelayLoopAsync(linked.Token); }
    catch (OperationCanceledException) when (request.IsCancellationRequested) { Console.WriteLine("  отменил вызывающий: не ошибка"); }
    catch (OperationCanceledException) when (timeout.IsCancellationRequested) { Console.WriteLine("  сработал таймаут операции"); }
}
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();                                                    // принимает соединения, но никогда не отвечает
using (var http = new HttpClient { Timeout = TimeSpan.FromMilliseconds(200) })
{
    string url = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/";
    try { await http.GetAsync(url); }
    catch (OperationCanceledException e) { Console.WriteLine($"  HttpClient.Timeout: {e.GetType().Name}, InnerException = {e.InnerException?.GetType().Name}"); }
    using var byCaller = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
    try { await http.GetAsync(url, byCaller.Token); }
    catch (OperationCanceledException e) { Console.WriteLine($"  отмена токеном вызывающего: {e.GetType().Name}, InnerException = {e.InnerException?.GetType().Name ?? "нет"}"); }
}
listener.Stop();

Console.WriteLine();
Console.WriteLine("== 4. Связанные токены ==");
using (var parent = new CancellationTokenSource())
{
    Console.WriteLine($"  обработчиков на родителе до связывания: {Probes.RegistrationsOn(parent)}");
    var linked = CancellationTokenSource.CreateLinkedTokenSource(parent.Token);
    Console.WriteLine($"  после CreateLinkedTokenSource: {Probes.RegistrationsOn(parent)}");
    linked.Dispose();
    Console.WriteLine($"  после linked.Dispose(): {Probes.RegistrationsOn(parent)}");

    const int N = 100_000;
    long before = Memory();
    var kept = new List<CancellationTokenSource>(N);
    for (int i = 0; i < N; i++)
        kept.Add(CancellationTokenSource.CreateLinkedTokenSource(parent.Token));
    kept.Clear();                                                    // ссылки на связанные источники выброшены, но не освобождены
    Console.WriteLine($"  {N:N0} связанных источников без Dispose: обработчиков на родителе {Probes.RegistrationsOn(parent):N0}, память +{(Memory() - before) / 1024:N0} КБ");
    before = Memory();
    for (int i = 0; i < N; i++)
        CancellationTokenSource.CreateLinkedTokenSource(parent.Token).Dispose();
    Console.WriteLine($"  {N:N0} с Dispose: обработчиков на родителе {Probes.RegistrationsOn(parent):N0} (прежние остались), память +{(Memory() - before) / 1024:N0} КБ");
}

Console.WriteLine();
Console.WriteLine("== 5. WaitAsync против WhenAny + Delay ==");
var swWait = Stopwatch.StartNew();
Task slow = SlowWorkAsync();
try { await slow.WaitAsync(TimeSpan.FromMilliseconds(200)); }
catch (TimeoutException) { Console.WriteLine($"  WaitAsync: TimeoutException через {swWait.ElapsedMilliseconds} мс, slow.Status={slow.Status}"); }
await slow;
Console.WriteLine($"  slow всё равно доработала: {swWait.ElapsedMilliseconds} мс");
{
    int baseline = Probes.TimersInQueue();
    for (int i = 0; i < 1000; i++)
        await Task.WhenAny(Task.CompletedTask, Task.Delay(TimeSpan.FromMinutes(5)));
    Console.WriteLine($"  1000 × WhenAny(задача, Delay(5 мин)): записей в очереди таймеров +{Probes.TimersInQueue() - baseline}");
    baseline = Probes.TimersInQueue();
    for (int i = 0; i < 1000; i++)
        await Task.CompletedTask.WaitAsync(TimeSpan.FromMinutes(5));
    Console.WriteLine($"  1000 × задача.WaitAsync(5 мин): записей в очереди таймеров +{Probes.TimersInQueue() - baseline}");
    baseline = Probes.TimersInQueue();
    for (int i = 0; i < 1000; i++)
    {
        using var stop = new CancellationTokenSource();
        Task delay = Task.Delay(TimeSpan.FromMinutes(5), stop.Token);
        await Task.WhenAny(Task.CompletedTask, delay);
        stop.Cancel();
    }
    Console.WriteLine($"  1000 × WhenAny + отмена Delay токеном: записей в очереди таймеров +{Probes.TimersInQueue() - baseline}");
}

Console.WriteLine();
Console.WriteLine("== 6. CancellationTokenSource с таймером ==");
WeakReference notDisposed = MakeWithTimer(dispose: false);
WeakReference disposed = MakeWithTimer(dispose: true);
for (int i = 0; i < 3; i++) { GC.Collect(); GC.WaitForPendingFinalizers(); }
Console.WriteLine($"  источник на 30 с без Dispose жив после GC: {notDisposed.IsAlive}; с Dispose: {disposed.IsAlive}");

static async Task DelayLoopAsync(CancellationToken ct)
{
    for (int i = 0; i < 10; i++)
    {
        await Task.Delay(100, ct);
        Console.WriteLine($"  шаг {i}");
    }
}

static async Task SpinLoopAsync(CancellationToken ct)
{
    await Task.Yield();
    while (true)
    {
        ct.ThrowIfCancellationRequested();                           // точка проверки в CPU-работе
        Thread.SpinWait(1000);
    }
}

static async Task SlowWorkAsync() => await Task.Delay(800);

static long Memory()
{
    for (int i = 0; i < 3; i++) { GC.Collect(); GC.WaitForPendingFinalizers(); }
    return GC.GetTotalMemory(forceFullCollection: true);
}

static WeakReference MakeWithTimer(bool dispose)
{
    var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
    var weak = new WeakReference(cts);
    if (dispose)
        cts.Dispose();
    return weak;
}
final/Probes.cs
using System.Reflection;

// Заглядываем внутрь рантайма через отражение (имена из CoreLib 10.0.12, детали реализации).
static class Probes
{
    private const BindingFlags Any = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static;

    // Сколько записей таймеров сейчас в очереди таймеров рантайма (все TimerQueue.Instances, короткие и длинные списки).
    public static int TimersInQueue()
    {
        Type queueType = typeof(object).Assembly.GetType("System.Threading.TimerQueue")!;
        var instances = (Array)queueType.GetProperty("Instances", Any)!.GetValue(null)!;
        int count = 0;
        foreach (object queue in instances)
            foreach (string listName in new[] { "_shortTimers", "_longTimers" })
                for (object? t = queueType.GetField(listName, Any)!.GetValue(queue); t is not null; t = t.GetType().GetField("_next", Any)!.GetValue(t))
                    count++;
        return count;
    }

    // Число обработчиков, зарегистрированных на источнике токена (через Registrations.Callbacks).
    public static int RegistrationsOn(CancellationTokenSource source)
    {
        object? registrations = typeof(CancellationTokenSource).GetField("_registrations", Any)!.GetValue(source);
        if (registrations is null)
            return 0;
        int count = 0;
        for (object? node = registrations.GetType().GetField("Callbacks", Any)!.GetValue(registrations);
             node is not null;
             node = node.GetType().GetField("Next", Any)!.GetValue(node))
            count++;
        return count;
    }
}