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

10. Task API: что и когда

О главе

Цель: выбирать правильный инструмент и понимать, что он делает внутри: 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».

10.1. Task.Run против Task.Factory.StartNew

Оба запускают делегат как задачу-«работу» (глава 4). Разница в умолчаниях. Вот что они делают:

CoreLib 10.0.12: Task.Run, TaskFactory.StartNew и выбор планировщика
// Task.Run
public static Task Run(Action action)
{
    return InternalStartNew(null, action, null, default, TaskScheduler.Default, TaskCreationOptions.DenyChildAttach, InternalTaskOptions.None);
}

public static Task Run(Func<Task?> function, CancellationToken cancellationToken)
{
    ...
    return new UnwrapPromise<VoidTaskResult>(Task<Task>.Factory.StartNew(function, cancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default), lookForOce: true);
}

// TaskFactory.StartNew(Action)
public Task StartNew(Action action)
{
    Task internalCurrent = Task.InternalCurrent;
    return Task.InternalStartNew(internalCurrent, action, null, m_defaultCancellationToken, GetDefaultScheduler(internalCurrent), m_defaultCreationOptions, InternalTaskOptions.None);
}

private TaskScheduler GetDefaultScheduler(Task currTask)
{
    TaskScheduler? taskScheduler = m_defaultScheduler;
    if (taskScheduler == null)
    {
        if (currTask == null || (currTask.CreationOptions & TaskCreationOptions.HideScheduler) != 0)
        {
            return TaskScheduler.Default;
        }
        taskScheduler = currTask.ExecutingTaskScheduler;
    }
    return taskScheduler;
}
  • Строка 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 (планировщик текущей задачи, если она есть)
async-лямбда возвращает развёрнутую Task / Task<T> (UnwrapPromise) возвращает Task<Task>, нужен .Unwrap()
Флаги DenyChildAttach None (можно AttachedToParent, LongRunning и др.)

Опыт: четыре отличия

final/Program.cs
var sw = Stopwatch.StartNew();
Task<Task> outer = Task.Factory.StartNew(async () => await Task.Delay(300));
await outer;
Console.WriteLine($"  await StartNew(async …)          : {sw.ElapsedMilliseconds} мс (дождались только запуска, Task<Task>)");
sw.Restart();
await Task.Factory.StartNew(async () => await Task.Delay(300)).Unwrap();
Console.WriteLine($"  await StartNew(async …).Unwrap() : {sw.ElapsedMilliseconds} мс");
sw.Restart();
await Task.Run(async () => await Task.Delay(300));
Console.WriteLine($"  await Task.Run(async …)          : {sw.ElapsedMilliseconds} мс (разворачивает сам)");

TaskScheduler exclusive = new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler;
await Task.Factory.StartNew(() =>
{
    Console.WriteLine($"  внутри задачи на {exclusive.GetType().Name}:");
    Console.WriteLine($"    StartNew(…) пошла в             : {Task.Factory.StartNew(() => TaskScheduler.Current.GetType().Name).Result}");
    Console.WriteLine($"    Task.Run(…) пошла в             : {Task.Run(() => TaskScheduler.Current.GetType().Name).Result}");
    Console.WriteLine($"    StartNew(…, HideScheduler) в    : {Task.Factory.StartNew(() => TaskScheduler.Current.GetType().Name, CancellationToken.None, TaskCreationOptions.HideScheduler, TaskScheduler.Current).Result}");
}, CancellationToken.None, TaskCreationOptions.None, exclusive);

Task viaStart = Task.Factory.StartNew(() => { });
Task viaRun = Task.Run(() => { });
await Task.WhenAll(viaStart, viaRun);
Console.WriteLine($"  флаги StartNew: 0x{Flags(viaStart):X8}, Task.Run: 0x{Flags(viaRun):X8}");
Console.WriteLine($"  родитель StartNew + дочерняя AttachedToParent ждёт дочернюю: {await ParentWaitsAsync(useRun: false)}");
Console.WriteLine($"  родитель Task.Run   + дочерняя AttachedToParent ждёт дочернюю: {await ParentWaitsAsync(useRun: true)}");
bool longPool = false, longDedicated = false;
await Task.Run(() => longPool = Thread.CurrentThread.IsThreadPoolThread);
await Task.Factory.StartNew(() => longDedicated = Thread.CurrentThread.IsThreadPoolThread, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default);
Console.WriteLine($"  поток пула: Task.Run = {longPool}, LongRunning = {longDedicated}");
== 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 запускает работу на отдельном потоке, а не в пуле:
CoreLib 10.0.12: ThreadPoolTaskScheduler.QueueTask
protected internal override void QueueTask(Task task)
{
    TaskCreationOptions options = task.Options;
    if ((options & TaskCreationOptions.LongRunning) != 0)
    {
        Thread thread = new Thread(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 chapters
dotnet run -c Release --project 10-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-тяжёлой работы, и то осторожно: вы всё равно тратите поток пула, просто другой.

10.2. ContinueWith против await

ContinueWith — старый способ цепочек. Посмотрим, что он делает:

CoreLib 10.0.12: Task.ContinueWith (сокращено)
public Task ContinueWith(Action<Task> continuationAction)
{
    return ContinueWith(continuationAction, TaskScheduler.Current, default, TaskContinuationOptions.None);
}

private Task ContinueWith(Action<Task, object> continuationAction, object state, TaskScheduler scheduler, CancellationToken cancellationToken, TaskContinuationOptions continuationOptions)
{
    ...
    Task task = new ContinuationTaskFromTask(this, continuationAction, state, creationOptions, internalOptions);
    ContinueWithCore(task, scheduler, cancellationToken, continuationOptions);
    return task;
}

// ContinueWithTaskContinuation.Run
task.m_taskScheduler = m_taskScheduler;
...
task.ScheduleAndStart(needsProtection: true);
  • Строка 3: планировщик по умолчанию — TaskScheduler.Current. Как у StartNew, он зависит от того, где вы вызвали ContinueWith (§10.1).
  • Строка 9: это новый объект-задача (ContinuationTaskFromTask) со своим делегатом. Для сравнения, await кладёт в продолжения уже существующий бокс (глава 4, §4.3).
  • Строка 17: когда исходная задача завершилась, продолжение ставится в планировщик как рабочий элемент. Контекст синхронизации не используется.

Опыт: поток UI, ошибки и тип

final/Program.cs
Console.WriteLine("== 2. ContinueWith против await на потоке UI ==");
var ui = new UiThread();
await ui.RunAsync(async () =>
{
    Console.WriteLine($"  старт                       : {Here.Now}");
    await Task.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>(new InvalidOperationException("упала"));
Task<string> ignoring = broken.ContinueWith(_ => "продолжение не смотрело на исключение");
string ignoringResult = await ignoring;
Console.WriteLine($"  ContinueWith без проверки ошибки: Status={ignoring.Status}, результат «{ignoringResult}», ошибка исходной задачи никому не видна");
try { await broken; } catch (InvalidOperationException) { Console.WriteLine("  await той же задачи бросает InvalidOperationException"); }
var asyncCw = Task.CompletedTask.ContinueWith(async _ => await Task.Delay(300));
sw.Restart();
await asyncCw;
Console.WriteLine($"  ContinueWith(async …) это Task<Task>: {asyncCw is Task<Task>}; await внешней задачи занял {sw.ElapsedMilliseconds} мс (внутренняя ещё работает)");
1
2
3
4
5
6
7
8
== 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-метода.

10.3. TaskCompletionSource<T>

TaskCompletionSource<T> — мост от событий и обратных вызовов к Task (внутренности — глава 4, §4.4). Правила:

  • завершать через TrySetResult / TrySetException / TrySetCanceled: они возвращают false, если задача уже завершена, а SetResult в этом случае бросает;
  • создавать с TaskCreationOptions.RunContinuationsAsynchronously, если нет веской причины иначе (глава 5, §5.6): иначе чужие продолжения выполнятся внутри вашего SetResult;
  • убедиться, что задача завершится при любом исходе, включая ошибку и отмену.
final/Events.cs: правильная обёртка события
public Task<string> ReadLineAsync(CancellationToken ct = default)
{
    var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
    CancellationTokenRegistration registration = default;
    void Handler(string line)
    {
        LineRead -= Handler;
        registration.Dispose();
        tcs.TrySetResult(line);
    }
    LineRead += Handler;
    registration = ct.Register(() =>
    {
        LineRead -= Handler;
        tcs.TrySetCanceled(ct);
    });
    return tcs.Task;
}
  • Строка 3: RunContinuationsAsynchronously.
  • Строки 5–10: обработчик отписывается при первом событии (иначе он остался бы на источнике навсегда).
  • Строки 12–16: отмена тоже отписывает обработчик и завершает задачу как Canceled.
final/Program.cs
Console.WriteLine("== 3. TaskCompletionSource ==");
var tcs = new TaskCompletionSource<int>();
Console.WriteLine($"  TrySetResult(1): {tcs.TrySetResult(1)}, второй раз: {tcs.TrySetResult(2)}");
try { tcs.SetResult(3); } catch (InvalidOperationException e) { Console.WriteLine($"  SetResult после завершения: {e.GetType().Name}"); }
var source = new LineSource();
Task<string> good = source.ReadLineAsync();
Console.WriteLine($"  обработчиков после подписки: {source.HandlerCount}");
source.Raise("привет");
Console.WriteLine($"  результат: «{await good}», обработчиков после события: {source.HandlerCount}");
using (var cts = new CancellationTokenSource())
{
    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 { await hanging.WaitAsync(TimeSpan.FromMilliseconds(300)); }
catch (TimeoutException) { Console.WriteLine($"  задача не завершилась никогда: Status={hanging.Status}"); }
1
2
3
4
5
6
7
8
== 3. TaskCompletionSource ==
  TrySetResult(1): True, второй раз: False
  SetResult после завершения: InvalidOperationException
  обработчиков после подписки: 1
  результат: «привет», обработчиков после события: 0
  после отмены: Status=Canceled, обработчиков: 0
  в обработчике бросило FormatException
  задача не завершилась никогда: Status=WaitingForActivation
  • Строки 2–3 вывода: TrySetResult вторично возвращает false, SetResult — бросает InvalidOperationException.
  • Строки 4–6: правильная обёртка отписывается и после события, и после отмены (счётчик HandlerCount — 0).
  • Строки 7–8: типичная ошибка. В ReadLineBrokenAsync (final/Events.cs) между подпиской и SetResult есть int.Parse, и она бросает FormatException. Исключение вылетает в того, кто вызвал Raise, а TaskCompletionSource остаётся ни завершённым, ни провалившимся: await на этой задаче никогда не вернётся (WaitingForActivation; мы вышли только по WaitAsync с таймаутом, глава 9). Правило: всё, что может бросить между «подписались» и «завершили», оборачивайте в try/catch с TrySetException.

10.4. Комбинаторы

Метод Что делает Ловушка
Task.WhenAll(tasks) завершается, когда завершатся все; результаты массивом await бросает только одно исключение (глава 8, §8.4)
Task.WhenAny(tasks) возвращает первую завершённую задачу (не её результат) остальные продолжают работать; их исключения никто не прочтёт
Task.WhenEach(tasks) (.NET 9) IAsyncEnumerable<Task>: задачи по мере завершения —
Task.WaitAsync(timeout, ct) ограничить ожидание не отменяет саму задачу (глава 9, §9.6)
Task.Delay(t, ct) пауза без потока (таймер) Delay(0) возвращает уже завершённую задачу и не уступает поток
Task.FromResult, Task.CompletedTask, Task.FromException, Task.FromCanceled готовые завершённые задачи —
final/Program.cs
Console.WriteLine("== 4. Комбинаторы ==");
Task<int> fast = Job(100), slowJob = Job(600);
Task<int> boom = Task.Run<int>(async () => { await Task.Delay(150); throw new InvalidOperationException("проигравшая упала"); });
int unobserved = 0;
TaskScheduler.UnobservedTaskException += (_, e) => { unobserved++; e.SetObserved(); };
Task<int> winner = await Task.WhenAny(fast, boom);
Console.WriteLine($"  WhenAny вернул задачу, которая выиграла: Result={winner.Result}, остальные: boom={boom.Status}, slow={slowJob.Status}");
await Task.Delay(200);
Console.WriteLine($"  после ожидания: boom={boom.Status}, slow={slowJob.Status}");
boom = null!;
for (int i = 0; i < 3; i++) { GC.Collect(); GC.WaitForPendingFinalizers(); }
Console.WriteLine($"  UnobservedTaskException сработал {unobserved} раз(а)");
Task.WhenAll().Wait();
Console.WriteLine($"  Task.WhenAll() без аргументов завершён сразу: {Task.WhenAll().IsCompletedSuccessfully}");
sw.Restart();
Task delayZero = Task.Delay(0);
Console.WriteLine($"  Task.Delay(0) возвращает завершённую задачу: {delayZero.IsCompletedSuccessfully} (ReferenceEquals с CompletedTask: {ReferenceEquals(delayZero, Task.CompletedTask)})");
1
2
3
4
5
6
== 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().

WhenEach против цикла WhenAny

До .NET 9 обрабатывать задачи по мере готовности приходилось циклом из WhenAny:

1
2
3
4
5
6
var pending = tasks.ToList();
while (pending.Count > 0)
{
    Task finished = await Task.WhenAny(pending);
    pending.Remove(finished);
}

Каждый WhenAny заново подписывается на все оставшиеся задачи и снимает подписку, так что цикл квадратичен. WhenEach подписывается на каждую задачу один раз:

CoreLib 10.0.12: Task.WhenEachState (сокращено)
private sealed class WhenEachState : Queue<Task>, IValueTaskSource, ITaskCompletionAction
{
    public static WhenEachState Create(ReadOnlySpan<Task> tasks)
    {
        WhenEachState whenEachState = null;
        if (tasks.Length != 0)
        {
            whenEachState = new WhenEachState();
            foreach (Task obj in tasks)
            {
                whenEachState.Remaining++;
                obj.AddCompletionAction(whenEachState);
            }
        }
        return whenEachState;
    }
    ...
}
  • Строка 1: состояние — обычная очередь завершённых задач (Queue<Task>) плюс ITaskCompletionAction: когда любая подписанная задача завершается, её кладут в очередь и будят потребителя.
  • Строки 9–13: по одной подписке на задачу. Итератор await foreach берёт из очереди.
final/Program.cs
foreach (int n in new[] { 1000, 4000, 16000 })
{
    var many = Enumerable.Range(0, n).Select(i => Task.Delay(1 + i % 20)).ToArray();
    sw.Restart();
    int done = 0;
    await foreach (Task _ in Task.WhenEach(many)) done++;
    long each = sw.ElapsedMilliseconds;

    many = Enumerable.Range(0, n).Select(i => Task.Delay(1 + i % 20)).ToArray();
    sw.Restart();
    var pending = many.ToList();
    while (pending.Count > 0)
    {
        Task finished = await Task.WhenAny(pending);
        pending.Remove(finished);
    }
    long loop = sw.ElapsedMilliseconds;
    Console.WriteLine($"  {n,6} задач: WhenEach {each,5} мс, цикл WhenAny + Remove {loop,6} мс");
}
1
2
3
    1000 задач: WhenEach    20 мс, цикл WhenAny + Remove     22 мс
    4000 задач: WhenEach    20 мс, цикл WhenAny + Remove     93 мс
   16000 задач: WhenEach    20 мс, цикл WhenAny + Remove   1966 мс

(Сами задачи — 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 листинга).

10.5. Ограничение параллелизма

Запуск тысяч задач одновременно перегрузит внешний ресурс (БД, API). Два основных способа:

// 1) Parallel.ForEachAsync (.NET 6+)
await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
    async (url, token) => await client.GetStringAsync(url, token));

// 2) SemaphoreSlim как асинхронный ограничитель
var gate = new SemaphoreSlim(8);
var tasks = urls.Select(async url =>
{
    await gate.WaitAsync(ct);
    try { return await client.GetStringAsync(url, ct); }
    finally { gate.Release(); }       // обязательно в finally
});
var results = await Task.WhenAll(tasks);

Как устроен Parallel.ForEachAsync (из System.Threading.Tasks.Parallel.dll, сокращено):

Parallel.ForEachAsync (сокращено)
public void QueueWorkerIfDopAvailable()
{
    if (_remainingDop > 0)
    {
        _remainingDop--;
        Interlocked.Increment(ref _completionRefCount);
        if (_scheduler == TaskScheduler.Default)
        {
            ThreadPool.UnsafeQueueUserWorkItem(this, preferLocal: false);
        }
        ...
    }
}

// тело воркера
while (!state.Cancellation.IsCancellationRequested)
{
    await state.AcquireLock();                       // SemaphoreSlim(1, 1) вокруг общего перечислителя
    TSource current;
    try
    {
        if (state.Cancellation.IsCancellationRequested || !state.Enumerator.MoveNext())
        {
            break;
        }
        current = state.Enumerator.Current;
    }
    finally
    {
        state.ReleaseLock();
    }
    if (!launchedNext)
    {
        launchedNext = true;
        state.QueueWorkerIfDopAvailable();           // запустить следующего воркера
    }
    await state.LoopBody(current, state.Cancellation.Token);
}
...
catch (Exception e) { state.RecordException(e); }    // RecordException отменяет Cancellation
  • Строки 1–13: не больше MaxDegreeOfParallelism воркеров: каждый — рабочий элемент пула, который в цикле берёт следующий элемент и ждёт его обработку.
  • Строки 18–31: воркеры по очереди берут элементы из одного общего перечислителя под асинхронным замком SemaphoreSlim(1, 1) (§10.6).
  • Строки 32–36: воркеры стартуют не все сразу, а цепочкой: каждый запускает следующего, пока есть свободная параллельность.
  • Строка 37: обработка элемента — ваша асинхронная лямбда.
  • Строка 40: первое исключение отменяет внутренний токен (RecordException → Cancellation.Cancel()): остальные воркеры не берут новые элементы.
final/Program.cs
Console.WriteLine("== 5. Ограничение параллелизма: 20 «запросов» по 100 мс, не больше 4 одновременно ==");
var gauge = new ConcurrencyGauge();
sw.Restart();
await Parallel.ForEachAsync(Enumerable.Range(0, 20), new ParallelOptions { MaxDegreeOfParallelism = 4 },
    async (i, ct) => await gauge.CallAsync(ct));
Console.WriteLine($"  Parallel.ForEachAsync: {sw.ElapsedMilliseconds} мс, максимум одновременно {gauge.Max}");
gauge = new ConcurrencyGauge();
var gate = new SemaphoreSlim(4);
sw.Restart();
await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ =>
{
    await gate.WaitAsync();
    try { await gauge.CallAsync(default); }
    finally { gate.Release(); }        // обязательно в finally
}));
Console.WriteLine($"  SemaphoreSlim        : {sw.ElapsedMilliseconds} мс, максимум одновременно {gauge.Max}");
gauge = new ConcurrencyGauge();
await Parallel.ForEachAsync(Enumerable.Range(0, 20), async (i, ct) => await gauge.CallAsync(ct));
Console.WriteLine($"  без MaxDegreeOfParallelism: максимум одновременно {gauge.Max} (ядер: {Environment.ProcessorCount})");
gauge = new ConcurrencyGauge();
try
{
    await Parallel.ForEachAsync(Enumerable.Range(0, 100), new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (i, ct) =>
    {
        await gauge.CallAsync(ct);
        if (i == 5) throw new InvalidOperationException($"упал элемент {i}");
    });
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"  первое исключение остановило цикл: «{e.Message}», обработано элементов: {gauge.Started} из 100");
}
1
2
3
4
5
== 5. Ограничение параллелизма: 20 «запросов» по 100 мс, не больше 4 одновременно ==
  Parallel.ForEachAsync: 510 мс, максимум одновременно 4
  SemaphoreSlim        : 505 мс, максимум одновременно 4
  без MaxDegreeOfParallelism: максимум одновременно 2 (ядер: 2)
  первое исключение остановило цикл: «упал элемент 5», обработано элементов: 11 из 100
1
2
3
4
5
== 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?

10.6. SemaphoreSlim как асинхронная блокировка

lock и await несовместимы: нельзя держать lock через await (компилятор запрещает: ошибка CS1996). Вместо него — SemaphoreSlim(1, 1):

1
2
3
4
5
6
7
8
private readonly SemaphoreSlim _lock = new(1, 1);

async Task UpdateAsync()
{
    await _lock.WaitAsync();
    try { await DoAsync(); }
    finally { _lock.Release(); }       // иначе блокировка останется занятой навсегда
}

Внутри:

CoreLib 10.0.12: SemaphoreSlim.WaitAsync, Release и TaskNode (сокращено)
private Task<bool> WaitAsyncCore(long millisecondsTimeout, CancellationToken cancellationToken)
{
    ...
    lock (m_lockObjAndDisposed)
    {
        if (m_currentCount > 0)
        {
            m_currentCount--;
            ...
            return Task.FromResult(result: true);           // синхронный путь: свободно
        }
        if (millisecondsTimeout == 0L)
        {
            return Task.FromResult(result: false);
        }
        TaskNode taskNode = CreateAndAddAsyncWaiter();      // в конец связного списка
        return (millisecondsTimeout == -1 && !cancellationToken.CanBeCanceled) ? taskNode : WaitUntilCountOrTimeoutAsync(taskNode, millisecondsTimeout, cancellationToken);
    }
}

public int Release(int releaseCount)
{
    ...
    lock (m_lockObjAndDisposed)
    {
        ...
        if (m_maxCount - currentCount < releaseCount)
        {
            throw new SemaphoreFullException();
        }
        ...
        if (m_asyncHead != null)
        {
            ...
            while (num3 > 0 && m_asyncHead != null)
            {
                currentCount--;
                num3--;
                TaskNode asyncHead = m_asyncHead;
                RemoveAsyncWaiter(asyncHead);
                asyncHead.TrySetResult(result: true);       // разбудить ПЕРВОГО ожидающего
            }
        }
        ...
    }
}

private sealed class TaskNode : Task<bool>
{
    internal TaskNode Prev;
    internal TaskNode Next;

    internal TaskNode()
        : 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, пока вы держите чужие блокировки.
final/Program.cs
Console.WriteLine("== 6. SemaphoreSlim как асинхронная блокировка ==");
var mutex = new SemaphoreSlim(1, 1);
await mutex.WaitAsync();
Task<bool> second = mutex.WaitAsync(TimeSpan.FromMilliseconds(200));
Console.WriteLine($"  повторный вход из того же потока выполнения: получил блокировку = {await second} (реентерабельности нет)");
mutex.Release();
try { mutex.Release(); } catch (SemaphoreFullException) { Console.WriteLine("  лишний Release: SemaphoreFullException"); }
var queue = new SemaphoreSlim(1, 1);
await queue.WaitAsync();                                             // блокировка занята
var order = new List<int>();
Task[] waiters = Enumerable.Range(1, 5).Select(async n =>
{
    await queue.WaitAsync();                                         // вызов WaitAsync происходит по порядку: n = 1…5
    order.Add(n);
    queue.Release();
}).ToArray();
queue.Release();
await Task.WhenAll(waiters);
Console.WriteLine($"  порядок получения блокировки пятью ожидающими: {string.Join(", ", order)}");
var rc = new SemaphoreSlim(0, 1);
int releaseThread = 0, continuationThread = 0;
Task awaiter = Task.Run(async () => { await rc.WaitAsync(); continuationThread = Environment.CurrentManagedThreadId; });
await Task.Delay(100);
await Task.Run(() => { releaseThread = Environment.CurrentManagedThreadId; rc.Release(); Thread.Sleep(50); });
await awaiter;
Console.WriteLine($"  Release из потока {releaseThread}, продолжение ожидавшего на потоке {continuationThread}: другой поток = {releaseThread != continuationThread}");
1
2
3
4
5
== 6. SemaphoreSlim как асинхронная блокировка ==
  повторный вход из того же потока выполнения: получил блокировку = False (реентерабельности нет)
  лишний Release: SemaphoreFullException
  порядок получения блокировки пятью ожидающими: 1, 2, 3, 4, 5
  Release из потока 12, продолжение ожидавшего на потоке 11: другой поток = True
  • Строка 2 вывода: повторный вход из того же логического потока выполнения (мы уже держим блокировку) не удался: WaitAsync с таймаутом вернул false. Без таймаута это был бы вечный дедлок. Реентерабельности нет, потому что у семафора нет понятия «владелец».
  • Строка 3: лишний Release — SemaphoreFullException (строки 27–29 листинга).
  • Строка 4: пять ожидающих получили блокировку в порядке постановки (FIFO).
  • Строка 5: продолжение ожидавшего выполнилось на другом потоке, чем Release (RunContinuationsAsynchronously).

Правила: Release всегда в finally; не использовать для рекурсивных блокировок; не забывать, что блокировка работает внутри процесса (между процессами и машинами не защитит).

10.7. Периодические задачи: PeriodicTimer

Цикл while (true) { await Task.Delay(period); Work(); } накапливает дрейф: следующий период отсчитывается после работы. PeriodicTimer (.NET 6) отсчитывает периоды от своего старта:

1
2
3
4
5
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(ct))
{
    await DoTickAsync(ct);
}

Внутри:

CoreLib 10.0.12: PeriodicTimer (сокращено)
public PeriodicTimer(TimeSpan period)
{
    ...
    _state = new State();
    _timer = new TimerQueueTimer((object s) =>
    {
        ((State)s).Signal();
    }, _state, milliseconds, milliseconds, flowExecutionContext: false);
}

// State : IValueTaskSource<bool>
public ValueTask<bool> WaitForNextTickAsync(PeriodicTimer owner, CancellationToken cancellationToken)
{
    lock (this)
    {
        if (_activeWait)
        {
            ThrowHelper.ThrowInvalidOperationException();
        }
        ...
        if (_signaled)
        {
            if (!_stopped)
            {
                _signaled = false;
            }
            return new ValueTask<bool>(!_stopped);
        }
        _activeWait = true;
        ...
        return new ValueTask<bool>(this, _mrvtsc.Version);
    }
}

public void Signal(bool stopping = false, CancellationToken cancellationToken = default)
{
    bool flag = 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).
final/Program.cs
Console.WriteLine();
Console.WriteLine("== 7. PeriodicTimer против цикла с Delay: работа 40 мс, период 100 мс, 10 итераций ==");
sw.Restart();
var stamps = new List<long>();
using (var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)))
{
    for (int i = 0; i < 10 && await timer.WaitForNextTickAsync(); i++)
    {
        stamps.Add(sw.ElapsedMilliseconds);
        await Task.Delay(40);
    }
}
Console.WriteLine($"  PeriodicTimer: начало итераций (мс) {string.Join(" ", stamps.Select(s => s / 10 * 10))}, всего {sw.ElapsedMilliseconds} мс");
sw.Restart();
stamps.Clear();
for (int i = 0; i < 10; i++)
{
    await Task.Delay(100);
    stamps.Add(sw.ElapsedMilliseconds);
    await Task.Delay(40);
}
Console.WriteLine($"  цикл с Delay : начало итераций (мс) {string.Join(" ", stamps.Select(s => s / 10 * 10))}, всего {sw.ElapsedMilliseconds} мс");
using (var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)))
{
    await Task.Delay(450);                                           // «долгая работа»: за это время прошло 4 тика
    int immediate = 0;
    for (int i = 0; i < 4; i++)
    {
        ValueTask<bool> tick = timer.WaitForNextTickAsync();
        if (tick.IsCompleted) immediate++;
        await tick;
    }
    Console.WriteLine($"  за 450 мс прошло 4 тика, мгновенно завершились ожидания: {immediate} (пропущенные не копятся)");
}
using (var timer = new PeriodicTimer(TimeSpan.FromSeconds(10)))
1
2
3
4
5
== 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
1
2
3
4
5
== 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 тика, а мгновенно завершилось одно ожидание: остальные три пропущены, не накопились.
  • Строка 5: два параллельных ожидания запрещены.

10.8. Ленивый асинхронный инициализатор

Lazy<Task<T>> выглядит естественным решением, но кеширует и неудачу:

final/Program.cs
    try { await timer.WaitForNextTickAsync(); }
    catch (InvalidOperationException) { Console.WriteLine("  два одновременных WaitForNextTickAsync: InvalidOperationException"); }
}

Console.WriteLine();
Console.WriteLine("== 8. Ленивая асинхронная инициализация ==");
int attempts = 0;
var lazy = new Lazy<Task<int>>(async () => { attempts++; await Task.Delay(10); if (attempts == 1) throw new InvalidOperationException("первая попытка"); return 42; });
for (int i = 0; i < 2; i++)
{
    try { Console.WriteLine($"  Lazy<Task>, вызов {i + 1}: {await lazy.Value}"); }
    catch (InvalidOperationException e) { Console.WriteLine($"  Lazy<Task>, вызов {i + 1}: {e.Message} (попыток инициализации: {attempts})"); }
}
attempts = 0;
var asyncLazy = new AsyncLazy<int>(async () => { attempts++; await Task.Delay(10); if (attempts == 1) throw new InvalidOperationException("первая попытка"); return 42; });
for (int i = 0; i < 3; i++)
1
2
3
4
5
6
== 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.

10.9. Итоги

  • 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).
  • SemaphoreSlim(1, 1) — асинхронная блокировка: FIFO, без реентерабельности, продолжения асинхронные (RunContinuationsAsynchronously).
  • PeriodicTimer не дрейфует и не копит пропущенные тики, одно ожидание за раз. Lazy<Task<T>> кеширует ошибку навсегда: нужна своя ленивая инициализация с повтором.

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

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

