1. Главная идея: async — не про потоки¶
О главе
Цель: разделить «Task как работу» и «Task как обещание»; увидеть, на каких потоках продолжается метод и откуда эти потоки берутся.
Лабораторная: start/ — эксперимент §1.3 с местами для предсказаний. final/ — то же плюс типы задач изнутри (§1.5) и 10 000 ожиданий (§1.6). Код — в конце главы.
Статус: ✅ проверено на Windows 11, 8 ядер, рантайм 10.0.12. Все листинги «под капотом» сняты с этой же лабораторной (tools/disasm.ps1 chapters\01-mental-model\start -Type Program -Method '*DemoAsync*:MoveNext') и из CoreLib 10.0.12.
1.1. Что такое Task¶
Task<T> — объект-обещание (promise): «результат появится позже, а пока вот ссылка, по которой можно подождать, подписаться на завершение или забрать исключение». Это обычный объект в куче с состоянием (WaitingForActivation, RanToCompletion, Faulted, Canceled и др.), списком продолжений и результатом.
Две разные идеи, которые путают:
| Что это | Пример | |
|---|---|---|
| Task как «работа на потоке» | вычисление, запущенное на потоке пула | Task.Run(() => Compute()) |
| Task как «обещание» | ожидание события без потока | Task.Delay, сетевой ввод-вывод, TaskCompletionSource |
async/await построен на втором: пока вы ждёте, поток не занят. Он возвращается в пул и обслуживает другую работу. Когда операция завершится, продолжение метода выполнит какой-то поток пула.
1.2. Что делает async, а что await¶
async— разрешение для компилятора: «этот метод превращай в машину состояний и разреши в нёмawait». Сам по себеasyncне создаёт потоков и не делает метод асинхронным: безawaitметод выполнится синхронно (компилятор предупредит CS1998).await expr— «еслиexprуже завершён, иди дальше прямо сейчас; если нет, подпишись на его завершение, верни управление вызывающему и продолжи отсюда позже».
Как это выглядит в скомпилированном коде нашего эксперимента — в §1.4. Подробно машину состояний разберём в главе 2.
1.3. Первый эксперимент: потоки до и после await¶
Код лабораторной start/Program.cs. Номера — это номера строк в файле:
Что такое Task.Yield() (и при чём тут yield return)
В строке 20 стоит await Task.Yield(). Это метод, а не ключевое слово. Английское yield значит «уступить»: «прервись, дай поработать другим, продолжу позже».
Устроен он так (YieldAwaitable.YieldAwaiter, CoreLib 10.0.12, фрагменты):
- Строка 1:
IsCompletedвсегдаfalse. Поэтомуawait Task.Yield()не может пройти «без остановки», какawait Task.CompletedTask: метод всегда приостанавливается и возвращает вызывающему незавершённыйTask. - Строки 3–18: «подписка» здесь не ждёт никакого события. Она просто ставит бокс машины (шаг 2 ниже) в очередь: в
SynchronizationContext, если он есть (строки 5–10), иначе в пул потоков (строки 12–16), иначе в нестандартный планировщик (строка 17). Когда до бокса дойдёт очередь, вызовется егоMoveNext. - Смену потока
Yieldне гарантирует: гарантируется только «продолжение пойдёт через очередь, а не синхронно». Ниже это видно в прогонах.
Зачем он нужен: уступить очередь (например, в UI — чтобы успели обработаться другие сообщения), не выполнять долгую синхронную часть метода внутри вызывающего, или (как в этой лабораторной и в главе 2) получить самый короткий await, который гарантированно приостанавливает метод, без таймера и других задач.
Не путайте с yield return. Это ключевые слова итераторов (IEnumerable<T>), и они тоже превращаются в машину состояний, но возобновляет её не уведомление, а сам вызывающий:
yield return |
await |
|
|---|---|---|
| Что значит пауза | отдать значение вызывающему | ждать результат |
| Кто возобновляет | вызывающий следующим MoveNext(), синхронно |
завершение того, чего ждали |
| Подробнее | глава 11 (там они встречаются вместе: IAsyncEnumerable<T>) |
вся книга |
А Thread.Yield() — то же «уступи» на уровне ОС: отдать остаток кванта времени другому потоку.
Предскажите
Какой номер потока напечатают строки 7, 13, 15, 18, 21 и 9? Впишите в комментарии PREDICT и только потом запускайте:
Факт: вывод на стенде автора (раскройте после предсказания)
Windows 11, 8 ядер. Шесть прогонов подряд:
- Строка 7 исходника → поток 2, а не 1. Номер 1 в .NET 10 на Windows достаётся потоку финализатора: он стартует раньше
Main(проверено: финализатор печатаетid=1, background=True). В первой версии курса на Linux главный поток был 1. Номера потоков — не контракт, не полагайтесь на них. - Строка 13 → поток 2. До первого
awaitметодDemoAsyncвыполняется синхронно, прямо внутри вызова из строки 8. - Строка 15 → поток пула 5. Это продолжение после
await Task.Delay(200)из строки 14. Откуда именно взялся этот поток — §1.4. - Строка 18 → тот же поток 5.
Task.CompletedTaskв строке 17 уже завершён,awaitне останавливается, продолжение идёт синхронно. - Строка 21 → обычно другой поток (7), но не всегда. В одном прогоне из шести здесь был поток 5.
Task.Yield()в строке 20 ставит продолжение в очередь пула, и его забирает первый свободный поток: обычно уже проснувшийся соседний, а иногда сам поток 5, успевший вернуться в пул. В первой версии курса на виртуалке с 2 ядрами всегда оставался тот же поток.Yieldгарантирует только одно: продолжение пойдёт через очередь, а не синхронно. Смену потока он не гарантирует. - Строка 9 → поток 7. Продолжение
await DemoAsync()из строки 8 выполнилось на том потоке, который завершилDemoAsync.
Вывод: после await вы можете оказаться на другом потоке, а await на завершённой задаче идёт синхронно, без смены потока.
1.4. Под капотом: откуда поток после Task.Delay¶
Парадокс эксперимента: «ожидание не занимает поток», но после await Task.Delay(200) (строка 14) код всё-таки продолжился на потоке пула №5 (строка 15). Откуда он взялся и почему именно из пула? Проследим путь этих 200 мс шаг за шагом: по скомпилированному коду этой лабораторной и по исходникам рантайма 10.0.12.
Представьте кухонный таймер. Вы поставили его на 200 мс и ушли заниматься другим. Таймер не стоит у плиты и не держит вас за руку, он просто тикает. Когда он звенит, к плите подходит тот, кто свободен, и продолжает готовить с того места, где вы остановились. В .NET всё устроено так же. Дальше — кто здесь кто.
Действующие лица¶
| Кто | Что это | Сколько их |
|---|---|---|
| Поток Main (2) | вызвал DemoAsync и дошёл до await |
один |
Машина состояний DemoAsync |
то, во что компилятор превратил метод DemoAsync |
по одной на каждый вызов; пока не приостановилась — на стеке |
| Бокс машины | объект в куче: копия машины + это же Task, который вернул DemoAsync (разбор — ниже) |
создаётся при первой приостановке вызова |
DelayPromise |
задача, которую вернул Task.Delay(200); наследник Task |
по одной на каждый Delay |
Запись таймера (TimerQueueTimer) |
«через 200 мс вызвать колбэк для этой задачи» | по одной на каждый Delay |
Поток .NET Timer |
будильник: спит до ближайшего срока | один на весь процесс |
| Поток пула (5) | исполнитель: выполняет колбэки и продолжения | сколько понадобится |
Во что компилятор превратил DemoAsync¶
Чтобы говорить о шагах предметно, сначала посмотрим, во что скомпилирован метод DemoAsync (строки 11–22). Компилятор оставил на его месте маленькую «заглушку», а всё тело перенёс в метод MoveNext сгенерированной структуры. Вот эта структура: декомпиляция ILSpy без восстановления async, Release.
Комментарии добавлены мной, а блоки с метками IL_009e и IL_0125 переставлены в порядке исходника: ILSpy выводит их в обратном порядке, логика та же. Явные реализации IAsyncStateMachine в конце структуры опущены.
Как строки исходника разложились по MoveNext:
| Исходник | MoveNext |
Что там |
|---|---|---|
строка 13, before await |
строка 18 | выполняется при первом входе (default) |
строка 14, await Task.Delay(200) |
строки 19–27, 28–32, 44 | получить awaiter → не готово? запомнить состояние 0 и уйти → при повторном входе case 0 → GetResult() |
строка 15, after Delay |
строка 45 | код после первого await |
строка 17, await Task.CompletedTask |
строки 46–54, 33–37, 56 | то же устройство, состояние 1 |
строка 18, after Completed |
строка 57 | |
строка 20, await Task.Yield() |
строки 58–66, 38–42, 68 | то же, состояние 2, свой тип awaiter'а в поле <>u__2 |
строка 21, after Yield |
строка 69 | |
| конец метода | строки 77–78 | SetResult() завершает Task, который вернул DemoAsync |
Каждый await превратился в одинаковую конструкцию из двух половин: «подписаться и уйти» и «вернуться по номеру состояния».
Факт: для await Task.CompletedTask компилятор тоже построил полный путь приостановки
Строки 46–54 и ветка case 1 (строки 33–37) сгенерированы, хотя Task.CompletedTask всегда завершён. Компилятор не знает этого заранее. Решение «идти дальше без остановки» принимается во время выполнения в строке 47: IsCompleted == true, и return не случается. Поэтому в эксперименте строка 18 исходника напечатала тот же поток.
Что такое «бокс машины»¶
Машина состояний <<<Main>$>g__DemoAsync|0_0>d — это структура (struct, значимый тип). Пока метод не приостанавливался, она живёт на стеке: в локальной переменной заглушки, которую компилятор оставил на месте DemoAsync. Вот эта заглушка из той же сборки:
- Строка 4: машина — локальная переменная, то есть лежит на стеке.
- Строка 7:
Startсинхронно вызывает еёMoveNext(строки 8–79 листинга выше). - Строка 8: вызывающий получает
builder.Task. Что это за объект, станет ясно ниже.
Но после await на незавершённой задаче MoveNext выходит, заглушка возвращает управление, и её стек исчезает вместе с машиной. Значит, до выхода машину нужно перенести туда, где она переживёт метод, — в кучу. Объект в куче, в который копируется машина, и называется боксом машины состояний (state machine box). Название — по аналогии с упаковкой (boxing) значимого типа в объект. Но это не object o = machine, а отдельный generic-класс рантайма.
В самом MoveNext этого переноса не видно, и так задумано. Он спрятан в одной строке — строке 24 листинга MoveNext: <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this). Второй аргумент ref this — ссылка на саму машину, лежащую на стеке заглушки. Builder создаёт бокс, копирует в него машину по этой ссылке, записывает бокс в своё поле и подписывает его на завершение Delay. Поэтому компилятор перед вызовом сохраняет в машине номер состояния и awaiter (строки 22–23): в бокс уедет копия, и она должна уже содержать всё нужное. Исходный экземпляр на стеке после этого не используется, а return в строке 25 завершает MoveNext и заглушку. Дальше все вызовы MoveNext идут уже на копии в боксе. Посмотрим, как бокс устроен и где именно создаётся.
Что это за класс. В CoreLib это AsyncTaskMethodBuilder<TResult>.AsyncStateMachineBox<TStateMachine>. Вот он, без отладочного свойства DebuggerDisplay и атрибута [MethodImpl]:
- Строка 1: бокс — наследник
Task<TResult>. Это главное. Бокс и есть та задача, которую получает вызывающий. Отдельного объекта «задача» плюс отдельного «контейнера для машины» нет: один объект совмещает обе роли. - Строки 1–2: класс generic по типу машины. Для каждого async-метода получается свой тип бокса:
AsyncStateMachineBox<<<<Main>$>g__DemoAsync|0_0>d>. Поэтому машина хранится в нём как есть, без упаковки вobject, а вызовStateMachine.MoveNext()— прямой, без виртуального вызова через интерфейс. - Строка 6: поле
StateMachine— копия вашей структуры: номер состояния, builder, awaiter'ы, поднятые параметры и локальные переменные. - Строка 10:
Context— захваченныйExecutionContext(AsyncLocalи т. п., глава 7). Хранится в унаследованном отTaskполеm_stateObject, чтобы не заводить новое. - Строки 27–30, 32–60:
MoveNextбокса — то, что вызывается, когда ожидаемая задача завершилась. Он восстанавливает контекст выполнения (строки 44–51) и вызываетMoveNextмашины (строка 42 или 14). - Строки 22–25:
ExecuteFromThreadPool. Бокс умеет быть рабочим элементом пула потоков, поэтому продолжение можно поставить в очередь пула без дополнительного объекта-обёртки. - Строки 52–55 и 62–74: когда метод завершён, бокс очищает себя:
StateMachine = default(строка 68), чтобы не держать ссылки на ваши объекты дольше нужного.
Где бокс создаётся. В AwaitUnsafeOnCompleted (строка 24 MoveNext) builder вызывает GetStateMachineBox. Его мы уже видели вызовом в ассемблере шага 1 (строка 26 листинга asm). Метод целиком:
- Строка 2:
taskField— ссылка на полеm_taskbuilder'а, который сидит внутри машины. Бокс записывается прямо туда. - Строка 5: захватить текущий
ExecutionContext. - Строки 6–13: бокс уже есть (это второй, третий…
awaitтого же вызова). Новый объект не создаётся, обновляется только контекст. Сколько быawaitни было в методе, бокс создаётся не больше одного раза за вызов. - Строки 14–23: редкий путь, когда задачу запросили раньше, чем машина приостановилась (например, отладчиком). Пропустим.
- Строки 25–28: первая приостановка — та самая аллокация.
new AsyncStateMachineBox<TStateMachine>()в куче, и сразу же запись вtaskField = ...: с этого моментаbuilder.Taskвозвращает бокс. - Строка 29: копирование структуры
StateMachine = stateMachineсо стека в бокс. Поэтому компилятор в строках 22–23MoveNextсохраняет номер состояния и awaiter до вызоваAwaitUnsafeOnCompleted: копия должна уже содержать всё нужное для возобновления. После копирования экземпляр на стеке больше не используется. ТекущийMoveNextдоходит доreturn(строка 25), а все следующие вызовы идут уже на копии в боксе (строка 42 листинга бокса). - Строка 30: сохранить контекст в боксе.
А Task из DemoAsync — это Task, а не Task<VoidTaskResult>? Для методов, возвращающих Task, builder AsyncTaskMethodBuilder внутри хранит Task<VoidTaskResult> и передаёт всю работу обобщённому builder'у (поэтому в ассемблере шага 1 вызывается AsyncTaskMethodBuilder1[VoidTaskResult]:GetStateMachineBox`):
- Строка 3: единственное поле builder'а —
m_task. Сам builder — структура размером с одну ссылку. - Строка 5:
builder.Taskиз строки 8 заглушки. Если машина приостанавливалась, вm_taskуже лежит бокс (строка 26GetStateMachineBox), и вызывающий получает его. Если нет, задача создаётся только теперь. - Строка 10:
ref m_taskпередаётся какtaskFieldвGetStateMachineBox. - Строки 15–18: метод завершился, ни разу не приостановившись. Тогда бокс не нужен, и даже новая задача не создаётся: отдаётся общий закешированный завершённый
Task.s_cachedCompleted.
Убедимся вживую. Четвёртая часть final/Program.cs смотрит на задачу, которую вернул async-метод, и через отражение читает поле StateMachine (строка 67):
- Строки 2–6 вывода (строка 50 кода): пока
WaitAsync(300)ждёт,Task, который мы держим в переменнойpending, — это и есть бокс. Внутри него копия машины: состояние0(стоит на первомawait), параметрms = 300(параметры метода тоже становятся полями машины), сохранённый awaiter. - Строки 7–11 вывода (строка 52 кода): задача завершена, состояние
-2, но машина ещё не очищена. Наше продолжение послеawait pendingвыполняется внутриSetResultбокса (шаг 6 §1.4), раньше, чемMoveNextбокса успел дойти доClearStateUponCompletion(строки 52–55 листинга бокса). - Строки 12–16 вывода (строка 54 кода): через 50 мс
MoveNextбокса вернулся и выполнилStateMachine = default. Все поля обнулены, ссылки отпущены. - Строки 17–18 вывода (строки 55–56 кода): метод завершился без паузы, и бокса нет. Это обычный
Task<VoidTaskResult>, причём один и тот же объект при каждом вызове: закешированныйs_cachedCompleted(строка 17 листинга builder'а).
Итого: бокс машины состояний
Бокс — объект класса AsyncTaskMethodBuilder<TResult>.AsyncStateMachineBox<TStateMachine> в куче. Он наследует Task<TResult>, в поле StateMachine хранит копию вашей машины-структуры, а в Context — захваченный ExecutionContext. Создаётся при первой реальной приостановке вызова (не больше одного раза за вызов). С этого момента это и есть Task, который вернул ваш async-метод. Если метод ни разу не приостановился, бокса нет совсем.
Теперь — шаги во времени.
Шаг 1. t = 0, поток Main: Task.Delay создаёт обещание с таймером¶
MoveNext вызван впервые (из заглушки DemoAsync, синхронно в потоке 2), состояние -1, поэтому выполняется ветка default. Строка 18 печатает before await, а строка 19 вызывает Task.Delay(200).
Task.Delay(200) не запускает никакой работы. Он создаёт объект DelayPromise и регистрирует для него запись в очереди таймеров. Вот этот класс из CoreLib целиком:
- Строка 1:
DelayPromise— наследникTask. Это та самая задача, которую получаетawait. - Строки 3 и 5: у задачи есть только статический колбэк и поле с таймером. Ни потока, ни делегата с работой.
- Строки 17–20:
Task.Delay(Timeout.Infinite)(этоuint.MaxValue) вообще не заводит таймер — такая задача ждёт вечно. - Строка 23: главное — запись в очереди таймеров: «через
millisecondsDelayмс вызовиs_timerCallback, передав емуthis». Последний аргументflowExecutionContext: false: таймеру не нужен контекст выполнения вызывающего.
То же самое видно и в машинном коде MoveNext этой лабораторной. JIT встроил вызов Task.Delay прямо в MoveNext:
- Строки 7–13:
new DelayPromise(200, TimeProvider.System)—Task.Delayвстроен целиком. - Строки 16–17:
IsCompletedиз строки 20MoveNext— это одна проверка битов в поле состоянияTask(смещение0x34). - Строки 18–30: строки 22–25
MoveNext: состояние0, сохранить awaiter, упаковать машину, подписаться, выйти. Подробно — в шаге 2.
Потоков на этом шаге не создано. Исключение одно: если это первый таймер в процессе, рантайм один раз заводит поток-будильник .NET Timer (шаг 3).
Задача создана, но не завершена: таймер только начал тикать. Значит,
awaitдолжен подписаться на неё.
Шаг 2. t = 0, поток Main: await подписывается и уходит¶
Строка 20 MoveNext спрашивает IsCompleted и получает false. Дальше строки 22–25:
- строка 22:
<>1__state = 0— запомнить, что остановились на первомawait; - строка 23:
<>u__1 = awaiter2— сохранить awaiter в поле, ведь локальные переменные сейчас пропадут; - строка 24:
AwaitUnsafeOnCompleted(ref awaiter2, ref this). Builder вызываетGetStateMachineBox(строка 26 листинга asm): создаёт бокс в куче, копирует в него машину со стека и записывает бокс в своё полеm_task(строки 25–29 листингаGetStateMachineBox). Затем кладёт бокс в список продолженийDelayPromise: «когда завершишься, вызови мойMoveNext»; - строка 25:
return.MoveNextвыходит, заглушкаDemoAsyncвозвращает в строку 8 исходника незавершённыйTask.
Стек DemoAsync исчез, всё её состояние лежит в боксе: номер состояния и awaiter.
Кто держит бокс, пока мы ждём
Бокс лежит в куче, а ссылка на него осталась только в двух местах: у вызывающего и в списке продолжений задачи Delay. Разберём по порядку.
Что с чем связалось на строке 24. awaiter2 — это TaskAwaiter, структура с единственным полем: ссылкой на задачу Delay(200) (DelayPromise). Это не «awaiter таймера»: запись таймера лежит внутри задачи, а не в awaiter'е. Машина к awaiter'у не привязывается. Builder кладёт бокс в поле m_continuationObject задачи DelayPromise (глава 4): когда она завершится, она вызовет MoveNext этого бокса.
Цепочка от корня. Запись таймера тоже лежит в куче. Её удерживает статическая очередь таймеров рантайма:
flowchart LR
R["TimerQueue.Instances<br/>(статика, корень)"] --> T["TimerQueueTimer<br/>_state"]
T --> D["DelayPromise (Task)<br/>m_continuationObject"]
D --> B["бокс машины<br/>StateMachine: <>1__state = 0,<br/><>u__1, builder"]
B -. "<>u__1 → TaskAwaiter → m_task" .-> D
M["Main: await DemoAsync()<br/>держит Task = тот же бокс"] --> B
- Корень — статическая очередь таймеров. Пока таймер не сработал, запись в ней живёт, а с ней
DelayPromise(через_state) и бокс (черезm_continuationObject). - Колбэк таймера — статический делегат
s_timerCallback. Он получаетDelayPromiseи ничего не знает о вашей машине. Бокс «просыпается» потому, что лежит в списке продолжений завершаемой задачи (шаг 5). - Кольцо «задача → бокс →
<>u__1→ задача» сборщику мусора не мешает: он идёт от корней, а не считает ссылки. - Второй держатель — вызывающий.
builder.m_taskи есть бокс, поэтомуTask, которыйDemoAsyncвернул вMain, — тот же самый объект. Дляfire-and-forgetвызова это не важно: бокс удержит цепочка от таймера. - Машина на стеке после строки 24 больше не нужна. Её кадр исчезает на
return(строка 25), все следующие вызовыMoveNextидут на копии в боксе.
Проверка (отдельная программа, не часть лабораторной). Задачу, которую вернул async-метод, нигде не храним, на неё только WeakReference:
Задача Delay и бокс пережили сборку мусора, хотя на них никто не ссылался, кроме таймера, а после завершения продолжения были собраны. Бокс — это и есть объект, лежащий в списке продолжений Delay. Снято на Ubuntu 26.04, рантайм 10.0.12.
А что делает поток Main?
В консольном приложении он дальше просто ждёт. Компилятор обернул код верхнего уровня (строки 7–9) в метод <Main>$, а настоящую точку входа сделал обычным синхронным Main, который блокируется на GetResult(). Декомпиляция этой же сборки:
И то же в IL:
- Строка 3 IL:
.entrypoint— именно этот метод вызывает рантайм при старте. - Строка 7 IL: вызывается ваш async-код и сразу возвращает незавершённый
Task. - Строка 11 IL: поток 2 блокируется здесь до конца программы.
Поэтому продолжение и не может вернуться на поток 2: тот занят ожиданием. В сервере (ASP.NET Core) такой поток вернулся бы в пул и обслуживал бы другие запросы.
Следующие 200 мс никакой ваш код не выполняется. Кто же тогда помнит, что через 200 мс надо что-то сделать?
Шаг 3. t = 0…200 мс: тишина, спит только будильник¶
Помнит поток .NET Timer. Рантайм создаёт его один раз, при первом таймере в процессе:
| CoreLib 10.0.12: TimerQueue, создание будильника | |
|---|---|
Фоновый поток (строка 3: не мешает процессу завершиться) с понятным именем — его видно в окне Threads отладчика. А вот всё, что он делает, — метод целиком:
- Строка 14: поток спит на событии с таймаутом
numмс. Сейчас таймаут — 200 мс до нашего срока. Если кто-то заведёт таймер с более ранним сроком, событие разбудит поток раньше. - Строки 19–38: проснувшись, поток проходит по очередям таймеров. Те, чей срок наступил (строка 23), откладывает в список
list(строка 26). Для остальных считает, сколько спать до ближайшего (строки 34–37). - Строки 40–47: истёкшие очереди отдаются в пул потоков как работа с высоким приоритетом. Сами колбэки этот поток не выполняет.
Сейчас ни один поток не работает на DemoAsync. Спящий будильник обслуживает и один Delay, и 10 000 (§1.6).
Проходит 200 мс,
WaitOneв строке 14 возвращается по таймауту, и будильник просыпается.
Шаг 4. t = 200 мс, поток .NET Timer: «звонок» уходит в пул¶
Будильник находит нашу очередь таймеров истёкшей (строки 22–26 листинга TimerThread) и ставит её в пул (строка 44), после чего снова засыпает в строке 14. Он не выполняет колбэки сам, поэтому медленный колбэк одного таймера не задержит срабатывание остальных: будильник всегда свободен.
Работа лежит в очереди пула. Её забирает первый свободный поток пула — в нашем прогоне №5.
Шаг 5. t ≈ 200 мс, поток пула 5: срабатывает таймер, завершается задача¶
Поток 5 выполняет TimerQueue.FireNextTimers(). Метод длинный, ниже его суть: многоточием ... заменены строки про периодические таймеры и пересчёт следующего срока.
- Строки 12–13: таймер истёк.
- Строка 16: одноразовый таймер (как у
Delay) удаляется из очереди. - Строки 17–20: первый истёкший таймер запоминается, чтобы выполнить его самому.
- Строки 21–24: каждый следующий ставится в пул отдельной задачей. Именно это даст всплеск потоков в §1.6.
- Строка 30: первый таймер срабатывает прямо на этом потоке.
Fire() вызывает колбэк таймера — статический TimerCallback из DelayPromise (строки 38–41 листинга DelayPromise), а тот — CompleteTimedOut (строки 43–57). Ключевая строка 45: TrySetResult().
TrySetResultпереводит задачу вRanToCompletionи обходит список её продолжений. А там лежит наш бокс из шага 2.
Шаг 6. t ≈ 200 мс, тот же поток 5: продолжение DemoAsync¶
Продолжение вызывается прямо внутри TrySetResult, синхронно, на том же потоке 5. Бокс вызывает MoveNext:
- строка 10:
num = <>1__state— там0, сохранённый в шаге 2; - строки 28–32: ветка
case 0: достать awaiter из поля, очистить поле, вернуть состояние-1, перейти кIL_009e; - строка 44:
GetResult()—awaitзавершён (при ошибке исключение было бы брошено здесь); - строка 45: печать — это строка 15 исходника:
Синхронно, а не через очередь, потому что в консоли нет SynchronizationContext, который потребовал бы вернуться в «свой» поток. Как выбирается место продолжения, разберём в главе 5.
Дальше тот же поток 5 идёт по строкам 46–57 (await Task.CompletedTask не останавливается) и доходит до Task.Yield() в строке 58. Тот уже по-настоящему уходит через очередь пула — отсюда возможная смена потока в строке 21 исходника.
Вся цепочка на одной схеме¶
sequenceDiagram
autonumber
participant M as Поток Main (2)
participant D as DelayPromise + таймер
participant T as Поток «.NET Timer»
participant P as Поток пула (5)
rect rgba(120,120,200,.08)
Note over M,D: t = 0
M->>D: MoveNext стр. 19: Task.Delay(200) → задача и запись таймера
M->>D: MoveNext стр. 22–25: бокс → в список продолжений, return
Note over M: Main ждёт в GetResult()
end
rect rgba(120,120,120,.06)
Note over T: t = 0…200 мс: WaitOne, ни один поток не занят DemoAsync
end
rect rgba(80,180,140,.10)
Note over T,P: t = 200 мс
T->>P: срок наступил → очередь таймеров в пул
P->>D: FireNextTimers → TimerCallback → CompleteTimedOut
D->>P: TrySetResult → бокс.MoveNext()
Note over P: MoveNext case 0 → стр. 45: «after Delay: thread 5»
end
Одной строкой: .NET Timer → очередь пула → FireNextTimers → TimerCallback → TrySetResult → MoveNext вашего метода, и всё после шага 4 — на одном и том же потоке пула.
Итого
Во время ожидания не занят ни один поток: есть объект DelayPromise, запись в очереди таймеров и один на весь процесс спящий поток .NET Timer. Поток пула появляется только в момент завершения: он выполняет колбэк таймера, а заодно и ваше продолжение. Поэтому после await Task.Delay вы оказываетесь на потоке пула.
На Windows рантайм умеет работать и поверх пула потоков ОС (настройка UseWindowsThreadPool). Тогда таймер заводится через CreateThreadpoolTimer, без потока .NET Timer. По умолчанию используется собственный пул .NET (проверено на Windows 11, рантайм 10.0.12: потоки пула называются .NET TP Worker, UseWindowsThreadPool выключен; включить можно переменной DOTNET_ThreadPool_UseWindowsThreadPool=1).
1.5. Task как работа и Task как обещание: что внутри¶
Вторая часть final/Program.cs запускает три задачи — работу, обещание с ручным завершением и Task.Delay — и печатает их настоящие типы:
Вывод:
Task.Run(строки 17–21) — обычныйTask<int>, внутри которого лежит ваш делегат. Пул выполняет его на своём потоке (строка 2 вывода): это «работа».TaskCompletionSource<int>.Task(строки 22–23) — тоже обычныйTask<int>, но без делегата. Его завершает тот, кто вызоветSetResult: здесь — колбэк таймераSystem.Threading.Timerчерез 100 мс (строка 23). До этого задача в состоянииWaitingForActivation(строка 25 кода, строка 3 вывода) и кода внутри не содержит вообще.Task.Delay(строка 24) — свой наследникTask,DelayPromise: специализированное обещание с таймером. Его устройство — в листинге шага 1 §1.4.
Один и тот же тип Task<int> может означать и «работу», и «обещание». Различие не в типе, а в том, кто и как его завершает.
1.6. 10 000 ожиданий: потоки нужны не для ожидания, а для продолжений¶
Третья часть final/Program.cs запускает 10 000 Task.Delay(500) одновременно и смотрит на пул потоков до, во время и после ожидания:
- Строка 32: сколько потоков в пуле до начала.
- Строки 35–39: 10 000 асинхронных лямбд. Каждая ждёт 500 мс, а потом записывает, на каком потоке выполнилось её продолжение (строка 38).
- Строки 40–41: через 250 мс, в середине ожидания, снова считаем потоки.
- Строка 42: дожидаемся всех.
Вывод:
Факт: во время ожидания потоков не прибавилось
Пока 10 000 задач ждут, в пуле те же 3 потока (строка 41 кода, строка 3 вывода). Рост до 8–9 происходит после, в момент, когда 10 000 таймеров истекают почти одновременно.
Это прямо следует из FireNextTimers (листинг шага 5 §1.4): первый истёкший таймер выполняется на текущем потоке (строка 30), а каждый следующий ставится в пул отдельной задачей (строки 21–24). Пул получает всплеск из 10 000 коротких работ. Пока потоков меньше MinThreads (по умолчанию — число ядер, здесь 8), пул создаёт их сразу, без задержки. Выше минимума он добавлял бы их медленно (глава 13).
В первой версии курса я написал «стало 9» и не объяснил, откуда рост. Замер «во время» показал, что ожидание само по себе потоков не требует.
Сравните с 10 000 Task.Run(() => Thread.Sleep(500)): там каждый «ожидающий» занимал бы поток, и при 8 ядрах это растянулось бы на минуты (10 000 × 0,5 с ÷ 8 потоков ≈ 10 минут, пока пул медленно добавляет потоки). Это эксперимент главы 13.
1.7. Итоги¶
async— указание компилятору построить машину состояний. Потоков он не создаёт.awaitна незавершённой задаче запоминает состояние, подписывает машину на завершение задачи и возвращает управление. Поток свободен.- «Ожидание» — это объект (
DelayPromise, сокет,TaskCompletionSource) плюс механизм, который его завершит: таймер, порт завершения ввода-вывода, чужой код. - Поток нужен в момент завершения: продолжение выполняется на том потоке, который завершил задачу (если нет контекста синхронизации, глава 5).
- Номера потоков и смена потока после
Yield— не контракт.
Код лабораторной¶
Запуск из папки главы: dotnet run -c Release --project start или --project final.
| final/Program.cs | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | |