6. Лаборатория дедлоков¶
О главе
Цель: воспроизвести классический дедлок sync-over-async («синхронно ждём асинхронное»), увидеть изнутри, где застревает продолжение, и разобрать способы его избежать, включая две ловушки с ConfigureAwait(false).
Лабораторная: start/ — модель UI-потока и один дедлок. Нужно предсказать вывод и найти три способа его устранить. final/ — дедлок со «снимком» потока UI, ConfigureAwait(false) и две ловушки с ним, Task.Run, await без блокировки. Код — в конце главы.
Визуализация: Дедлок по шагам — Три варианта кода: .Result, .Result с ConfigureAwait(false) и await.
Статус: ✅ проверено на Ubuntu 26.04 (2 ядра), рантайм 10.0.12. Листинги CoreLib — декомпиляция System.Private.CoreLib 10.0.12 (.\tools\disasm.ps1 -Assembly corelib -Type …). Перепроверено на Windows 11 (8 ядер): вывод совпадает, отличаются только номера потоков.
Дедлок с .Result / .Wait() — самый частый вопрос про async на собеседованиях и одна из самых частых ошибок в UI-приложениях и старом ASP.NET. В консоли его не воспроизвести: там нет SynchronizationContext. Поэтому сделаем собственный однопоточный контекст, как у UI.
Что нужно помнить из главы 5:
awaitна незавершённой задаче смотрит наSynchronizationContext.Currentв момент подписки. Если контекст есть (и не базового типа), продолжение будет передано в него черезPost.ConfigureAwait(false)отключает это для одногоawait. На уже завершённой задаче он ничего не меняет: подписки нет, поток тот же.
6.1. Стенд¶
UiThread — модель UI-потока: свой поток с именем UI, очередь делегатов и SynchronizationContext, у которого Post кладёт делегат в эту очередь.
- Строки 16–20:
Postтолько ставит делегат в очередь (и печатает, кто его вызвал). Выполнит делегат поток UI, когда до него дойдёт. - Строки 35–40: цикл потока UI. Он ставит себя контекстом потока (строка 37) и выполняет делегаты из очереди по одному. Если поток UI занят или заблокирован, очередь стоит. Цикл сообщений WinForms и диспетчер WPF работают так же.
- Строки 23–25: «снимок» для наблюдения за дедлоком: заблокирован ли поток UI и сколько делегатов ждёт в очереди.
- Строки 28–33: запустить обработчик на потоке UI, как будто пользователь нажал кнопку.
«Библиотечные» методы, которые будем вызывать:
И блокирующее ожидание с таймаутом 1 с, чтобы программа не зависла навсегда. Если через 500 мс задача ещё не готова, таймер пула печатает снимок потока UI:
Предскажите
В start/Program.cs один и тот же вызов Work.WithContextAsync() с .Wait делается дважды: без контекста (строка 7) и на потоке UI (строка 13). Что напечатает каждый Block? Что покажет снимок через 500 мс? Запуск:
Интерактивная схема¶
Три варианта кода: .Result, .Result с ConfigureAwait(false) и await. Открыть на весь экран.
6.2. Дедлок по шагам¶
Строка 2 вывода (строка 8 кода): в консоли контекста нет. Продолжение WithContextAsync выполнил поток пула, обработавший таймер Delay, задача завершилась через 100 мс, Wait вернул true. Блокировать поток плохо (он простаивает), но дедлока нет.
Строки 4–7 (строки 16–17): тот же вызов на потоке UI. По шагам:
- Обработчик выполняется на потоке UI (строка 4 вывода).
SynchronizationContext.Current—UiThread. WithContextAsync()(строка 16) выполняется синхронно доawait Task.Delay(100)(Work.cs, строка 7).Delayне завершён,awaitзахватывает контекст UI: в продолженияDelayкладётся обёртка «при завершении сделайPostвUiThread» (маршрут 1 из главы 5).WithContextAsyncвозвращает незавершённую задачу. Обработчик вызываетBlock→task.Wait(1 с)(строка 34): поток UI блокируется.- Через 100 мс таймер на потоке пула 5 завершает
Delay, обёртка делаетPost(строка 5 вывода). ПродолжениеWithContextAsyncвстало в очередь UI. - Обработать очередь некому: единственный поток UI ждёт в
Wait. Снимок через 500 мс (строка 6) это и показывает: поток UI вWaitSleepJoin, в очереди 1 делегат — то самое продолжение. - Круг замкнулся: поток UI ждёт задачу → задача ждёт своё продолжение → продолжение ждёт поток UI. Без таймаута ожидание длилось бы вечно. С таймаутом через 1 с
Waitвозвращаетfalse(строка 7).
sequenceDiagram
participant UI as Поток UI
participant Q as Очередь UI
participant W as WithContextAsync
participant P as Поток пула (таймер)
UI->>W: WithContextAsync()
W->>W: await Task.Delay(100): захват контекста UI
W-->>UI: незавершённая задача
UI->>UI: task.Wait() — поток заблокирован
Note over P: через 100 мс
P->>Q: Post(продолжение WithContextAsync)
Note over Q: продолжение ждёт поток UI
Note over UI,Q: поток UI ждёт задачу, задача ждёт продолжение,<br/>продолжение ждёт поток UI — дедлок
Под капотом: почему Wait не обрабатывает очередь¶
Wait, .Result и .GetAwaiter().GetResult() на незавершённой задаче приходят в один и тот же InternalWait. Wait(timeout) передаёт таймаут, .Result и GetResult() — -1, то есть «бесконечно».
- Строка 8: при бесконечном ожидании задачу, которая ещё не начала выполняться (например, из
Task.Run),Waitпытается выполнить сам, на текущем потоке (WrappedTryRunInline). У задачи async-метода планировщика нет (m_taskScheduler == null), этот путь не срабатывает. - Строка 17: немного покрутиться (
SpinWait) в надежде, что задача вот-вот завершится. - Строка 20: если ждёт поток пула, работа из его локальной очереди переносится в глобальную, чтобы её могли забрать другие потоки, пока этот заблокирован (помните
preferLocal: trueиз главы 5). - Строки 21, 24: событие
SetOnInvokeMres(этоManualResetEventSlim, у которого «вызвать» =Set()) добавляется первым в продолжения задачи. Завершение задачи взведёт событие. - Строка 32: поток засыпает на событии. Это ожидание ядра (
WaitSleepJoinв снимке), а не цикл обработки сообщений: делегаты из очереди UI в это время никто не выполняет. - Строки 29, 38: пулу сообщают, что поток заблокирован. Это помогает при голодании пула (глава 13), но очередь UI-потока не спасает.
- Строки 45–48: таймаут истёк, а задача не завершилась: событие убирается из продолжений. Сама задача и её продолжение в очереди UI никуда не деваются.
Разница между тремя способами блокироваться — только в исключениях: .Wait() и .Result заворачивают исключение задачи в AggregateException, а .GetAwaiter().GetResult() бросает исходное (глава 8). От дедлока не спасает ни один.
Факт: застрявшее продолжение не теряется, оно выполнится, как только поток UI освободится
В конце обработчика (строки 23–26 final/Program.cs) печатается статус задачи из опыта 1:
Wait давно вернул false, но продолжение WithContextAsync всё это время лежало в очереди UI (снимки опытов 3 и 4 показывают, как очередь растёт: 1 → 2 → 3). Обработчик дошёл до await в строке 24 и освободил поток UI. Цикл потока UI выполнил всю очередь по порядку, и задача из опыта 1 завершилась. Таймаут на Wait не «отменяет» операцию, он только перестаёт её ждать.
6.3. Способы избежать¶
Вывод одинаков во всех прогонах (кроме номеров потоков пула).
1. Асинхронность до конца — правильный способ¶
Опыт 6 (строки 24–25): не блокировать. await освобождает поток UI, тот обрабатывает очередь, продолжение WithContextAsync выполняется на нём же, а затем и продолжение обработчика (строка 11 вывода — снова поток UI). Это async all the way: async/await пробрасывается до самой точки входа (обработчик события, action контроллера, Main). Синхронный вызов асинхронного кода — источник и дедлоков, и голодания пула.
2. ConfigureAwait(false) в библиотеке — на каждом await¶
Опыт 2 (строка 18, Work.cs строка 14): продолжению NoContextAsync поток UI не нужен. Оно выполняется на потоке пула, завершает задачу, и Wait возвращает true. Post не было.
Это защищает вызывающих вашу библиотеку от дедлока, если они всё же блокируются. Но только если ConfigureAwait(false) стоит на каждом await по всей цепочке, причём на await, который действительно ждёт. Две ловушки:
Ловушка 1: ConfigureAwait(false) только снаружи (опыт 3, Work.cs строки 19–28). OuterOnlyAsync ставит ConfigureAwait(false), а вызванный им InnerAsync — нет. Дедлок (строки 2–4 вывода). Почему — видно в машине состояний OuterOnlyAsync (.\tools\disasm.ps1 chapters\06-deadlocks\final -Type Work -Mode cs):
| Во что превращается return await InnerAsync().ConfigureAwait(false) (из MoveNext) | |
|---|---|
Строка 1: сначала вызывается InnerAsync(), и только потом к его задаче применяется ConfigureAwait. InnerAsync выполняется синхронно на потоке UI до своего await Task.Delay(100) и там сам захватывает контекст UI. ConfigureAwait(false) снаружи влияет только на продолжение OuterOnlyAsync, но оно не наступит: задача InnerAsync ждёт своё продолжение в очереди UI.
Ловушка 2: ConfigureAwait(false) на завершённой задаче (опыт 4, Work.cs строки 31–36). Первый await Task.CompletedTask.ConfigureAwait(false) не приостанавливает метод (задача уже завершена), поэтому поток UI мы не покидаем. Следующий await Task.Delay(100) без ConfigureAwait захватывает контекст UI. Дедлок (строки 5–7 вывода). Так бывает и «в жизни»: первый await попал в кеш и завершился синхронно, а второй пошёл в сеть.
Отсюда правило анализатора CA2007 и практика библиотек: ConfigureAwait(false) на всех await во всех методах. Пропуск одного места возвращает дедлок.
3. Task.Run — обходной путь¶
Опыт 5 (строка 21): Task.Run(Work.WithContextAsync) запускает метод на потоке пула, где контекста нет. Его await не захватывает ничего, продолжение выполняется в пуле, Wait возвращает true (строка 8 вывода).
Это обходной путь, а не решение:
- поток UI всё равно заблокирован на время операции: интерфейс «замирает»;
- метод выполняется без контекста: если он трогает элементы UI или полагается на контекст, это ошибка;
- в серверном коде это занимает два потока вместо нуля и ведёт к голоданию пула.
Применять — только если синхронный вызов неизбежен (например, синхронный интерфейс, который нельзя поменять) и известно, что метод не требует контекста.
| Способ | Дедлок | Поток UI блокируется | Где применять |
|---|---|---|---|
await до конца |
нет | нет | везде, где можно |
ConfigureAwait(false) на каждом await в библиотеке |
нет | да, если вызывающий блокирует | библиотеки |
Task.Run(…).GetAwaiter().GetResult() |
нет | да | вынужденный sync-over-async |
.Result / .Wait() / .GetAwaiter().GetResult() напрямую |
да, при контексте | да | нигде |
Задание
В start/ найдите три способа получить на потоке UI результат без дедлока (TODO в start/Program.cs и start/Work.cs):
- изменить только
Work.WithContextAsync; - изменить только вызов в строке 13
start/Program.cs, оставив блокирующийBlock; - не блокировать вовсе.
6.4. Без контекста дедлока нет, а проблема есть¶
В ASP.NET Core, консоли, сервисах и на потоках пула SynchronizationContext нет. Продолжение выполняется на любом потоке пула, и классического дедлока, как в опыте 0, не бывает. Но блокировка .Result / .Wait() занимает поток пула на всё время ожидания. Под нагрузкой заблокированных потоков становится много, пул добавляет новые медленно, и сервис «встаёт» — голодание пула потоков (глава 13). Внешне похоже на дедлок, причина другая.
Старый ASP.NET (.NET Framework) — с контекстом AspNetSynchronizationContext. Он не привязан к одному потоку, но выполняет продолжения запроса по одному. Поэтому .Result в контроллере там даёт тот же дедлок, что и в UI.
6.5. Другие источники дедлоков с async¶
lockи синхронное ожидание. Поток держитlockи блокируется на задаче, а продолжению этой задачи (на другом потоке) нужен тот жеlock.awaitвнутриlockкомпилятор запрещает, а.Wait()— нет.- Синхронные продолжения
TaskCompletionSource(глава 5, §5.6). Вы держите блокировку и вызываетеSetResult, а чужое продолжение выполняется инлайн внутри этого вызова и ждёт ресурс, который держите вы. Лечение —RunContinuationsAsynchronously. SemaphoreSlim.Wait()вместоWaitAsync()в async-коде: блокирует поток, а при контексте может замкнуть такой же круг, как в опыте 1.- Блокирующая инициализация:
.Resultв конструкторе, статическом конструкторе,Lazy<T>, фабрике DI. Конструкторы не бываютasync, и туда «просто подставляют».Result. Решение — асинхронная фабрика или ленивая асинхронная инициализация (глава 10). async void-обработчик, который пытаются ждать. Его задачу не получить, и приходится изобретать синхронное ожидание через события. Обработчики событий оставляйтеasync void, но не ждите их из другого кода.
6.6. Итоги¶
- Дедлок sync-over-async: поток контекста блокируется на задаче, а продолжение этой задачи стоит в очереди того же контекста. Нужны оба условия: однопоточный (или «по одному») контекст и блокирующее ожидание на нём.
.Wait(),.Result,.GetAwaiter().GetResult()одинаково спят на событии и не обрабатывают очередь контекста. Различаются только видом исключения.- Таймаут на
Waitне отменяет операцию: продолжение выполнится, как только поток освободится. - Правильно —
awaitдо конца. В библиотеках —ConfigureAwait(false)на каждомawait. Он не помогает, если его нет во внутреннем методе или если он стоит на уже завершённой задаче.Task.Run— обходной путь, поток всё равно блокируется. - Без контекста (ASP.NET Core) дедлока нет, но блокировки ведут к голоданию пула.
Код лабораторной¶
Запуск из папки главы: dotnet run -c Release --project start или --project final.