start/Program.cs
// Глава 10, заготовка. Task.Run против StartNew и ограничение параллелизма.
// PREDICT: через сколько мс завершится каждый await? В какой планировщик попадёт задача внутри ExclusiveScheduler?
// TODO 1 (§10.1): исправьте вариант StartNew двумя способами, чтобы await ждал 500 мс.
// TODO 2 (§10.5): запустите 20 «запросов» по 100 мс так, чтобы одновременно шло не больше 4 (SemaphoreSlim),
//                 и убедитесь по счётчику Max в ConcurrencyGauge.
using System.Diagnostics;

var sw = Stopwatch.StartNew();
await Task.Run(async () => await Task.Delay(500));
Console.WriteLine($"Task.Run       : {sw.ElapsedMilliseconds} мс");                // PREDICT:

sw.Restart();
var outer = Task.Factory.StartNew(async () => await Task.Delay(500));              // какой тип у outer?  TODO 1
await outer;
Console.WriteLine($"StartNew       : {sw.ElapsedMilliseconds} мс");                // PREDICT:

TaskScheduler exclusive = new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler;
await Task.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);

var gauge = new ConcurrencyGauge();
sw.Restart();
await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => gauge.CallAsync(default)));   // TODO 2: сейчас ограничения нет
Console.WriteLine($"20 запросов: {sw.ElapsedMilliseconds} мс, максимум одновременно {gauge.Max}");

