5. Куда вернётся продолжение¶
О главе
Цель: понять, где и кем выполняется код после await: на каком потоке, через какую очередь, внутри чьего вызова. Разобрать по коду рантайма, как это решается (SynchronizationContext, TaskScheduler, инлайн), что на самом деле делает ConfigureAwait, и чем опасны синхронные продолжения.
Лабораторная: start/ — модель UI-потока (свой SynchronizationContext) и TaskCompletionSource. Нужно предсказать потоки и выполнить два TODO. final/ — все маршруты продолжения (§5.2–5.4), ConfigureAwait и ConfigureAwaitOptions (§5.5), синхронные продолжения: несколько подписчиков и защита стека (§5.6). Код — в конце главы.
Визуализация: Маршрут продолжения — Меняйте условия: контекст, планировщик, ConfigureAwait, способ завершения, и смотрите, какой маршрут выберет await.
Статус: ✅ проверено на Ubuntu 26.04 (2 ядра), рантайм 10.0.12. Листинги CoreLib — декомпиляция System.Private.CoreLib 10.0.12 (.\tools\disasm.ps1 -Assembly corelib -Type …), машинный код снят на Linux x64 и на Windows x64. Перепроверено на Windows 11 (8 ядер); различия — во вкладках «Linux» и «Windows».
После await на незавершённой задаче метод «засыпает», а его продолжение (следующий кусок MoveNext) потом кто-то должен вызвать. Вопрос главы: кто и где. Ответ складывается из трёх вещей:
- что было в момент подписки на потоке, который дошёл до
await:SynchronizationContextиTaskScheduler; - что сказали
awaitчерезConfigureAwait; - что происходит в момент завершения задачи: кто её завершил, с какими опциями, насколько глубок стек.
5.1. Где принимается решение¶
Возьмём await из лабораторной (final/Program.cs, строка 50):
| final/Program.cs | |
|---|---|
Компилятор превращает его в ту же конструкцию, что и любой await (C# без сахара, .\tools\disasm.ps1 chapters\05-continuations\final -Mode cs, фрагмент MoveNext верхнего уровня):
- Строка 2:
ConfigureAwaitвозвращает не задачу, а структуруConfiguredTaskAwaitable, у неё свой awaiter —ConfiguredTaskAwaitable.ConfiguredTaskAwaiter. Это просто другой тип awaiter'а (паттерн awaitable из главы 3), а не «режим» задачи. - Строка 6: под этот тип awaiter'а у машины своё поле
<>u__2(для обычногоTaskAwaiter—<>u__1). - Строка 7: подписка идёт, как всегда, через builder. Ни в коде компилятора, ни в машине состояний нет ни слова о потоках и контекстах. Всё решается внутри
AwaitUnsafeOnCompletedи дальше, в рантайме.
Builder: какой awaiter пришёл¶
- Строки 4–8: обычный
await task(TaskAwaiter,TaskAwaiter<T>) — «вернись в захваченный контекст»:continueOnCapturedContext: true. - Строки 10–15:
await task.ConfigureAwait(…)— тот же вызов, но флаг берётся из опций: битContinueOnCapturedContext(= 1).ConfigureAwait(false)— это опцииNone, то естьfalse. - Строки 3–4, 9–10: странное
default(TAwaiter) != null— приём для JIT. Для структуры-awaiter'а это константаtrue, проверкаisдля конкретного типа тоже вычисляется при компиляции, и от метода остаётся одна ветка. Это видно в машинном коде ниже.
Под капотом: от builder'а для ConfiguredTaskAwaiter остаются три инструкции
.\tools\disasm.ps1 chapters\05-continuations\final -Mode asm -Method '*AsyncTaskMethodBuilder*:AwaitUnsafeOnCompleted'. Логика одна, листинги на ОС разные: соглашение о вызовах другое (Linux — System V: аргументы в rdi, rsi, rdx; Windows x64: rcx, rdx, r8), а в Linux-версии есть ещё и кадр rbp.
- Строки 5–6:
edx = m_options & 1— третий аргумент,continueOnCapturedContext. Полеm_optionsлежит в awaiter'е по смещению 8, сразу за ссылкой на задачу. - Строка 7:
rdi = m_task— первый аргумент. Второй (box) уже лежит вrsi. - Строка 10: переход в
UnsafeOnCompletedInternalбез возврата (хвостовой вызов). Никакихis, никаких других веток.
| JIT, FullOpts: AwaitUnsafeOnCompleted[ConfiguredTaskAwaiter] | |
|---|---|
- Строки 3–4:
r8d = m_options & 1— третий аргумент,continueOnCapturedContext. Смещение поля то же, 8. - Строка 5:
rcx = m_task— первый аргумент. Второй (box) уже лежит вrdx. - Строка 7: тот же хвостовой переход. Кадра нет вообще (ни
push rbp, ниpop rbp), поэтому 17 байт против 20.
Вывод одинаков на обеих ОС: три инструкции, одна ветка, хвостовой вызов.
Для обычного TaskAwaiter в том же файле 119 байт на Linux и 112 на Windows: туда встроен UnsafeOnCompletedInternal с проверкой «включена ли трассировка TPL», а затем tail.jmp в Task.UnsafeSetContinuationForAwait с edx = 1 (на Windows r8d = 1).
UnsafeSetContinuationForAwait: три маршрута¶
UnsafeOnCompletedInternal при выключенной трассировке сразу зовёт метод задачи, на которую подписываемся:
Это и есть весь алгоритм выбора места. Он выполняется в момент подписки, на потоке, который дошёл до await:
| Маршрут | Условие | Что кладётся в продолжения задачи | Строки |
|---|---|---|---|
| 1. Контекст синхронизации | continueOnCapturedContext и у потока есть SynchronizationContext не базового типа |
обёртка SynchronizationContextAwaitTaskContinuation: «при завершении сделай Post в этот контекст» |
5–10 |
| 2. Планировщик | контекста нет, но код выполняется внутри задачи с нестандартным TaskScheduler |
обёртка TaskSchedulerAwaitTaskContinuation: «запусти в этом планировщике» |
13–18 |
| 3. Без контекста | ConfigureAwait(false) или ни контекста, ни планировщика |
сам бокс машины, без обёрток и аллокаций | 27–31 |
- Строка 7:
GetType() != typeof(SynchronizationContext). Экземпляр базового классаSynchronizationContextсчитается «контекста нет». Причина — в §5.2. - Строка 13:
TaskScheduler.InternalCurrent— планировщик задачи, которая сейчас выполняется на этом потоке (null, если код вызван не из задачи). ПубличныйTaskScheduler.Current— то же самое, но вместоnullвозвращаетDefault. - Строки 20–23, 28–31: задача успела завершиться между проверкой
IsCompletedвMoveNextи подпиской. Тогда продолжение запускается сразу, но не синхронно: черезPost/ планировщик (canInlineContinuationTask: false) или очередь пула. Синхронный вызов здесь был бы ошибкой: мы ещё внутриAwaitUnsafeOnCompletedтекущегоMoveNext, и следующийMoveNextначался бы до того, как текущий дошёл доreturn. - Маршрут 3 — тот самый случай из главы 4: бокс лежит прямо в
m_continuationObject, а решение «инлайн или пул» принимается позже, при завершении (§5.4).
flowchart TD
A["await task<br/>(задача не завершена)"] --> B{"continueOnCapturedContext?<br/>(нет ConfigureAwait(false))"}
B -- нет --> N
B -- да --> C{"SynchronizationContext.Current<br/>есть и не базовый?"}
C -- да --> S["маршрут 1: при завершении<br/>Post в этот контекст"]
C -- нет --> D{"выполняемся внутри задачи<br/>с нестандартным TaskScheduler?"}
D -- да --> T["маршрут 2: при завершении<br/>задача в этом планировщике"]
D -- нет --> N["маршрут 3: бокс в продолжениях задачи"]
N --> E{"при завершении: инлайн разрешён?<br/>(нет RunContinuationsAsynchronously,<br/>стек не глубокий, у завершающего<br/>потока нет своего контекста)"}
E -- да --> I["MoveNext прямо внутри<br/>SetResult завершающего потока"]
E -- нет --> P["очередь пула потоков"]
Опыт: четыре маршрута¶
Первая часть final/Program.cs:
UiThread — модель UI-потока: отдельный поток с именем UI, очередь и SynchronizationContext, у которого Post кладёт делегат в эту очередь (§5.2). Here.Now печатает номер и вид потока, а также контекст и планировщик, если они не «по умолчанию».
Предскажите
В start/Program.cs у строк с Here.Now стоят комментарии PREDICT. Впишите, на каком потоке напечатается каждая (основной, пул, UI), и только потом запускайте:
- Строки 2–3 вывода (строки 7–9 кода): консоль. Контекста нет — маршрут 3. Продолжение выполнил поток пула, который обработал срабатывание таймера
Task.Delay(подробно — глава 1, §1.4). - Строки 4–6 (строки 12–17): на «UI-потоке» — маршрут 1. Таймер сработал на потоке пула 5, тот сделал
Post(строка 5 вывода печатает самUiThread.Post), и продолжение выполнил поток UI, тот же, что доawait. Ровно так WinForms/WPF возвращают вас в UI-поток послеawait. - Строки 7–8 (строки 19–22):
new SynchronizationContext()поставлен, но послеawaitего нет: контекст базового типа маршрут 1 не включает (строка 7 листингаUnsafeSetContinuationForAwait). - Строки 9–10 (строки 24–30): лямбда запущена задачей в
ConcurrentExclusiveSchedulerPair.ExclusiveScheduler. Внутри неёTaskScheduler.Current— этот планировщик, и продолжение послеawait Task.Delayснова выполнено им (маршрут 2).
Факт: маршруты одинаковы на Linux и Windows, номера потоков нет (раскройте после предсказания)
Ubuntu 26.04 (2 ядра, десять прогонов start и final) и Windows 11 (8 ядер, два прогона final), рантайм 10.0.12. Маршруты одинаковы на обеих ОС и во всех прогонах, номера потоков пула меняются (Linux: 5, 6 или 7; Windows: 5, 7, 8, 9).
- Основной поток: на Linux 1, на Windows 2: на Windows номер 1 достаётся потоку финализатора (глава 1, §1.3). Номера потоков — не контракт.
- Строка 8: после
awaitс базовым контекстом поток бывает и тем же (5), и другим (6, 7). Но контекста на нём нет никогда: рантайм не «переносит»SynchronizationContextчерезawait, а потоки пула работают без него.
Интерактивная схема¶
Меняйте условия: контекст, планировщик, ConfigureAwait, способ завершения, и смотрите, какой маршрут выберет await. Открыть на весь экран.
5.2. SynchronizationContext: «выполни это там, где надо»¶
SynchronizationContext — абстракция «места выполнения» с двумя главными методами. Базовый класс целиком полезен для понимания:
- Строки 1–6: контекст — просто поле потока. Никакой магии:
SetSynchronizationContextзаписывает объект в текущий поток,Currentчитает его. - Строки 8–11:
Send— выполнить синхронно и дождаться. - Строки 13–19:
Post— выполнить асинхронно. Базовая реализация отправляет делегат в пул потоков, то есть ведёт себя ровно как «контекста нет». Поэтомуawaitи пропускает базовый тип (строка 7UnsafeSetContinuationForAwait): обёртка иPostдали бы то же, что маршрут 3, только дороже.
Настоящие контексты переопределяют Post:
| Контекст | Post делает |
Кто ставит |
|---|---|---|
WindowsFormsSynchronizationContext |
Control.BeginInvoke — сообщение в очередь окна |
WinForms для UI-потока |
DispatcherSynchronizationContext |
Dispatcher.BeginInvoke |
WPF |
AspNetSynchronizationContext |
выполнение «внутри запроса», по одному продолжению за раз | старый ASP.NET (.NET Framework) |
| нет | — | консоль, ASP.NET Core, потоки пула, сервисы |
Наш UiThread из лабораторной — такой же по устройству:
- Строки 16–20:
Postтолько кладёт делегат в очередь. Выполнит его поток UI. - Строки 30–35: цикл потока UI: поставить себя контекстом этого потока (строка 32) и выполнять делегаты из очереди по одному. Цикл сообщений WinForms и диспетчер WPF устроены так же.
- Строки 23–28: запуск обработчика на потоке UI, как будто пользователь нажал кнопку. Синхронная часть
handler()выполняется на потоке UI, аUnwrapвозвращает задачу, которая завершится вместе с обработчиком.
Обёртка маршрута 1¶
- Строки 3–7: если задачу завершили на том же контексте (например, UI-поток сам вызвал
SetResult) и инлайн разрешён — продолжение выполняется сразу, без очереди. - Строки 9, 12–17: иначе —
Postв захваченный контекст.m_action— делегатMoveNextActionбокса: когда поток UI дойдёт до него в очереди, он вызоветMoveNextвашего метода.
Это дороже маршрута 3: обёртка (объект в куче) и делегат MoveNextAction (создаётся один раз на бокс), плюс работа самого контекста (в WinForms — сообщение окну). И это причина дедлоков, если поток контекста заблокирован (глава 6).
5.3. TaskScheduler: маршрут для задач¶
Маршрут 2 срабатывает, когда await выполняется внутри задачи, запущенной на нестандартном планировщике (Task.Factory.StartNew(…, scheduler), ConcurrentExclusiveSchedulerPair, планировщики с ограничением параллелизма, TaskScheduler.FromCurrentSynchronizationContext()).
- Строка 9: продолжение оборачивается в новую задачу на этом планировщике. Это самый дорогой из трёх маршрутов: обёртка плюс объект
Task. - Строки 20–24: планировщику сначала предлагают выполнить её сразу (
TryExecuteTaskInline), он может отказаться. - Строка 27: иначе — поставить в очередь планировщика (
QueueTask).
Так ExclusiveScheduler в опыте гарантирует, что и продолжения после await идут «по одному», как и сама задача. Но только пока await без ConfigureAwait(false): с ним продолжение уйдёт из планировщика в пул.
Task.Factory.StartNew без планировщика берёт TaskScheduler.Current
Внутри такой задачи «невинный» Task.Factory.StartNew(…) без явного scheduler тоже попадёт в нестандартный планировщик, а не в пул. Task.Run всегда использует TaskScheduler.Default. Подробно — глава 10.
5.4. Без контекста: инлайн или пул¶
В консоли, ASP.NET Core, сервисах и на потоках пула работает маршрут 3: в продолжениях задачи лежит сам бокс. Где выполнится MoveNext, решает тот, кто завершает задачу, внутри TrySetResult → FinishContinuations → RunContinuations (глава 4, §4.4):
Продолжение выполняется инлайн — прямо внутри SetResult, на потоке завершителя (строка 22), если выполнены три условия:
- у задачи нет
RunContinuationsAsynchronously(бит0x40, строка 2); - стек не слишком глубок (
TryEnsureSufficientExecutionStack, строка 2); - у завершающего потока нет своего контекста или нестандартного планировщика (строки 31–41). Код из UI-потока не будет «втихую» выполнять чужие продолжения на себе.
Иначе бокс уходит в очередь пула (строка 13). preferLocal: true — в локальную очередь текущего потока пула, если завершитель сам поток пула. К этому вернёмся в опыте §5.6.
Отсюда частая формулировка «после await код продолжится на потоке, который завершил задачу». Для Task.Delay это поток пула, обработавший таймер. Для сокета — поток, обработавший завершение ввода-вывода (на Windows его приносит порт завершения IOCP, на Linux — цикл epoll; подробно — глава 13). Для TaskCompletionSource — любой код, вызвавший SetResult, и это источник проблем (§5.6).
5.5. ConfigureAwait¶
Мы уже видели всё, что делает ConfigureAwait(false): ставит continueOnCapturedContext = false в вызове UnsafeSetContinuationForAwait. Маршруты 1 и 2 пропускаются, бокс кладётся в продолжения напрямую (маршрут 3). Отсюда свойства:
- Действует только на этот
await. Это не режим метода и не режим задачи, а другой тип awaiter'а. СледующийawaitбезConfigureAwait(false)снова посмотрит на контекст. Но если после первогоawait …ConfigureAwait(false)код оказался на потоке пула, контекста там уже нет, и следующиеawaitего не найдут. - Не переключает поток сам по себе. Если задача уже завершена,
IsCompletedвернётtrueи подписки не будет вовсе: код продолжится синхронно на текущем потоке, в том числе на потоке UI. - Не влияет на
ExecutionContext.AsyncLocalтечёт черезawaitвсегда (глава 7). - Зачем нужен: (1) не дать дедлок, если вызывающий блокируется на задаче (глава 6); (2) не грузить UI-поток работой, которой он не нужен; (3) чуть дешевле: нет обёртки и
Post. - В приложении на ASP.NET Core и в консоли не нужен: контекста нет. В библиотеках, которые могут вызвать из UI или старого ASP.NET, его ставят на каждый
await(анализатор CA2007).
ConfigureAwaitOptions (.NET 8+)¶
Перегрузка ConfigureAwait(ConfigureAwaitOptions) — флаги вместо одного bool:
| Флаг | Значение | Что делает |
|---|---|---|
None |
0 | то же, что ConfigureAwait(false) |
ContinueOnCapturedContext |
1 | то же, что ConfigureAwait(true) |
SuppressThrowing |
2 | не бросать исключение на await упавшей или отменённой задачи |
ForceYielding |
4 | всегда приостанавливаться, даже если задача уже завершена |
Как это устроено в awaiter'е:
- Строки 6–10:
ForceYieldingпросто врёт «ещё не готово». Машина приостанавливается, подписка идёт черезUnsafeSetContinuationForAwait, а тамAddTaskContinuationна завершённой задаче вернётfalse, и продолжение запустится асинхронно: черезPostв контекст (строки 20–23 листинга §5.1) или в пул (строки 28–31). Это заменаawait Task.Yield(), которую можно сочетать сConfigureAwait(false). - Строки 29–33:
SuppressThrowingне бросает, а помечает исключение обработанным (MarkExceptionsAsHandled): оно не всплывёт и вTaskScheduler.UnobservedTaskException.
Опыт: ConfigureAwait на «UI-потоке»¶
- Строка 2 вывода (строки 36–37 кода): обработчик начался на потоке UI, но после
await …ConfigureAwait(false)оказался в пуле.Postне было. - Строка 3 (строки 41–42):
ConfigureAwait(false)на завершённой задаче — мы остались на потоке UI. Подписки не было, переключать поток некому. - Строки 4–5 (строки 43–44):
ForceYieldingс захватом контекста — поток UI сам себе сделалPost(строка 4 вывода) и продолжил из очереди. Это аналогawait Task.Yield()в UI: «дай очереди сообщений обработать накопившееся». - Строка 6 (строки 45–46):
ForceYieldingбезContinueOnCapturedContext— гарантированный уход с текущего потока в пул. Удобно, чтобы снять тяжёлую работу с UI-потока, не создаваяTask.Run. - Строка 7 (строки 49–51):
SuppressThrowing—awaitупавшей задачи не бросил, статусFaultedостался. - Строка 8 (строки 52–60): тот же флаг на
Task<int>— исключениеArgumentOutOfRangeExceptionпри вызовеConfigureAwait.
Факт: SuppressThrowing для Task<T> — ошибка во время выполнения, а не компиляции
В первой версии курса стояло «работает только для необобщённого Task [проверить]». Проверено: Task<TResult>.ConfigureAwait(ConfigureAwaitOptions) проверяет флаги и бросает исключение:
Текст исключения подсказывает выход: «…cast the Task<TResult> to its base class Task and await that with SuppressThrowing». Результата при таком await не будет: подавить исключение и получить T нельзя. Компилятор ошибку не даёт, но анализатор предупреждает: при сборке final видно warning CA2261: The ConfigureAwaitOptions.SuppressThrowing option is only supported with the non-generic Task.
Задание (TODO 1)
В start/Program.cs строка «UI, после второго await» печатается на потоке UI. Сделайте так, чтобы она печаталась на потоке пула, изменив только второй await. Найдите два способа: через bool и через ConfigureAwaitOptions.
5.6. Синхронные продолжения: чужой код внутри вашего SetResult¶
Маршрут 3 по умолчанию выполняет продолжение инлайн, внутри вызова, который завершил задачу. Для Task.Delay и ввода-вывода это хорошо: потоку пула не нужно второй раз ставить работу в очередь. Но для TaskCompletionSource «завершитель» — ваш код, и в него «ныряет» чужое продолжение.
- Строки 1–4 вывода:
tcs.SetResult(1)(строка 13 кода) выполнил продолжениеConsumerAsync(строки 20–21) на своём потоке и вернул управление только через секунду, когда оно закончилоThread.Sleep. Чем это опасно:- задержка: кто вызвал
SetResult(обработчик сети, таймер, ваш производитель в очереди), стоит, пока работает чужой код; - дедлок: вы держите
lockи вызываетеSetResult, а продолжение пытается взять тот жеlockна другом потоке и ждёт, или вызывает код, который ждёт вас; - реентерабельность: продолжение снова вызывает ваш объект, пока тот в середине операции и его состояние не согласовано.
- задержка: кто вызвал
- Строки 5–8: с
RunContinuationsAsynchronouslyбокс ушёл в очередь пула (бит0x40в строке 2 листингаRunContinuations),SetResultвернулся сразу.
Правило: если ваш код завершает задачи, которые ждут другие (библиотека, очередь, пул соединений, Channel-подобные структуры), создавайте TaskCompletionSource с TaskCreationOptions.RunContinuationsAsynchronously. Если причины поступить иначе нет.
Задание (TODO 2)
В start/Program.cs строка «после SetResult» печатается через секунду. Добейтесь, чтобы она печаталась сразу, изменив только создание TaskCompletionSource.
Факт: «асинхронно» не значит «на другом потоке»
В 20 прогонах final продолжение с RunContinuationsAsynchronously всегда выполнял другой поток пула. Но в черновой версии лабораторной в одном прогоне все три строки напечатал один и тот же поток 5: «до SetResult», «после SetResult» (0 мс) и только потом «продолжение Consumer».
Объяснение — в строке 13 листинга RunOrScheduleAction: preferLocal: true. Бокс попадает в локальную очередь текущего потока пула. Сразу после SetResult метод доходит до await consumer (строка 15) и освобождает поток. Кто первым возьмёт работу — сам этот поток из своей очереди или соседний, «укравший» её, — дело случая. Гарантируется только «не внутри SetResult», а не «на другом потоке» (как и с Task.Yield в главе 1).
Несколько подписчиков: инлайн только у одного¶
Во всех прогонах подписчик 1 выполнялся инлайн на потоке SetResult, а 2 и 3 — на других потоках пула. Так же на Windows: подписчик 1 всегда на потоке SetResult, остальные на других потоках пула, порядок строк меняется от запуска к запуску. Строки при этом идут в любом порядке: подписчики 2 и 3 работают параллельно с первым. Почему так, видно в ветке «список продолжений» RunContinuations:
- Строки 4–16: первый проход. Первое подходящее продолжение пропускается (
flag3ещёfalse), все остальные ставятся в пул (allowInlining: false, строка 13). - Строки 18–27: второй проход выполняет то, что осталось, — первое продолжение, инлайн (если
flag2).
Если бы все продолжения шли инлайн по очереди, второй подписчик ждал бы, пока закончит первый, а SetResult — пока закончат все. Рантайм выполняет инлайн только одно продолжение, а остальные раздаёт пулу, чтобы они шли параллельно.
Защита стека¶
Если продолжение само завершает следующую задачу, инлайн вкладывается в инлайн: SetResult → MoveNext → SetResult → MoveNext → … Каждое звено добавляет кадры в стек одного потока. Проверка TryEnsureSufficientExecutionStack() (строка 2 листинга RunContinuations) смотрит, сколько стека осталось. Если мало, продолжение уходит в пул, и цепочка продолжается на «свежем» стеке.
- Строки 52–53: 99 999 async-методов, каждый ждёт своё звено.
- Строки 72–74: звено
kпосле пробуждения завершает звеноk+1. Если инлайн разрешён,MoveNextзвенаk+1выполнится внутри этогоSetResult, глубже по стеку. - Строки 70–71: признак ухода в пул: звено выполняется не внутри
SetResultпредыдущего. Поле[ThreadStatic](строка 42) хранит, внутри какогоSetResultсейчас находится этот поток. - Строки 56–61: цепочку запускает отдельный поток со стеком ровно 1 МБ.
Факт: ограничитель глубины есть, а числа зависят от ОС
В первой версии курса это стояло с пометкой «проверить». Проверено на обеих ОС: инлайн-цепочка рано или поздно прерывается, и продолжение уходит в пул. Результат одинаков во всех прогонах (Linux — десять, Windows — пять). Меняются сами числа:
| Linux (Ubuntu 26.04) | Windows 11 | |
|---|---|---|
| первое звено, поток со стеком 1 МБ | 1 500 | 1 042 |
| дальше, на потоках пула | каждые 13 573 | каждые 1 638 |
| стек потока пула по умолчанию | 8 МБ (ulimit -s) |
1,5 МБ |
| стека на одно звено | ≈ 600 байт | ≈ 880 байт |
с DOTNET_TieredCompilation=0 |
1 901 и 17 192 | 1 432 и 2 251 |
Почему так. Проверка TryEnsureSufficientExecutionStack() отрабатывает, когда на стеке остаётся меньше ≈ 128 КБ, поэтому число звеньев до ухода — это (размер стека − 128 КБ) / (стека на звено).
- Размер стека потока пула. На Linux поток получает стек
ulimit -s(здесь 8 МБ): сulimit -s 2048интервал сократился до 3 225, а первое звено на потоке с явными 1 МБ осталось 1 500. На Windows стек потока по умолчанию 1,5 МБ. Проверено: если запускающий поток создать сmaxStackSize1,5 МБ (или 0, то есть по умолчанию), первое звено сдвигается к 1 637, как и интервал на пуле; с 8 МБ первое звено уходит к 9 383, а интервал на пуле остаётся 1 638. Выходит, Windows-пул не стал бы жить дольше от того, что у основного потока стек больше. - Размер кадров. Из двух замеров на Windows (стеки 1 и 1,5 МБ) выходит ≈ 880 байт на звено, из замеров на Linux (1 и 8 МБ) — ≈ 600 байт: оба раза запас получается ≈ 128–136 КБ. Кадры на Windows крупнее, возможно, из-за соглашения о вызовах x64 Windows (резерв «shadow space» в каждом кадре), но это гипотеза, кадры не сравнивались.
Конкретные числа — свойство стенда, а не рантайма. Многоуровневая компиляция на результат влияет слабо: оптимизированные кадры чуть меньше.
5.7. Итоги¶
- Место продолжения выбирается при подписке, в
Task.UnsafeSetContinuationForAwait. Три маршрута:PostвSynchronizationContext(если он есть и не базового типа), задача в нестандартномTaskScheduler, иначе бокс прямо в продолжения задачи. - Компилятор и машина состояний про контексты ничего не знают.
ConfigureAwait— другой тип awaiter'а, а JIT сводит builder к одной ветке. - Без контекста продолжение выполняется инлайн, внутри вызова завершителя, если у задачи нет
RunContinuationsAsynchronously, стек не глубок и у завершающего потока нет своего контекста. Иначе — в пул. Из нескольких подписчиков инлайн идёт только один. ConfigureAwait(false)действует на одинawait, на завершённой задаче поток не меняет,ExecutionContextне трогает. В приложениях без контекста он не нужен, в библиотеках — на каждыйawait.ConfigureAwaitOptions:ForceYielding— гарантированная приостановка (аналогTask.Yield),SuppressThrowing— только для необобщённогоTask, дляTask<T>— исключение при вызове.TaskCompletionSourceв библиотечном коде создавайте сRunContinuationsAsynchronously. «Асинхронно» гарантирует «не внутриSetResult», но не «на другом потоке».
Код лабораторной¶
Запуск из папки главы: dotnet run -c Release --project start или --project final.