Цель: понять по коду рантайма, как устроена кооперативная отмена: что такое 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 кооперативная: ничто не прерывает код силой. Тот, кто хочет остановить работу, поднимает флажок. Работа сама периодически смотрит на флажок и сама заканчивается. Исключения для этого нужны только затем, чтобы быстро размотать стек.
publicreadonlystructCancellationToken:IEquatable<CancellationToken>{privatereadonlyCancellationTokenSource_source;publicboolCanBeCanceled=>_source!=null;publicvoidThrowIfCancellationRequested(){if(IsCancellationRequested){ThrowOperationCanceledException();}}privatevoidThrowOperationCanceledException(){thrownewOperationCanceledException(SR.OperationCanceled,this);}}publicclassCancellationTokenSource:IDisposable{privatevolatileStates_state;// NotCanceledState → NotifyingState → NotifyingCompleteStateprivatevolatileITimer_timer;// таймер для CancelAfter и конструктора с задержкойprivatevolatileManualResetEvent_kernelEvent;// создаётся лениво, при обращении к WaitHandleprivateRegistrations_registrations;// список обработчиков, добавленных через RegisterpublicboolIsCancellationRequested=>_state!=States.NotCanceledState;}
Строки 1–3: токен — структура с единственным полем, ссылкой на источник. Копировать и передавать токен ничего не стоит. Сам он ничего не умеет, только читает состояние источника. default(CancellationToken) — токен без источника: CanBeCanceled == false (строка 5), отменить его нельзя.
Строки 7–12: ThrowIfCancellationRequested — точка проверки в вашем коде. Бросает OperationCanceledExceptionс этим токеном (строка 17).
Строки 23–26: источник хранит состояние, необязательный таймер и список зарегистрированных обработчиков. Метод Cancel() на нём — единственное, что умеет менять состояние.
privatevoidNotifyCancellation(boolthrowOnFirstException){if(TransitionToCancellationRequested()){ExecuteCallbackHandlers(throwOnFirstException);}}privateboolTransitionToCancellationRequested(){if(!IsCancellationRequested&&Interlocked.CompareExchange(ref_state,States.NotifyingState,States.NotCanceledState)==States.NotCanceledState){ITimertimer=_timer;if(timer!=null){_timer=null;timer.Dispose();}_kernelEvent?.Set();returntrue;}returnfalse;}privatevoidExecuteCallbackHandlers(boolthrowOnFirstException){Registrationsregistrations=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(Exceptionitem)when(!throwOnFirstException){(list??(list=newList<Exception>())).Add(item);}}}finally{_state=States.NotifyingCompleteState;...}if(list==null){return;}thrownewAggregateException(list);}
Строка 11: переход «не отменён → уведомляем» делается атомарно. Из нескольких одновременных Cancel() выиграет один, остальные ничего не сделают.
Строка 19: если кто-то брал WaitHandle, он взводится.
Строки 32–45: обработчики выполняются в цикле, прямо в потоке, вызвавшем Cancel(). Отдельного потока или очереди тут нет. Пока цикл не закончится, Cancel() не вернётся.
Строки 46–49: исключение обработчика не прерывает цикл: оно копится в списке, остальные обработчики всё равно выполнятся.
Строки 57–61: в конце все накопленные исключения выбрасываются одним AggregateException, из Cancel().
Строки 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, связанные токены).
Это та же очередь таймеров, которую мы видели у Task.Delay в главе 1. Срабатывает таймер, и обработчики токена выполняются на потоке, который обслужил таймер (поток пула), в обычном Cancel() из §9.1.
== 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 её замечает:
Строки 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 chaptersdotnetrun-cRelease--project09-cancellation\start
9.3. Обработчики Register: кто, где и в каком порядке¶
Всё, что §9.1 говорит о выполнении обработчиков, проверим опытом:
Console.WriteLine("== 2. Register ==");using(varcts=newCancellationTokenSource()){cts.Token.Register(()=>Console.WriteLine($" обработчик A (поток {Environment.CurrentManagedThreadId})"));cts.Token.Register(()=>Console.WriteLine($" обработчик B (поток {Environment.CurrentManagedThreadId})"));cts.Token.Register(()=>thrownewInvalidOperationException("из обработчика C"));Console.WriteLine($" Cancel() из потока {Environment.CurrentManagedThreadId}");try{cts.Cancel();}catch(AggregateExceptione){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(varcts=newCancellationTokenSource()){varsw=Stopwatch.StartNew();cts.Token.Register(()=>Thread.Sleep(300));cts.Cancel();Console.WriteLine($" Cancel() с медленным обработчиком вернулся через {sw.ElapsedMilliseconds} мс");}using(varcts=newCancellationTokenSource()){varsw=Stopwatch.StartNew();cts.Token.Register(()=>Thread.Sleep(300));Taskcancel=cts.CancelAsync();Console.WriteLine($" CancelAsync() вернул задачу через {sw.ElapsedMilliseconds} мс, обработчик ещё работает: {!cancel.IsCompleted}");awaitcancel;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 мс), а обработчики выполняет на потоке пула:
Строка 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 нужно отличать:
== 3. Canceled против Faulted, таймаут против отмены ==
шаг 0
сработал таймаут операции
Фильтр when проверяет, чей именно токен отменён (строки 69–70 кода). Без when вы проглотили бы отмену, пришедшую не от вашего токена (скажем, внутренний таймаут чужого вызова), и скрыли бы баг. Стандартный шаблон:
try{awaitOperationAsync(ct);}catch(OperationCanceledException)when(ct.IsCancellationRequested){// нас отменили сознательно: не ошибка, на уровне Error не логируем}
HttpClient: тот же тип исключения для двух разных причин¶
Сервер в опыте (TcpListener) принимает соединение и ничего не отвечает.
Строка 1: собственный таймаут HttpClient.Timeout даёт TaskCanceledException с вложенным TimeoutException. Это то, как отличить его от отмены: смотреть InnerException.
Строка 2: отмена токеном вызывающего — тоже TaskCanceledException, но внутри не TimeoutException.
В первой версии курса это было одной фразой («HttpClient при таймауте бросает TaskCanceledException»). Теперь видно, как различать без токена: e.InnerException is TimeoutException. Универсальный способ остаётся тем же, что выше: проверять свой токен в when.
Строки 8–9: связывание — это два обработчика, зарегистрированных на родительских токенах. Каждый вызывает NotifyCancellation на новом источнике. Особого «механизма связывания» нет.
Строки 12–20:Dispose связанного источника снимает эти регистрации. Без Dispose обработчик остаётся в списке родителя, а значит, родитель держит ссылку на связанный источник.
Для одного, двух и более токенов в .NET свои классы (Linked1, Linked2, LinkedN), логика у всех такая же.
Console.WriteLine("== 4. Связанные токены ==");using(varparent=newCancellationTokenSource()){Console.WriteLine($" обработчиков на родителе до связывания: {Probes.RegistrationsOn(parent)}");varlinked=CancellationTokenSource.CreateLinkedTokenSource(parent.Token);Console.WriteLine($" после CreateLinkedTokenSource: {Probes.RegistrationsOn(parent)}");linked.Dispose();Console.WriteLine($" после linked.Dispose(): {Probes.RegistrationsOn(parent)}");constintN=100_000;longbefore=Memory();varkept=newList<CancellationTokenSource>(N);for(inti=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(inti=0;i<N;i++)CancellationTokenSource.CreateLinkedTokenSource(parent.Token).Dispose();Console.WriteLine($" {N:N0} с Dispose: обработчиков на родителе {Probes.RegistrationsOn(parent):N0} (прежние остались), память +{(Memory() - before) / 1024:N0} КБ");}
== 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() до таймаута вы попадаете в первую ветку.
// плохо: Delay остаётся в очереди таймеров до срока, даже если task давно завершиласьTaskwinner=awaitTask.WhenAny(task,Task.Delay(timeout));if(winner!=task)thrownewTimeoutException();// хорошо (.NET 6+)awaittask.WaitAsync(timeout,ct);
privateTaskWaitAsync(uintmillisecondsTimeout,TimeProvidertimeProvider,CancellationTokencancellationToken){if(IsCompleted||(!cancellationToken.CanBeCanceled&&millisecondsTimeout==uint.MaxValue)){returnthis;}if(cancellationToken.IsCancellationRequested){returnFromCanceled(cancellationToken);}if(millisecondsTimeout==0){returnFromException(newTimeoutException());}returnnewCancellationPromise<VoidTaskResult>(this,millisecondsTimeout,timeProvider,cancellationToken);}// CancellationPromise<TResult>internalCancellationPromise(Tasksource,uintmillisecondsDelay,TimeProvidertimeProvider,CancellationTokentoken){_task=source;source.AddCompletionAction(this);// подписаться на исходную задачу..._timer=newTimerQueueTimer(…TrySetException(newTimeoutException())…)_registration=token.UnsafeRegister(…TrySetCanceled(cancellationToken)…,this);}privatevoidCleanup(){_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 управляет только ожиданием. Сама работа продолжается.
== 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), которая не принимает токен. Исключение какого типа вы получите? Как сделать так, чтобы работа тоже остановилась?
Console.WriteLine("== 6. CancellationTokenSource с таймером ==");WeakReferencenotDisposed=MakeWithTimer(dispose:false);WeakReferencedisposed=MakeWithTimer(dispose:true);for(inti=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
Отмена не откатывает сделанное. Если вы отменили после начала записи в БД, транзакция уже могла зафиксироваться. Отмена означает «перестань дальше», а не «откати».
Не передать токен вглубь. Самая частая причина «не отменяется»: метод принимает токен, но не передаёт его вызовам внутри. Анализатор CA2016 подсказывает.
Проверять токен в CPU-цикле: ct.ThrowIfCancellationRequested() (как SpinLoopAsync в опыте 1). await с токеном сам проверит токен, а вот цикл без await отмену не заметит.
Task.Delay(Timeout.Infinite, ct) — способ «ждать до отмены». Из §9.2 видно, что для Infinite таймер не создаётся, остаётся одна регистрация на токене.
В ASP.NET Core токен HttpContext.RequestAborted срабатывает при обрыве клиентом. Передавайте его вниз, чтобы не работать зря. Это долгоживущий родитель из §9.5: связанные с ним источники освобождайте.
Токен — структура с одной ссылкой на источник. 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 оставляет запись в очереди таймеров на каждый вызов.
Освобождать нужно источники с таймером и связанные.
// Глава 9, заготовка. Кооперативная отмена, таймаут и обработчики Register.// PREDICT: сколько шагов успеет напечататься, какой тип исключения поймаем, в каком порядке выполнятся обработчики A, B?// TODO 1 (§9.5): добавьте внешний «токен запроса» (второй CancellationTokenSource) и объедините его с таймаутом// через CreateLinkedTokenSource. Различите в catch, кто отменил: вызывающий или таймаут.// TODO 2 (§9.6): добавьте таймаут 200 мс для задачи Task.Delay(1000) без токена. Чем он отличается от отмены токеном?usingvarcts=newCancellationTokenSource();cts.CancelAfter(TimeSpan.FromMilliseconds(450));// таймаут как отменаtry{awaitDoWorkAsync(cts.Token);}catch(OperationCanceledExceptione){Console.WriteLine($"отменено: {e.GetType().Name}");// PREDICT:}Console.WriteLine();usingvarsecond=newCancellationTokenSource();second.Token.Register(()=>Console.WriteLine("обработчик A"));second.Token.Register(()=>Console.WriteLine("обработчик B"));Console.WriteLine($"Cancel() из потока {Environment.CurrentManagedThreadId}");second.Cancel();// PREDICT: в каком порядке и на каком потоке?staticasyncTaskDoWorkAsync(CancellationTokenct){for(inti=0;i<10;i++){ct.ThrowIfCancellationRequested();// точка проверки в CPU-работеawaitTask.Delay(100,ct);// Delay сам понимает токенConsole.WriteLine($"шаг {i}");// PREDICT: до какого шага дойдёт?}}
// Глава 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).usingSystem.Diagnostics;usingSystem.Net;usingSystem.Net.Sockets;Console.WriteLine("== 1. Таймаут как отмена ==");using(varcts=newCancellationTokenSource(TimeSpan.FromMilliseconds(450))){Taskwork=DelayLoopAsync(cts.Token);try{awaitwork;}catch(OperationCanceledExceptione){Console.WriteLine($" Delay-цикл: {e.GetType().Name}, Status={work.Status}, токен исключения тот же: {e.CancellationToken == cts.Token}");}}using(varcts=newCancellationTokenSource(TimeSpan.FromMilliseconds(50))){Taskwork=SpinLoopAsync(cts.Token);try{awaitwork;}catch(OperationCanceledExceptione){Console.WriteLine($" цикл с проверкой: {e.GetType().Name}, Status={work.Status}");}}Console.WriteLine();Console.WriteLine("== 2. Register ==");using(varcts=newCancellationTokenSource()){cts.Token.Register(()=>Console.WriteLine($" обработчик A (поток {Environment.CurrentManagedThreadId})"));cts.Token.Register(()=>Console.WriteLine($" обработчик B (поток {Environment.CurrentManagedThreadId})"));cts.Token.Register(()=>thrownewInvalidOperationException("из обработчика C"));Console.WriteLine($" Cancel() из потока {Environment.CurrentManagedThreadId}");try{cts.Cancel();}catch(AggregateExceptione){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(varcts=newCancellationTokenSource()){varsw=Stopwatch.StartNew();cts.Token.Register(()=>Thread.Sleep(300));cts.Cancel();Console.WriteLine($" Cancel() с медленным обработчиком вернулся через {sw.ElapsedMilliseconds} мс");}using(varcts=newCancellationTokenSource()){varsw=Stopwatch.StartNew();cts.Token.Register(()=>Thread.Sleep(300));Taskcancel=cts.CancelAsync();Console.WriteLine($" CancelAsync() вернул задачу через {sw.ElapsedMilliseconds} мс, обработчик ещё работает: {!cancel.IsCompleted}");awaitcancel;Console.WriteLine($" задача CancelAsync завершилась через {sw.ElapsedMilliseconds} мс");}Console.WriteLine();Console.WriteLine("== 3. Canceled против Faulted, таймаут против отмены ==");using(varrequest=newCancellationTokenSource())using(vartimeout=newCancellationTokenSource(TimeSpan.FromMilliseconds(100)))using(varlinked=CancellationTokenSource.CreateLinkedTokenSource(timeout.Token,request.Token)){try{awaitDelayLoopAsync(linked.Token);}catch(OperationCanceledException)when(request.IsCancellationRequested){Console.WriteLine(" отменил вызывающий: не ошибка");}catch(OperationCanceledException)when(timeout.IsCancellationRequested){Console.WriteLine(" сработал таймаут операции");}}varlistener=newTcpListener(IPAddress.Loopback,0);listener.Start();// принимает соединения, но никогда не отвечаетusing(varhttp=newHttpClient{Timeout=TimeSpan.FromMilliseconds(200)}){stringurl=$"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/";try{awaithttp.GetAsync(url);}catch(OperationCanceledExceptione){Console.WriteLine($" HttpClient.Timeout: {e.GetType().Name}, InnerException = {e.InnerException?.GetType().Name}");}usingvarbyCaller=newCancellationTokenSource(TimeSpan.FromMilliseconds(200));try{awaithttp.GetAsync(url,byCaller.Token);}catch(OperationCanceledExceptione){Console.WriteLine($" отмена токеном вызывающего: {e.GetType().Name}, InnerException = {e.InnerException?.GetType().Name ?? "нет"}");}}listener.Stop();Console.WriteLine();Console.WriteLine("== 4. Связанные токены ==");using(varparent=newCancellationTokenSource()){Console.WriteLine($" обработчиков на родителе до связывания: {Probes.RegistrationsOn(parent)}");varlinked=CancellationTokenSource.CreateLinkedTokenSource(parent.Token);Console.WriteLine($" после CreateLinkedTokenSource: {Probes.RegistrationsOn(parent)}");linked.Dispose();Console.WriteLine($" после linked.Dispose(): {Probes.RegistrationsOn(parent)}");constintN=100_000;longbefore=Memory();varkept=newList<CancellationTokenSource>(N);for(inti=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(inti=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 ==");varswWait=Stopwatch.StartNew();Taskslow=SlowWorkAsync();try{awaitslow.WaitAsync(TimeSpan.FromMilliseconds(200));}catch(TimeoutException){Console.WriteLine($" WaitAsync: TimeoutException через {swWait.ElapsedMilliseconds} мс, slow.Status={slow.Status}");}awaitslow;Console.WriteLine($" slow всё равно доработала: {swWait.ElapsedMilliseconds} мс");{intbaseline=Probes.TimersInQueue();for(inti=0;i<1000;i++)awaitTask.WhenAny(Task.CompletedTask,Task.Delay(TimeSpan.FromMinutes(5)));Console.WriteLine($" 1000 × WhenAny(задача, Delay(5 мин)): записей в очереди таймеров +{Probes.TimersInQueue() - baseline}");baseline=Probes.TimersInQueue();for(inti=0;i<1000;i++)awaitTask.CompletedTask.WaitAsync(TimeSpan.FromMinutes(5));Console.WriteLine($" 1000 × задача.WaitAsync(5 мин): записей в очереди таймеров +{Probes.TimersInQueue() - baseline}");baseline=Probes.TimersInQueue();for(inti=0;i<1000;i++){usingvarstop=newCancellationTokenSource();Taskdelay=Task.Delay(TimeSpan.FromMinutes(5),stop.Token);awaitTask.WhenAny(Task.CompletedTask,delay);stop.Cancel();}Console.WriteLine($" 1000 × WhenAny + отмена Delay токеном: записей в очереди таймеров +{Probes.TimersInQueue() - baseline}");}Console.WriteLine();Console.WriteLine("== 6. CancellationTokenSource с таймером ==");WeakReferencenotDisposed=MakeWithTimer(dispose:false);WeakReferencedisposed=MakeWithTimer(dispose:true);for(inti=0;i<3;i++){GC.Collect();GC.WaitForPendingFinalizers();}Console.WriteLine($" источник на 30 с без Dispose жив после GC: {notDisposed.IsAlive}; с Dispose: {disposed.IsAlive}");staticasyncTaskDelayLoopAsync(CancellationTokenct){for(inti=0;i<10;i++){awaitTask.Delay(100,ct);Console.WriteLine($" шаг {i}");}}staticasyncTaskSpinLoopAsync(CancellationTokenct){awaitTask.Yield();while(true){ct.ThrowIfCancellationRequested();// точка проверки в CPU-работеThread.SpinWait(1000);}}staticasyncTaskSlowWorkAsync()=>awaitTask.Delay(800);staticlongMemory(){for(inti=0;i<3;i++){GC.Collect();GC.WaitForPendingFinalizers();}returnGC.GetTotalMemory(forceFullCollection:true);}staticWeakReferenceMakeWithTimer(booldispose){varcts=newCancellationTokenSource(TimeSpan.FromSeconds(30));varweak=newWeakReference(cts);if(dispose)cts.Dispose();returnweak;}
usingSystem.Reflection;// Заглядываем внутрь рантайма через отражение (имена из CoreLib 10.0.12, детали реализации).staticclassProbes{privateconstBindingFlagsAny=BindingFlags.Instance|BindingFlags.NonPublic|BindingFlags.Public|BindingFlags.Static;// Сколько записей таймеров сейчас в очереди таймеров рантайма (все TimerQueue.Instances, короткие и длинные списки).publicstaticintTimersInQueue(){TypequeueType=typeof(object).Assembly.GetType("System.Threading.TimerQueue")!;varinstances=(Array)queueType.GetProperty("Instances",Any)!.GetValue(null)!;intcount=0;foreach(objectqueueininstances)foreach(stringlistNameinnew[]{"_shortTimers","_longTimers"})for(object?t=queueType.GetField(listName,Any)!.GetValue(queue);tisnotnull;t=t.GetType().GetField("_next",Any)!.GetValue(t))count++;returncount;}// Число обработчиков, зарегистрированных на источнике токена (через Registrations.Callbacks).publicstaticintRegistrationsOn(CancellationTokenSourcesource){object?registrations=typeof(CancellationTokenSource).GetField("_registrations",Any)!.GetValue(source);if(registrationsisnull)return0;intcount=0;for(object?node=registrations.GetType().GetField("Callbacks",Any)!.GetValue(registrations);nodeisnotnull;node=node.GetType().GetField("Next",Any)!.GetValue(node))count++;returncount;}}