sealed class ConcurrencyGauge
{
    private int _current;
    public int Max;

    public async Task CallAsync(CancellationToken ct)
    {
        int now = Interlocked.Increment(ref _current);
        int seen;
        while (now > (seen = Volatile.Read(ref Max)) && Interlocked.CompareExchange(ref Max, now, seen) != seen) { }
        await Task.Delay(100, ct);                 // имитация HTTP-вызова
        Interlocked.Decrement(ref _current);
    }
}
final/Program.cs
// Глава 10, итог. Task API:
//   1) Task.Run против Task.Factory.StartNew: тип, планировщик, флаги, LongRunning (§10.1);
//   2) ContinueWith против await: контекст, исключения, тип (§10.2);
//   3) TaskCompletionSource: TrySet*, зависшая задача, отписка (§10.3);
//   4) комбинаторы: WhenAny, WhenEach против цикла WhenAny, Delay(0) (§10.4);
//   5) ограничение параллелизма: Parallel.ForEachAsync и SemaphoreSlim (§10.5);
//   6) SemaphoreSlim как асинхронная блокировка (§10.6);
//   7) PeriodicTimer против цикла с Delay (§10.7);
//   8) ленивая асинхронная инициализация (§10.8).
using System.Diagnostics;
using System.Runtime.CompilerServices;

