Цель: выбирать правильный инструмент и понимать, что он делает внутри: Task.Run против Task.Factory.StartNew, ContinueWith против await, TaskCompletionSource, комбинаторы (WhenAny, WhenEach), ограничение параллелизма (Parallel.ForEachAsync, SemaphoreSlim), асинхронная блокировка, PeriodicTimer, ленивая инициализация.
Лабораторная:start/ — ловушка StartNew с async-лямбдой, планировщик внутри чужой задачи, ограничение параллелизма (два TODO). final/ — восемь опытов: по разделу на каждую тему главы. Код — в конце главы.
Статус: ✅ проверено на Ubuntu 26.04 (2 ядра), рантайм 10.0.12. Листинги CoreLib — декомпиляция System.Private.CoreLib 10.0.12 (.\tools\disasm.ps1 -Assembly corelib -Type …); Parallel.ForEachAsync — из System.Threading.Tasks.Parallel.dll того же рантайма. Перепроверено на Windows 11 (8 ядер); различия (предел параллелизма = число ядер, шаг таймера ≈ 15,6 мс) — во вкладках «Linux» и «Windows».
Строка 4:Task.Run(Action) всегда планирует в TaskScheduler.Default (пул потоков) и с флагом DenyChildAttach (строка 4, последний параметр).
Строка 10:Task.Run(Func<Task>) запускает функцию как Task<Task> и оборачивает её в UnwrapPromise. Это задача-обещание, которая завершается, когда завершится внутренняя задача, и копирует её результат, исключение или отмену. Так Task.Run сам «разворачивает» async-лямбду.
Строки 14–18:StartNew без аргументов берёт планировщик не из Default, а из текущей задачи (InternalCurrent), если она есть (строки 20–32).
Строки 25–28: вне задачи (currTask == null) или если у текущей задачи стоит HideScheduler — всё равно Default.
Строка 29: иначе — планировщик текущей задачи: StartNewнаследует его.
Task.Run(f)
Task.Factory.StartNew(f)
Планировщик
всегда TaskScheduler.Default (пул)
TaskScheduler.Current (планировщик текущей задачи, если она есть)
== 1. Task.Run против StartNew ==
await StartNew(async …) : 7 мс (дождались только запуска, Task<Task>)
await StartNew(async …).Unwrap() : 301 мс
await Task.Run(async …) : 301 мс (разворачивает сам)
внутри задачи на ConcurrentExclusiveTaskScheduler:
StartNew(…) пошла в : ConcurrentExclusiveTaskScheduler
Task.Run(…) пошла в : ThreadPoolTaskScheduler
StartNew(…, HideScheduler) в : ThreadPoolTaskScheduler
флаги StartNew: 0x01032000, Task.Run: 0x01032008
родитель StartNew + дочерняя AttachedToParent ждёт дочернюю: True
родитель Task.Run + дочерняя AttachedToParent ждёт дочернюю: False
поток пула: Task.Run = True, LongRunning = False
Строки 2–4 вывода (строки 15–23 кода): await задачи Task<Task> ждёт только запуск лямбды (несколько миллисекунд), а не Delay(300). Внешняя задача завершается, когда async-лямбда вернула внутреннюю задачу, а не когда та завершилась. С Unwrap() и с Task.Run ждём все 300 мс.
Строки 5–8 (строки 25–32): внутри задачи на ConcurrentExclusiveTaskScheduler голый StartNew попал в тот же планировщик, а Task.Run — в пул. Если планировщик последовательный (как здесь), «простой» StartNew внутри его задачи встанет в очередь за ней. В опыте .Result вложенной задачи не завис только потому, что Wait сам выполняет не начатую задачу на месте (WrappedTryRunInline в InternalWaitCore, глава 6); когда выполнить на месте нельзя, блокирующее ожидание внутри такого планировщика заканчивается зависанием. HideScheduler (строка 31) возвращает Default.
Строка 9 (строки 34–37): флаги из главы 4. Разница ровно в 0x8 — это DenyChildAttach у Task.Run.
Строки 10–11 (строки 38–39): DenyChildAttach отключает «прикрепление» дочерних задач. Дочерняя AttachedToParent задача, запущенная внутри StartNew, родитель ждёт (родитель считается завершённым, когда завершены прикреплённые дочерние). Внутри Task.Run дочерняя уже не прикрепляется, и await родителя её не ждёт.
Строка 12 (строки 40–43): LongRunning запускает работу на отдельном потоке, а не в пуле:
protectedinternaloverridevoidQueueTask(Tasktask){TaskCreationOptionsoptions=task.Options;if((options&TaskCreationOptions.LongRunning)!=0){Threadthread=newThread(s_longRunningThreadWork);thread.IsBackground=true;thread.Name=".NET Long Running Task";thread.UnsafeStart(task);}else{ThreadPool.UnsafeQueueUserWorkItemInternal(task,(options&TaskCreationOptions.PreferFairness)==0);}}
Строки 4–9:new Thread на каждую LongRunning-задачу (с именем .NET Long Running Task). Это оправдано для действительно долгих блокирующих циклов (не занимать пул). Для коротких задач создавать поток дороже, чем взять из пула.
Строка 13: обычный путь — рабочий элемент пула; PreferFairness задаёт, в локальную (true по умолчанию) или в глобальную очередь.
Факт: Task.Factory.StartNew без планировщика наследует планировщик текущей задачи
В первой версии курса это называлось «ловушкой» словами. Проверено запуском: внутри задачи на ConcurrentExclusiveTaskScheduler вложенный StartNew попал в тот же планировщик (строка 6 вывода), Task.Run — в пул (строка 7). Частый источник сюрпризов в коде, который вызывают из чужих задач, например изнутри Task.Factory.StartNew(…, scheduler) или из ExclusiveScheduler: вложенная работа встаёт в очередь того планировщика, а не в пул. Если нужен пул, берите Task.Run или передавайте TaskScheduler.Default явно.
Предскажите
В start/Program.cs у строк стоят комментарии PREDICT. Запишите, сколько миллисекунд займёт каждый await и в какой планировщик попадёт задача внутри ExclusiveScheduler. Потом запускайте:
cd chaptersdotnetrun-cRelease--project10-task-api\start
Задание (TODO 1)
В start/Program.cs вариант с StartNew ждёт 3 мс вместо 500. Исправьте двумя способами.
Практическое правило: почти всегда берите Task.Run. StartNew нужен, когда нужны опции (LongRunning, AttachedToParent) или свой планировщик, и тогда планировщик указывайте явно. Task.Run в ASP.NET Core вокруг I/O бессмыслен (глава 16): он оправдан только для вынесения CPU-тяжёлой работы, и то осторожно: вы всё равно тратите поток пула, просто другой.
Строка 3: планировщик по умолчанию — TaskScheduler.Current. Как у StartNew, он зависит от того, где вы вызвали ContinueWith (§10.1).
Строка 9: это новый объект-задача (ContinuationTaskFromTask) со своим делегатом. Для сравнения, await кладёт в продолжения уже существующий бокс (глава 4, §4.3).
Строка 17: когда исходная задача завершилась, продолжение ставится в планировщик как рабочий элемент. Контекст синхронизации не используется.
Console.WriteLine("== 2. ContinueWith против await на потоке UI ==");varui=newUiThread();awaitui.RunAsync(async()=>{Console.WriteLine($" старт : {Here.Now}");awaitTask.Delay(50);Console.WriteLine($" после await : {Here.Now}");Task<string>cw=Task.Delay(50).ContinueWith(_=>Here.Now);Console.WriteLine($" ContinueWith : {await cw}");Task<string>cwUi=Task.Delay(50).ContinueWith(_=>Here.Now,TaskScheduler.FromCurrentSynchronizationContext());Console.WriteLine($" ContinueWith(…UI-планировщик): {await cwUi}");});Task<int>broken=Task.FromException<int>(newInvalidOperationException("упала"));Task<string>ignoring=broken.ContinueWith(_=>"продолжение не смотрело на исключение");stringignoringResult=awaitignoring;Console.WriteLine($" ContinueWith без проверки ошибки: Status={ignoring.Status}, результат «{ignoringResult}», ошибка исходной задачи никому не видна");try{awaitbroken;}catch(InvalidOperationException){Console.WriteLine(" await той же задачи бросает InvalidOperationException");}varasyncCw=Task.CompletedTask.ContinueWith(async_=>awaitTask.Delay(300));sw.Restart();awaitasyncCw;Console.WriteLine($" ContinueWith(async …) это Task<Task>: {asyncCw is Task<Task>}; await внешней задачи занял {sw.ElapsedMilliseconds} мс (внутренняя ещё работает)");
== 2. ContinueWith против await на потоке UI ==
старт : поток 10 (UI), SynchronizationContext=UiThread
после await : поток 10 (UI), SynchronizationContext=UiThread
ContinueWith : поток 4 (пул)
ContinueWith(…UI-планировщик): поток 10 (UI), SynchronizationContext=UiThread, TaskScheduler=SynchronizationContextTaskScheduler
ContinueWith без проверки ошибки: Status=RanToCompletion, результат «продолжение не смотрело на исключение», ошибка исходной задачи никому не видна
await той же задачи бросает InvalidOperationException
ContinueWith(async …) это Task<Task>: True; await внешней задачи занял 0 мс (внутренняя ещё работает)
Строки 2–3 вывода:await возвращает на поток UI (глава 5).
Строка 4:ContinueWithне возвращает на поток UI: продолжение выполнил поток пула (планировщик по умолчанию на потоке UI — Default, а не контекст). Чтобы вернуться, нужно явно передать TaskScheduler.FromCurrentSynchronizationContext() (строка 5).
Строка 6: если продолжение не смотрит на исходную задачу, ошибка пропадает: продолжение завершилось успешно, исходная задача Faulted и её исключение не прочитано (глава 8, §8.6). await же исключение достаёт и бросает (строка 7).
Строка 8:ContinueWith(async …) возвращает Task<Task>, как StartNew: внешняя задача готова сразу, когда лямбда вернула внутреннюю.
Итог. В новом коде используйте await: оно возвращает в контекст, достаёт исключения, не плодит Task<Task> и не создаёт лишних объектов. ContinueWith оправдан в библиотеках низкого уровня, где нужны TaskContinuationOptions (OnlyOnFaulted, ExecuteSynchronously и другие) или где продолжение нужно повесить на задачу без async-метода.
TaskCompletionSource<T> — мост от событий и обратных вызовов к Task (внутренности — глава 4, §4.4). Правила:
завершать через TrySetResult / TrySetException / TrySetCanceled: они возвращают false, если задача уже завершена, а SetResult в этом случае бросает;
создавать с TaskCreationOptions.RunContinuationsAsynchronously, если нет веской причины иначе (глава 5, §5.6): иначе чужие продолжения выполнятся внутри вашего SetResult;
убедиться, что задача завершится при любом исходе, включая ошибку и отмену.
Console.WriteLine("== 3. TaskCompletionSource ==");vartcs=newTaskCompletionSource<int>();Console.WriteLine($" TrySetResult(1): {tcs.TrySetResult(1)}, второй раз: {tcs.TrySetResult(2)}");try{tcs.SetResult(3);}catch(InvalidOperationExceptione){Console.WriteLine($" SetResult после завершения: {e.GetType().Name}");}varsource=newLineSource();Task<string>good=source.ReadLineAsync();Console.WriteLine($" обработчиков после подписки: {source.HandlerCount}");source.Raise("привет");Console.WriteLine($" результат: «{await good}», обработчиков после события: {source.HandlerCount}");using(varcts=newCancellationTokenSource()){Task<string>cancellable=source.ReadLineAsync(cts.Token);cts.Cancel();Console.WriteLine($" после отмены: Status={cancellable.Status}, обработчиков: {source.HandlerCount}");}Task<string>hanging=source.ReadLineBrokenAsync();try{source.Raise("не число");}catch(FormatException){Console.WriteLine(" в обработчике бросило FormatException");}try{awaithanging.WaitAsync(TimeSpan.FromMilliseconds(300));}catch(TimeoutException){Console.WriteLine($" задача не завершилась никогда: Status={hanging.Status}");}
== 3. TaskCompletionSource ==
TrySetResult(1): True, второй раз: False
SetResult после завершения: InvalidOperationException
обработчиков после подписки: 1
результат: «привет», обработчиков после события: 0
после отмены: Status=Canceled, обработчиков: 0
в обработчике бросило FormatException
задача не завершилась никогда: Status=WaitingForActivation
Строки 4–6: правильная обёртка отписывается и после события, и после отмены (счётчик HandlerCount — 0).
Строки 7–8: типичная ошибка. В ReadLineBrokenAsync (final/Events.cs) между подпиской и SetResult есть int.Parse, и она бросает FormatException. Исключение вылетает в того, кто вызвал Raise, а TaskCompletionSource остаётся ни завершённым, ни провалившимся: await на этой задаче никогда не вернётся (WaitingForActivation; мы вышли только по WaitAsync с таймаутом, глава 9). Правило: всё, что может бросить между «подписались» и «завершили», оборачивайте в try/catch с TrySetException.
== 4. Комбинаторы ==
WhenAny вернул задачу, которая выиграла: Result=100, остальные: boom=WaitingForActivation, slow=WaitingForActivation
после ожидания: boom=Faulted, slow=WaitingForActivation
UnobservedTaskException сработал 1 раз(а)
Task.WhenAll() без аргументов завершён сразу: True
Task.Delay(0) возвращает завершённую задачу: True (ReferenceEquals с CompletedTask: True)
Строки 2–3 вывода:WhenAny вернул задачу-победителя (с Result=100), остальные продолжили работать. Одна из проигравших упала (boom=Faulted), и если проигравших не ждать, её исключение теряется: сработало только событие UnobservedTaskException при сборке мусора (строка 4; глава 8, §8.6).
Строка 6:Task.Delay(0) — это сам Task.CompletedTask (мы видели строку return CompletedTask в листинге Task.Delay главы 9). То есть await Task.Delay(0)не приостанавливается, в отличие от await Task.Yield() (глава 1, §1.3). Для «уступить поток» нужен Yield.
Факт: await Task.Delay(0) не уступает поток
В первой версии курса в таблице было: «нулевая пауза не равна Yield». Проверено: Task.Delay(0) возвращает тот же объект, что Task.CompletedTask (ReferenceEquals даёт True), поэтому await на ней проходит синхронно, без приостановки. Если нужно именно уступить очередь — await Task.Yield().
Каждый WhenAny заново подписывается на все оставшиеся задачи и снимает подписку, так что цикл квадратичен. WhenEach подписывается на каждую задачу один раз:
Строка 1: состояние — обычная очередь завершённых задач (Queue<Task>) плюс ITaskCompletionAction: когда любая подписанная задача завершается, её кладут в очередь и будят потребителя.
Строки 9–13: по одной подписке на задачу. Итератор await foreach берёт из очереди.
(Сами задачи — Delay от 1 до 20 мс, так что 20 мс — это время, за которое они все завершаются.) На 16 000 задач цикл WhenAny + Remove занял около двух секунд, WhenEach — те же 20 мс, что и сами задачи. Если у вас больше сотни задач и нужно реагировать по мере готовности, берите WhenEach.
Факт: цикл WhenAny + Remove квадратичен, WhenEach — нет
В первой версии курса WhenEach стоял с пометкой «проверить». Проверено: на 1 000 задач разницы почти нет (20 и 22 мс), на 4 000 — в 4–5 раз (20 и около 100 мс), на 16 000 — в сто раз (20 мс и около 2 секунд). Причина видна в коде: WhenAny на каждом вызове подписывается на все оставшиеся задачи (и снимает подписки), а WhenEach подписывается один раз (строки 9–13 листинга).
// 1) Parallel.ForEachAsync (.NET 6+)awaitParallel.ForEachAsync(urls,newParallelOptions{MaxDegreeOfParallelism=8,CancellationToken=ct},async(url,token)=>awaitclient.GetStringAsync(url,token));// 2) SemaphoreSlim как асинхронный ограничительvargate=newSemaphoreSlim(8);vartasks=urls.Select(asyncurl=>{awaitgate.WaitAsync(ct);try{returnawaitclient.GetStringAsync(url,ct);}finally{gate.Release();}// обязательно в finally});varresults=awaitTask.WhenAll(tasks);
Как устроен Parallel.ForEachAsync (из System.Threading.Tasks.Parallel.dll, сокращено):
publicvoidQueueWorkerIfDopAvailable(){if(_remainingDop>0){_remainingDop--;Interlocked.Increment(ref_completionRefCount);if(_scheduler==TaskScheduler.Default){ThreadPool.UnsafeQueueUserWorkItem(this,preferLocal:false);}...}}// тело воркераwhile(!state.Cancellation.IsCancellationRequested){awaitstate.AcquireLock();// SemaphoreSlim(1, 1) вокруг общего перечислителяTSourcecurrent;try{if(state.Cancellation.IsCancellationRequested||!state.Enumerator.MoveNext()){break;}current=state.Enumerator.Current;}finally{state.ReleaseLock();}if(!launchedNext){launchedNext=true;state.QueueWorkerIfDopAvailable();// запустить следующего воркера}awaitstate.LoopBody(current,state.Cancellation.Token);}...catch(Exceptione){state.RecordException(e);}// RecordException отменяет Cancellation
Строки 1–13: не больше MaxDegreeOfParallelismворкеров: каждый — рабочий элемент пула, который в цикле берёт следующий элемент и ждёт его обработку.
Строки 18–31: воркеры по очереди берут элементы из одного общего перечислителя под асинхронным замком SemaphoreSlim(1, 1) (§10.6).
Строки 32–36: воркеры стартуют не все сразу, а цепочкой: каждый запускает следующего, пока есть свободная параллельность.
Console.WriteLine("== 5. Ограничение параллелизма: 20 «запросов» по 100 мс, не больше 4 одновременно ==");vargauge=newConcurrencyGauge();sw.Restart();awaitParallel.ForEachAsync(Enumerable.Range(0,20),newParallelOptions{MaxDegreeOfParallelism=4},async(i,ct)=>awaitgauge.CallAsync(ct));Console.WriteLine($" Parallel.ForEachAsync: {sw.ElapsedMilliseconds} мс, максимум одновременно {gauge.Max}");gauge=newConcurrencyGauge();vargate=newSemaphoreSlim(4);sw.Restart();awaitTask.WhenAll(Enumerable.Range(0,20).Select(async_=>{awaitgate.WaitAsync();try{awaitgauge.CallAsync(default);}finally{gate.Release();}// обязательно в finally}));Console.WriteLine($" SemaphoreSlim : {sw.ElapsedMilliseconds} мс, максимум одновременно {gauge.Max}");gauge=newConcurrencyGauge();awaitParallel.ForEachAsync(Enumerable.Range(0,20),async(i,ct)=>awaitgauge.CallAsync(ct));Console.WriteLine($" без MaxDegreeOfParallelism: максимум одновременно {gauge.Max} (ядер: {Environment.ProcessorCount})");gauge=newConcurrencyGauge();try{awaitParallel.ForEachAsync(Enumerable.Range(0,100),newParallelOptions{MaxDegreeOfParallelism=4},async(i,ct)=>{awaitgauge.CallAsync(ct);if(i==5)thrownewInvalidOperationException($"упал элемент {i}");});}catch(InvalidOperationExceptione){Console.WriteLine($" первое исключение остановило цикл: «{e.Message}», обработано элементов: {gauge.Started} из 100");}
== 5. Ограничение параллелизма: 20 «запросов» по 100 мс, не больше 4 одновременно ==
Parallel.ForEachAsync: 510 мс, максимум одновременно 4
SemaphoreSlim : 505 мс, максимум одновременно 4
без MaxDegreeOfParallelism: максимум одновременно 2 (ядер: 2)
первое исключение остановило цикл: «упал элемент 5», обработано элементов: 11 из 100
== 5. Ограничение параллелизма: 20 «запросов» по 100 мс, не больше 4 одновременно ==
Parallel.ForEachAsync: 562 мс, максимум одновременно 4
SemaphoreSlim : 543 мс, максимум одновременно 4
без MaxDegreeOfParallelism: максимум одновременно 8 (ядер: 8)
первое исключение остановило цикл: «упал элемент 5», обработано элементов: 10 из 100
Разница между ОС здесь двойная. Предел без MaxDegreeOfParallelism равен числу ядер (стенды разные: 2 и 8), от ОС это не зависит. Время 562 против 510 мс — из-за таймера: Task.Delay(100) на Windows занимает ≈ 107 мс (см. плашку в §10.7). «Обработано элементов» до остановки цикла зависит от гонки и меняется от запуска к запуску.
Строки 2–3 вывода: обе техники держат 4 одновременных вызова. Время 20 × 100 / 4 = 500 мс плюс накладные.
Строка 4: без MaxDegreeOfParallelism предел — число ядер (здесь 2): рассчитан на CPU-работу. Для I/O с сотнями вызовов его нужно задавать явно.
Строка 5: после падения элемента 5 цикл остановился, взято всего 11 из 100 элементов (по одному воркеру успели добрать). await бросает только первое исключение (глава 8).
Задание (TODO 2)
В start/Program.cs двадцать «запросов» стартуют все сразу (максимум одновременно 20). Ограничьте их до 4 с помощью SemaphoreSlim. Убедитесь по Max, что их стало 4, а общее время выросло до пятисот миллисекунд. Освобождали ли вы семафор в finally?
privateTask<bool>WaitAsyncCore(longmillisecondsTimeout,CancellationTokencancellationToken){...lock(m_lockObjAndDisposed){if(m_currentCount>0){m_currentCount--;...returnTask.FromResult(result:true);// синхронный путь: свободно}if(millisecondsTimeout==0L){returnTask.FromResult(result:false);}TaskNodetaskNode=CreateAndAddAsyncWaiter();// в конец связного спискаreturn(millisecondsTimeout==-1&&!cancellationToken.CanBeCanceled)?taskNode:WaitUntilCountOrTimeoutAsync(taskNode,millisecondsTimeout,cancellationToken);}}publicintRelease(intreleaseCount){...lock(m_lockObjAndDisposed){...if(m_maxCount-currentCount<releaseCount){thrownewSemaphoreFullException();}...if(m_asyncHead!=null){...while(num3>0&&m_asyncHead!=null){currentCount--;num3--;TaskNodeasyncHead=m_asyncHead;RemoveAsyncWaiter(asyncHead);asyncHead.TrySetResult(result:true);// разбудить ПЕРВОГО ожидающего}}...}}privatesealedclassTaskNode:Task<bool>{internalTaskNodePrev;internalTaskNodeNext;internalTaskNode():base((object)null,TaskCreationOptions.RunContinuationsAsynchronously){}}
Строки 5–10: если семафор свободен, WaitAsync возвращает готовую задачу, без ожидания и аллокации списка (путь await без приостановки, глава 1).
Строки 16–17: иначе создаётся узелTaskNode и ставится в конец связного списка. Сам узел и есть возвращаемая задача (Task<bool>): отдельного объекта-обещания нет.
Строки 27–29:Release бросает SemaphoreFullException, если счётчик превысит максимум (лишний Release).
Строки 35–42:Release берёт голову списка, то есть ожидающего, пришедшего раньше всех, и завершает его задачу. Значит, порядок получения — FIFO.
Строки 53–56:TaskNode создаётся с RunContinuationsAsynchronously. Это та самая защита от синхронных продолжений (глава 5, §5.6): код ожидавшего не выполняется внутри Release, пока вы держите чужие блокировки.
Console.WriteLine("== 6. SemaphoreSlim как асинхронная блокировка ==");varmutex=newSemaphoreSlim(1,1);awaitmutex.WaitAsync();Task<bool>second=mutex.WaitAsync(TimeSpan.FromMilliseconds(200));Console.WriteLine($" повторный вход из того же потока выполнения: получил блокировку = {await second} (реентерабельности нет)");mutex.Release();try{mutex.Release();}catch(SemaphoreFullException){Console.WriteLine(" лишний Release: SemaphoreFullException");}varqueue=newSemaphoreSlim(1,1);awaitqueue.WaitAsync();// блокировка занятаvarorder=newList<int>();Task[]waiters=Enumerable.Range(1,5).Select(asyncn=>{awaitqueue.WaitAsync();// вызов WaitAsync происходит по порядку: n = 1…5order.Add(n);queue.Release();}).ToArray();queue.Release();awaitTask.WhenAll(waiters);Console.WriteLine($" порядок получения блокировки пятью ожидающими: {string.Join(",", order)}");varrc=newSemaphoreSlim(0,1);intreleaseThread=0,continuationThread=0;Taskawaiter=Task.Run(async()=>{awaitrc.WaitAsync();continuationThread=Environment.CurrentManagedThreadId;});awaitTask.Delay(100);awaitTask.Run(()=>{releaseThread=Environment.CurrentManagedThreadId;rc.Release();Thread.Sleep(50);});awaitawaiter;Console.WriteLine($" Release из потока {releaseThread}, продолжение ожидавшего на потоке {continuationThread}: другой поток = {releaseThread != continuationThread}");
== 6. SemaphoreSlim как асинхронная блокировка ==
повторный вход из того же потока выполнения: получил блокировку = False (реентерабельности нет)
лишний Release: SemaphoreFullException
порядок получения блокировки пятью ожидающими: 1, 2, 3, 4, 5
Release из потока 12, продолжение ожидавшего на потоке 11: другой поток = True
Строка 2 вывода: повторный вход из того же логического потока выполнения (мы уже держим блокировку) не удался: WaitAsync с таймаутом вернул false. Без таймаута это был бы вечный дедлок. Реентерабельности нет, потому что у семафора нет понятия «владелец».
Строка 4: пять ожидающих получили блокировку в порядке постановки (FIFO).
Строка 5: продолжение ожидавшего выполнилось на другом потоке, чем Release (RunContinuationsAsynchronously).
Правила: Release всегда в finally; не использовать для рекурсивных блокировок; не забывать, что блокировка работает внутри процесса (между процессами и машинами не защитит).
Цикл while (true) { await Task.Delay(period); Work(); } накапливает дрейф: следующий период отсчитывается после работы. PeriodicTimer (.NET 6) отсчитывает периоды от своего старта:
publicPeriodicTimer(TimeSpanperiod){..._state=newState();_timer=newTimerQueueTimer((objects)=>{((State)s).Signal();},_state,milliseconds,milliseconds,flowExecutionContext:false);}// State : IValueTaskSource<bool>publicValueTask<bool>WaitForNextTickAsync(PeriodicTimerowner,CancellationTokencancellationToken){lock(this){if(_activeWait){ThrowHelper.ThrowInvalidOperationException();}...if(_signaled){if(!_stopped){_signaled=false;}returnnewValueTask<bool>(!_stopped);}_activeWait=true;...returnnewValueTask<bool>(this,_mrvtsc.Version);}}publicvoidSignal(boolstopping=false,CancellationTokencancellationToken=default){boolflag=false;lock(this){_stopped|=stopping;if(!_signaled){_signaled=true;flag=_activeWait;}}if(flag){_mrvtsc.SetResult(result:true);}}
Строки 5–8: обычный повторяющийся таймер из очереди таймеров (period и period). Он живёт сам по себе, пока вы выполняете работу.
Строки 16–19: одновременно может ждать одноWaitForNextTickAsync; второе бросает InvalidOperationException.
Строки 21–28: если тик уже случился, пока вы работали (_signaled), следующий WaitForNextTickAsync вернёт готовыйValueTask без ожидания.
Строки 41–46: _signaled — булев флаг, а не счётчик. Сколько бы тиков ни прошло за время работы, накопится один. Пропущенные тики не копятся.
Строка 31: возвращается ValueTask<bool> поверх ManualResetValueTaskSourceCore: без аллокации Task на каждый тик (глава 12).
Console.WriteLine();Console.WriteLine("== 7. PeriodicTimer против цикла с Delay: работа 40 мс, период 100 мс, 10 итераций ==");sw.Restart();varstamps=newList<long>();using(vartimer=newPeriodicTimer(TimeSpan.FromMilliseconds(100))){for(inti=0;i<10&&awaittimer.WaitForNextTickAsync();i++){stamps.Add(sw.ElapsedMilliseconds);awaitTask.Delay(40);}}Console.WriteLine($" PeriodicTimer: начало итераций (мс) {string.Join("", stamps.Select(s => s / 10 * 10))}, всего {sw.ElapsedMilliseconds} мс");sw.Restart();stamps.Clear();for(inti=0;i<10;i++){awaitTask.Delay(100);stamps.Add(sw.ElapsedMilliseconds);awaitTask.Delay(40);}Console.WriteLine($" цикл с Delay : начало итераций (мс) {string.Join("", stamps.Select(s => s / 10 * 10))}, всего {sw.ElapsedMilliseconds} мс");using(vartimer=newPeriodicTimer(TimeSpan.FromMilliseconds(100))){awaitTask.Delay(450);// «долгая работа»: за это время прошло 4 тикаintimmediate=0;for(inti=0;i<4;i++){ValueTask<bool>tick=timer.WaitForNextTickAsync();if(tick.IsCompleted)immediate++;awaittick;}Console.WriteLine($" за 450 мс прошло 4 тика, мгновенно завершились ожидания: {immediate} (пропущенные не копятся)");}using(vartimer=newPeriodicTimer(TimeSpan.FromSeconds(10)))
== 7. PeriodicTimer против цикла с Delay: работа 40 мс, период 100 мс, 10 итераций ==
PeriodicTimer: начало итераций (мс) 100 200 300 400 500 600 700 800 900 1000, всего 1046 мс
цикл с Delay : начало итераций (мс) 90 240 380 520 660 800 940 1080 1220 1360, всего 1408 мс
за 450 мс прошло 4 тика, мгновенно завершились ожидания: 1 (пропущенные не копятся)
два одновременных WaitForNextTickAsync: InvalidOperationException
== 7. PeriodicTimer против цикла с Delay: работа 40 мс, период 100 мс, 10 итераций ==
PeriodicTimer: начало итераций (мс) 100 200 310 400 500 600 700 800 910 1000, всего 1054 мс
цикл с Delay : начало итераций (мс) 110 260 410 560 710 860 1010 1170 1320 1480, всего 1527 мс
за 450 мс прошло 4 тика, мгновенно завершились ожидания: 1 (пропущенные не копятся)
два одновременных WaitForNextTickAsync: InvalidOperationException
Факт: на Windows таймеры «квантуются» по ≈ 15,6 мс
Выводы те же на обеих ОС: PeriodicTimer не дрейфует (на Windows его начала сдвигаются на ±10 мс, но не накапливаются), цикл с Delay дрейфует на время работы, пропущенные тики не копятся. Отличаются сами числа: цикл с Delay на Linux тратит ≈ 140 мс на итерацию, на Windows ≈ 150 мс (всего 1 527 против 1 408 мс).
Причина в разрешении системных часов. Замер на Windows 11 (рантайм 10.0.12, среднее по 20–50 вызовам): Task.Delay(1) ≈ 14,7 мс, Delay(10) ≈ 14,9, Delay(50) ≈ 62, Delay(100) ≈ 107 мс. Ожидание таймера на Windows заканчивается на ближайшем тике системных часов (обычно ≈ 15,6 мс), а не в запрошенный миллисекунд. На Linux такой «ступеньки» нет на Linux замер Delay(1) не делался; ступеньку на Windows объясняю гранулярностью системных часов, а не разбором кода таймера. Вывод для практики: на Windows не полагайтесь на Delay меньше ≈ 15 мс, а периодическую работу делайте на PeriodicTimer, а не циклом с Delay.
Строка 2 вывода:PeriodicTimer начинает итерации в 100, 200, 300… — без дрейфа, хотя каждая итерация работает 40 мс.
Строка 3: цикл с Delay начинает в 90, 240, 380…: каждая итерация сдвигается на время работы (140 мс вместо 100). За 10 итераций набежало 360 мс.
Строка 4: за 450 мс «долгой работы» прошло 4 тика, а мгновенно завершилось одно ожидание: остальные три пропущены, не накопились.
== 8. Ленивая асинхронная инициализация ==
Lazy<Task>, вызов 1: первая попытка (попыток инициализации: 1)
Lazy<Task>, вызов 2: первая попытка (попыток инициализации: 1)
AsyncLazy, вызов 1: первая попытка (попыток: 1)
AsyncLazy, вызов 2: 42 (попыток: 2)
AsyncLazy, вызов 3: 42 (попыток: 2)
Строки 2–3 вывода:Lazy<Task<int>> выполнил фабрику один раз; провалившаяся задача кеширована, и все следующие вызовы получают то же исключение. Повторить нельзя.
Строки 4–6:AsyncLazy<T> из лабораторной (final/AsyncLazy.cs, на SemaphoreSlim) не взводит флаг при ошибке: следующий вызов пробует снова, а после успеха возвращает значение без блокировки.
Ещё одна оговорка про Lazy<Task<T>>: фабрика выполняется в контексте первого вызвавшего (глава 5): если это поток UI, дальнейшая работа фабрики до первого await идёт на нём. Готовые реализации: AsyncLazy<T> из библиотеки Nito.AsyncEx.
Task.Run — всегда Default, разворачивает async-лямбду, DenyChildAttach. StartNew без планировщика наследует планировщик текущей задачи, возвращает Task<Task> для async-лямбды. По умолчанию берите Task.Run.
ContinueWith создаёт отдельную задачу в TaskScheduler.Current, не возвращает в SynchronizationContext и «не замечает» ошибку исходной задачи. В новом коде — await.
TaskCompletionSource: Try*-методы, RunContinuationsAsynchronously, и задача должна завершаться при любом исходе (иначе await зависнет), а обработчики событий — отписываться.
WhenAny возвращает задачу-победителя, проигравшие продолжают работать, и их исключения теряются. WhenEach подписывается на каждую задачу один раз и на больших наборах быстрее цикла WhenAny на порядки. Task.Delay(0) не уступает поток.
Ограничение параллелизма: Parallel.ForEachAsync (воркеры в числе DOP, предел по умолчанию — число ядер, первое исключение отменяет цикл) или SemaphoreSlim (освобождать в finally).
PeriodicTimer не дрейфует и не копит пропущенные тики, одно ожидание за раз. Lazy<Task<T>> кеширует ошибку навсегда: нужна своя ленивая инициализация с повтором.
// Глава 10, заготовка. Task.Run против StartNew и ограничение параллелизма.// PREDICT: через сколько мс завершится каждый await? В какой планировщик попадёт задача внутри ExclusiveScheduler?// TODO 1 (§10.1): исправьте вариант StartNew двумя способами, чтобы await ждал 500 мс.// TODO 2 (§10.5): запустите 20 «запросов» по 100 мс так, чтобы одновременно шло не больше 4 (SemaphoreSlim),// и убедитесь по счётчику Max в ConcurrencyGauge.usingSystem.Diagnostics;varsw=Stopwatch.StartNew();awaitTask.Run(async()=>awaitTask.Delay(500));Console.WriteLine($"Task.Run : {sw.ElapsedMilliseconds} мс");// PREDICT:sw.Restart();varouter=Task.Factory.StartNew(async()=>awaitTask.Delay(500));// какой тип у outer? TODO 1awaitouter;Console.WriteLine($"StartNew : {sw.ElapsedMilliseconds} мс");// PREDICT:TaskSchedulerexclusive=newConcurrentExclusiveSchedulerPair().ExclusiveScheduler;awaitTask.Factory.StartNew(()=>{Console.WriteLine($"StartNew внутри: {Task.Factory.StartNew(() => TaskScheduler.Current.GetType().Name).Result}");// PREDICT:Console.WriteLine($"Task.Run внутри: {Task.Run(() => TaskScheduler.Current.GetType().Name).Result}");// PREDICT:},CancellationToken.None,TaskCreationOptions.None,exclusive);vargauge=newConcurrencyGauge();sw.Restart();awaitTask.WhenAll(Enumerable.Range(0,20).Select(_=>gauge.CallAsync(default)));// TODO 2: сейчас ограничения нетConsole.WriteLine($"20 запросов: {sw.ElapsedMilliseconds} мс, максимум одновременно {gauge.Max}");sealedclassConcurrencyGauge{privateint_current;publicintMax;publicasyncTaskCallAsync(CancellationTokenct){intnow=Interlocked.Increment(ref_current);intseen;while(now>(seen=Volatile.Read(refMax))&&Interlocked.CompareExchange(refMax,now,seen)!=seen){}awaitTask.Delay(100,ct);// имитация HTTP-вызоваInterlocked.Decrement(ref_current);}}
// Считает, сколько «вызовов» выполняется одновременно, и запоминает максимум.sealedclassConcurrencyGauge{privateint_current;publicintMax;publicintStarted;publicasyncTaskCallAsync(CancellationTokenct){Interlocked.Increment(refStarted);intnow=Interlocked.Increment(ref_current);intseen;while(now>(seen=Volatile.Read(refMax))&&Interlocked.CompareExchange(refMax,now,seen)!=seen){}awaitTask.Delay(100,ct);// имитация HTTP-вызоваInterlocked.Decrement(ref_current);}}
// Источник событий для §10.3: мост «событие → Task» через TaskCompletionSource.sealedclassLineSource{publiceventAction<string>?LineRead;publicintHandlerCount=>LineRead?.GetInvocationList().Length??0;publicvoidRaise(stringline)=>LineRead?.Invoke(line);// Правильно: обработчик отписывается и при успехе, и при отмене; исключение в обработчике не оставляет задачу висеть.publicTask<string>ReadLineAsync(CancellationTokenct=default){vartcs=newTaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);CancellationTokenRegistrationregistration=default;voidHandler(stringline){LineRead-=Handler;registration.Dispose();tcs.TrySetResult(line);}LineRead+=Handler;registration=ct.Register(()=>{LineRead-=Handler;tcs.TrySetCanceled(ct);});returntcs.Task;}// Ошибка: между подпиской и SetResult есть код, который может бросить, и задача не завершится никогда.publicTask<string>ReadLineBrokenAsync(){vartcs=newTaskCompletionSource<string>();LineRead+=line=>{intlength=Parse(line);// бросает на плохой строкеtcs.SetResult($"{line} ({length})");};returntcs.Task;staticintParse(stringline)=>int.Parse(line);}}
// Ленивая асинхронная инициализация с повтором после ошибки (§10.8).sealedclassAsyncLazy<T>{privatereadonlyFunc<Task<T>>_factory;privatereadonlySemaphoreSlim_gate=new(1,1);privateT?_value;privatebool_hasValue;publicAsyncLazy(Func<Task<T>>factory)=>_factory=factory;publicasyncTask<T>GetAsync(){if(_hasValue)return_value!;await_gate.WaitAsync();try{if(!_hasValue){_value=await_factory();// при исключении флаг не взводится: следующий вызов попробует снова_hasValue=true;}return_value!;}finally{_gate.Release();}}}
usingSystem.Collections.Concurrent;// Однопоточный SynchronizationContext — модель UI-потока (WinForms, WPF, MAUI).// Свой поток "UI" и очередь: Post кладёт делегат в очередь, поток UI выполняет их по одному.sealedclassUiThread:SynchronizationContext{privatereadonlyBlockingCollection<(SendOrPostCallbackCallback,object?State)>_queue=new();privatereadonlyThread_thread;publicUiThread(){_thread=newThread(Loop){Name="UI",IsBackground=true};_thread.Start();}publicoverridevoidPost(SendOrPostCallbackd,object?state){_queue.Add((d,state));}// Запустить async-обработчик на потоке UI и дождаться его завершения.publicTaskRunAsync(Func<Task>handler){varstarted=newTaskCompletionSource<Task>(TaskCreationOptions.RunContinuationsAsynchronously);_queue.Add((_=>started.SetResult(handler()),null));returnstarted.Task.Unwrap();}privatevoidLoop(){SetSynchronizationContext(this);foreach(var(callback,state)in_queue.GetConsumingEnumerable())callback(state);}}staticclassHere{// Где мы сейчас: номер и вид потока, плюс контекст или планировщик, если они не «по умолчанию».publicstaticstringNow{get{varthread=Thread.CurrentThread;stringkind=thread.IsThreadPoolThread?"пул":thread.Name??"основной";stringwhere=$"поток {Environment.CurrentManagedThreadId,2} ({kind})";if(SynchronizationContext.Currentis{}ctx)where+=$", SynchronizationContext={ctx.GetType().Name}";if(TaskScheduler.Current!=TaskScheduler.Default)where+=$", TaskScheduler={TaskScheduler.Current.GetType().Name}";returnwhere;}}}