Console.WriteLine("== 1. Task.Run против StartNew ==");
var sw = Stopwatch.StartNew();
Task<Task> outer = Task.Factory.StartNew(async () => await Task.Delay(300));
await outer;
Console.WriteLine($"  await StartNew(async …)          : {sw.ElapsedMilliseconds} мс (дождались только запуска, Task<Task>)");
sw.Restart();
await Task.Factory.StartNew(async () => await Task.Delay(300)).Unwrap();
Console.WriteLine($"  await StartNew(async …).Unwrap() : {sw.ElapsedMilliseconds} мс");
sw.Restart();
await Task.Run(async () => await Task.Delay(300));
Console.WriteLine($"  await Task.Run(async …)          : {sw.ElapsedMilliseconds} мс (разворачивает сам)");

TaskScheduler exclusive = new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler;
await Task.Factory.StartNew(() =>
{
    Console.WriteLine($"  внутри задачи на {exclusive.GetType().Name}:");
    Console.WriteLine($"    StartNew(…) пошла в             : {Task.Factory.StartNew(() => TaskScheduler.Current.GetType().Name).Result}");
    Console.WriteLine($"    Task.Run(…) пошла в             : {Task.Run(() => TaskScheduler.Current.GetType().Name).Result}");
    Console.WriteLine($"    StartNew(…, HideScheduler) в    : {Task.Factory.StartNew(() => TaskScheduler.Current.GetType().Name, CancellationToken.None, TaskCreationOptions.HideScheduler, TaskScheduler.Current).Result}");
}, CancellationToken.None, TaskCreationOptions.None, exclusive);

Task viaStart = Task.Factory.StartNew(() => { });
Task viaRun = Task.Run(() => { });
await Task.WhenAll(viaStart, viaRun);
Console.WriteLine($"  флаги StartNew: 0x{Flags(viaStart):X8}, Task.Run: 0x{Flags(viaRun):X8}");
Console.WriteLine($"  родитель StartNew + дочерняя AttachedToParent ждёт дочернюю: {await ParentWaitsAsync(useRun: false)}");
Console.WriteLine($"  родитель Task.Run   + дочерняя AttachedToParent ждёт дочернюю: {await ParentWaitsAsync(useRun: true)}");
bool longPool = false, longDedicated = false;
await Task.Run(() => longPool = Thread.CurrentThread.IsThreadPoolThread);
await Task.Factory.StartNew(() => longDedicated = Thread.CurrentThread.IsThreadPoolThread, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default);
Console.WriteLine($"  поток пула: Task.Run = {longPool}, LongRunning = {longDedicated}");

Console.WriteLine();
Console.WriteLine("== 2. ContinueWith против await на потоке UI ==");
var ui = new UiThread();
await ui.RunAsync(async () =>
{
    Console.WriteLine($"  старт                       : {Here.Now}");
    await Task.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>(new InvalidOperationException("упала"));
Task<string> ignoring = broken.ContinueWith(_ => "продолжение не смотрело на исключение");
string ignoringResult = await ignoring;
Console.WriteLine($"  ContinueWith без проверки ошибки: Status={ignoring.Status}, результат «{ignoringResult}», ошибка исходной задачи никому не видна");
try { await broken; } catch (InvalidOperationException) { Console.WriteLine("  await той же задачи бросает InvalidOperationException"); }
var asyncCw = Task.CompletedTask.ContinueWith(async _ => await Task.Delay(300));
sw.Restart();
await asyncCw;
Console.WriteLine($"  ContinueWith(async …) это Task<Task>: {asyncCw is Task<Task>}; await внешней задачи занял {sw.ElapsedMilliseconds} мс (внутренняя ещё работает)");

Console.WriteLine();
Console.WriteLine("== 3. TaskCompletionSource ==");
var tcs = new TaskCompletionSource<int>();
Console.WriteLine($"  TrySetResult(1): {tcs.TrySetResult(1)}, второй раз: {tcs.TrySetResult(2)}");
try { tcs.SetResult(3); } catch (InvalidOperationException e) { Console.WriteLine($"  SetResult после завершения: {e.GetType().Name}"); }
var source = new LineSource();
Task<string> good = source.ReadLineAsync();
Console.WriteLine($"  обработчиков после подписки: {source.HandlerCount}");
source.Raise("привет");
Console.WriteLine($"  результат: «{await good}», обработчиков после события: {source.HandlerCount}");
using (var cts = new CancellationTokenSource())
{
    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 { await hanging.WaitAsync(TimeSpan.FromMilliseconds(300)); }
catch (TimeoutException) { Console.WriteLine($"  задача не завершилась никогда: Status={hanging.Status}"); }

Console.WriteLine();
Console.WriteLine("== 4. Комбинаторы ==");
Task<int> fast = Job(100), slowJob = Job(600);
Task<int> boom = Task.Run<int>(async () => { await Task.Delay(150); throw new InvalidOperationException("проигравшая упала"); });
int unobserved = 0;
TaskScheduler.UnobservedTaskException += (_, e) => { unobserved++; e.SetObserved(); };
Task<int> winner = await Task.WhenAny(fast, boom);
Console.WriteLine($"  WhenAny вернул задачу, которая выиграла: Result={winner.Result}, остальные: boom={boom.Status}, slow={slowJob.Status}");
await Task.Delay(200);
Console.WriteLine($"  после ожидания: boom={boom.Status}, slow={slowJob.Status}");
boom = null!;
for (int i = 0; i < 3; i++) { GC.Collect(); GC.WaitForPendingFinalizers(); }
Console.WriteLine($"  UnobservedTaskException сработал {unobserved} раз(а)");
Task.WhenAll().Wait();
Console.WriteLine($"  Task.WhenAll() без аргументов завершён сразу: {Task.WhenAll().IsCompletedSuccessfully}");
sw.Restart();
Task delayZero = Task.Delay(0);
Console.WriteLine($"  Task.Delay(0) возвращает завершённую задачу: {delayZero.IsCompletedSuccessfully} (ReferenceEquals с CompletedTask: {ReferenceEquals(delayZero, Task.CompletedTask)})");
foreach (int n in new[] { 1000, 4000, 16000 })
{
    var many = Enumerable.Range(0, n).Select(i => Task.Delay(1 + i % 20)).ToArray();
    sw.Restart();
    int done = 0;
    await foreach (Task _ in Task.WhenEach(many)) done++;
    long each = sw.ElapsedMilliseconds;

    many = Enumerable.Range(0, n).Select(i => Task.Delay(1 + i % 20)).ToArray();
    sw.Restart();
    var pending = many.ToList();
    while (pending.Count > 0)
    {
        Task finished = await Task.WhenAny(pending);
        pending.Remove(finished);
    }
    long loop = sw.ElapsedMilliseconds;
    Console.WriteLine($"  {n,6} задач: WhenEach {each,5} мс, цикл WhenAny + Remove {loop,6} мс");
}

Console.WriteLine();
Console.WriteLine("== 5. Ограничение параллелизма: 20 «запросов» по 100 мс, не больше 4 одновременно ==");
var gauge = new ConcurrencyGauge();
sw.Restart();
await Parallel.ForEachAsync(Enumerable.Range(0, 20), new ParallelOptions { MaxDegreeOfParallelism = 4 },
    async (i, ct) => await gauge.CallAsync(ct));
Console.WriteLine($"  Parallel.ForEachAsync: {sw.ElapsedMilliseconds} мс, максимум одновременно {gauge.Max}");
gauge = new ConcurrencyGauge();
var gate = new SemaphoreSlim(4);
sw.Restart();
await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ =>
{
    await gate.WaitAsync();
    try { await gauge.CallAsync(default); }
    finally { gate.Release(); }        // обязательно в finally
}));
Console.WriteLine($"  SemaphoreSlim        : {sw.ElapsedMilliseconds} мс, максимум одновременно {gauge.Max}");
gauge = new ConcurrencyGauge();
await Parallel.ForEachAsync(Enumerable.Range(0, 20), async (i, ct) => await gauge.CallAsync(ct));
Console.WriteLine($"  без MaxDegreeOfParallelism: максимум одновременно {gauge.Max} (ядер: {Environment.ProcessorCount})");
gauge = new ConcurrencyGauge();
try
{
    await Parallel.ForEachAsync(Enumerable.Range(0, 100), new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (i, ct) =>
    {
        await gauge.CallAsync(ct);
        if (i == 5) throw new InvalidOperationException($"упал элемент {i}");
    });
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"  первое исключение остановило цикл: «{e.Message}», обработано элементов: {gauge.Started} из 100");
}

Console.WriteLine();
Console.WriteLine("== 6. SemaphoreSlim как асинхронная блокировка ==");
var mutex = new SemaphoreSlim(1, 1);
await mutex.WaitAsync();
Task<bool> second = mutex.WaitAsync(TimeSpan.FromMilliseconds(200));
Console.WriteLine($"  повторный вход из того же потока выполнения: получил блокировку = {await second} (реентерабельности нет)");
mutex.Release();
try { mutex.Release(); } catch (SemaphoreFullException) { Console.WriteLine("  лишний Release: SemaphoreFullException"); }
var queue = new SemaphoreSlim(1, 1);
await queue.WaitAsync();                                             // блокировка занята
var order = new List<int>();
Task[] waiters = Enumerable.Range(1, 5).Select(async n =>
{
    await queue.WaitAsync();                                         // вызов WaitAsync происходит по порядку: n = 1…5
    order.Add(n);
    queue.Release();
}).ToArray();
queue.Release();
await Task.WhenAll(waiters);
Console.WriteLine($"  порядок получения блокировки пятью ожидающими: {string.Join(", ", order)}");
var rc = new SemaphoreSlim(0, 1);
int releaseThread = 0, continuationThread = 0;
Task awaiter = Task.Run(async () => { await rc.WaitAsync(); continuationThread = Environment.CurrentManagedThreadId; });
await Task.Delay(100);
await Task.Run(() => { releaseThread = Environment.CurrentManagedThreadId; rc.Release(); Thread.Sleep(50); });
await awaiter;
Console.WriteLine($"  Release из потока {releaseThread}, продолжение ожидавшего на потоке {continuationThread}: другой поток = {releaseThread != continuationThread}");

Console.WriteLine();
Console.WriteLine("== 7. PeriodicTimer против цикла с Delay: работа 40 мс, период 100 мс, 10 итераций ==");
sw.Restart();
var stamps = new List<long>();
using (var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)))
{
    for (int i = 0; i < 10 && await timer.WaitForNextTickAsync(); i++)
    {
        stamps.Add(sw.ElapsedMilliseconds);
        await Task.Delay(40);
    }
}
Console.WriteLine($"  PeriodicTimer: начало итераций (мс) {string.Join(" ", stamps.Select(s => s / 10 * 10))}, всего {sw.ElapsedMilliseconds} мс");
sw.Restart();
stamps.Clear();
for (int i = 0; i < 10; i++)
{
    await Task.Delay(100);
    stamps.Add(sw.ElapsedMilliseconds);
    await Task.Delay(40);
}
Console.WriteLine($"  цикл с Delay : начало итераций (мс) {string.Join(" ", stamps.Select(s => s / 10 * 10))}, всего {sw.ElapsedMilliseconds} мс");
using (var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)))
{
    await Task.Delay(450);                                           // «долгая работа»: за это время прошло 4 тика
    int immediate = 0;
    for (int i = 0; i < 4; i++)
    {
        ValueTask<bool> tick = timer.WaitForNextTickAsync();
        if (tick.IsCompleted) immediate++;
        await tick;
    }
    Console.WriteLine($"  за 450 мс прошло 4 тика, мгновенно завершились ожидания: {immediate} (пропущенные не копятся)");
}
using (var timer = new PeriodicTimer(TimeSpan.FromSeconds(10)))
{
    ValueTask<bool> first = timer.WaitForNextTickAsync();
    try { await timer.WaitForNextTickAsync(); }
    catch (InvalidOperationException) { Console.WriteLine("  два одновременных WaitForNextTickAsync: InvalidOperationException"); }
}

Console.WriteLine();
Console.WriteLine("== 8. Ленивая асинхронная инициализация ==");
int attempts = 0;
var lazy = new Lazy<Task<int>>(async () => { attempts++; await Task.Delay(10); if (attempts == 1) throw new InvalidOperationException("первая попытка"); return 42; });
for (int i = 0; i < 2; i++)
{
    try { Console.WriteLine($"  Lazy<Task>, вызов {i + 1}: {await lazy.Value}"); }
    catch (InvalidOperationException e) { Console.WriteLine($"  Lazy<Task>, вызов {i + 1}: {e.Message} (попыток инициализации: {attempts})"); }
}
attempts = 0;
var asyncLazy = new AsyncLazy<int>(async () => { attempts++; await Task.Delay(10); if (attempts == 1) throw new InvalidOperationException("первая попытка"); return 42; });
for (int i = 0; i < 3; i++)
{
    try { Console.WriteLine($"  AsyncLazy, вызов {i + 1}: {await asyncLazy.GetAsync()} (попыток: {attempts})"); }
    catch (InvalidOperationException e) { Console.WriteLine($"  AsyncLazy, вызов {i + 1}: {e.Message} (попыток: {attempts})"); }
}

static async Task<int> Job(int ms) { await Task.Delay(ms); return ms; }

static async Task<bool> ParentWaitsAsync(bool useRun)
{
    bool childDone = false;
    Action body = () => { Task.Factory.StartNew(() => { Thread.Sleep(150); childDone = true; }, TaskCreationOptions.AttachedToParent); };
    Task parent = useRun ? Task.Run(body) : Task.Factory.StartNew(body);
    await parent;
    return childDone;
}

[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "m_stateFlags")]
static extern ref int Flags(Task task);
final/Gauge.cs
// Считает, сколько «вызовов» выполняется одновременно, и запоминает максимум.
sealed class ConcurrencyGauge
{
    private int _current;
    public int Max;
    public int Started;

    public async Task CallAsync(CancellationToken ct)
    {
        Interlocked.Increment(ref Started);
        int now = Interlocked.Increment(ref _current);
        int seen;
        while (now > (seen = Volatile.Read(ref Max)) && Interlocked.CompareExchange(ref Max, now, seen) != seen) { }
        await Task.Delay(100, ct);                 // имитация HTTP-вызова
        Interlocked.Decrement(ref _current);
    }
}
final/Events.cs
// Источник событий для §10.3: мост «событие → Task» через TaskCompletionSource.
sealed class LineSource
{
    public event Action<string>? LineRead;
    public int HandlerCount => LineRead?.GetInvocationList().Length ?? 0;
    public void Raise(string line) => LineRead?.Invoke(line);

    // Правильно: обработчик отписывается и при успехе, и при отмене; исключение в обработчике не оставляет задачу висеть.
    public Task<string> ReadLineAsync(CancellationToken ct = default)
    {
        var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
        CancellationTokenRegistration registration = default;
        void Handler(string line)
        {
            LineRead -= Handler;
            registration.Dispose();
            tcs.TrySetResult(line);
        }
        LineRead += Handler;
        registration = ct.Register(() =>
        {
            LineRead -= Handler;
            tcs.TrySetCanceled(ct);
        });
        return tcs.Task;
    }

    // Ошибка: между подпиской и SetResult есть код, который может бросить, и задача не завершится никогда.
    public Task<string> ReadLineBrokenAsync()
    {
        var tcs = new TaskCompletionSource<string>();
        LineRead += line =>
        {
            int length = Parse(line);                // бросает на плохой строке
            tcs.SetResult($"{line} ({length})");
        };
        return tcs.Task;

        static int Parse(string line) => int.Parse(line);
    }
}
final/AsyncLazy.cs
// Ленивая асинхронная инициализация с повтором после ошибки (§10.8).
sealed class AsyncLazy<T>
{
    private readonly Func<Task<T>> _factory;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private T? _value;
    private bool _hasValue;

    public AsyncLazy(Func<Task<T>> factory) => _factory = factory;

    public async Task<T> GetAsync()
    {
        if (_hasValue)
            return _value!;
        await _gate.WaitAsync();
        try
        {
            if (!_hasValue)
            {
                _value = await _factory();               // при исключении флаг не взводится: следующий вызов попробует снова
                _hasValue = true;
            }
            return _value!;
        }
        finally
        {
            _gate.Release();
        }
    }
}
final/UiThread.cs
using System.Collections.Concurrent;

// Однопоточный SynchronizationContext — модель UI-потока (WinForms, WPF, MAUI).
// Свой поток "UI" и очередь: Post кладёт делегат в очередь, поток UI выполняет их по одному.
sealed class UiThread : SynchronizationContext
{
    private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
    private readonly Thread _thread;

    public UiThread()
    {
        _thread = new Thread(Loop) { Name = "UI", IsBackground = true };
        _thread.Start();
    }

    public override void Post(SendOrPostCallback d, object? state)
    {
        _queue.Add((d, state));
    }

    // Запустить async-обработчик на потоке UI и дождаться его завершения.
    public Task RunAsync(Func<Task> handler)
    {
        var started = new TaskCompletionSource<Task>(TaskCreationOptions.RunContinuationsAsynchronously);
        _queue.Add((_ => started.SetResult(handler()), null));
        return started.Task.Unwrap();
    }

    private void Loop()
    {
        SetSynchronizationContext(this);
        foreach (var (callback, state) in _queue.GetConsumingEnumerable())
            callback(state);
    }
}

static class Here
{
    // Где мы сейчас: номер и вид потока, плюс контекст или планировщик, если они не «по умолчанию».
    public static string Now
    {
        get
        {
            var thread = Thread.CurrentThread;
            string kind = thread.IsThreadPoolThread ? "пул" : thread.Name ?? "основной";
            string where = $"поток {Environment.CurrentManagedThreadId,2} ({kind})";
            if (SynchronizationContext.Current is { } ctx)
                where += $", SynchronizationContext={ctx.GetType().Name}";
            if (TaskScheduler.Current != TaskScheduler.Default)
                where += $", TaskScheduler={TaskScheduler.Current.GetType().Name}";
            return where;
        }
    }
}