Это результат выбранного вами типа приложения. Консольные приложения и приложения с графическим интерфейсом ведут себя по-разному в отношении SynchronizationContext
. При использовании await
текущий SynchronizationContext
захватывается и передается в фоновый поток.
Идея состоит не в том, чтобы блокировать основной поток, просто ожидая завершения фонового потока. Оставшийся код ставится в очередь, а текущий контекст сохраняется в SynchronizationContext
, который будет захвачен фоновым потоком. Когда фоновый поток завершает работу, он возвращает захваченный SynchronizationContext
, чтобы оставшийся в коде код мог возобновить выполнение. Вы можете получить текущий контекст, обратившись к свойству SynchronizationContext.Current
. Код, ожидающий завершения await
(оставшийся код после await
), будет помещен в очередь как продолжение и выполнен на захваченном SynchronizationContext
.
Значение по умолчанию SynchronizationContext.Current
- это поток пользовательского интерфейса для приложений с графическим интерфейсом, таких как WPF или NULL, для консольных приложений. Консольные приложения не имеют SynchronizationContext
, поэтому для использования async
фреймворк использует ThreadPool
SynchronizationContext
. Правила поведения SynchronizationContext
таковы:
- Если
SynchronizationContext.Current
возвращает NULL,
продолжение потока будет по умолчанию потоком пула потоков
- Если
SynchronizationContext.Current
не равен NULL, продолжение
будет выполнен в захваченном контексте.
- И: если
await
используется в фоновом потоке (следовательно, новый
фоновый поток запускается из фонового потока), затем
SynchronizationContext
всегда будет потоком пула потоков.
Сценарий 1, консольное приложение:
Применяется правило 1): поток 1 вызывает await
, который попытается захватить текущий контекст. await
будет использовать фоновый поток поток 3 из ThreadPool
для выполнения асинхронного делегата.
После завершения делегата оставшийся код вызывающего потока будет выполнен в захваченном контексте. Поскольку в консольных приложениях этот контекст равен NULL, по умолчанию вступит в силу SynchronizationContext
(первое правило). Поэтому планировщик решает продолжить выполнение в потоке ThreadPool
, потоке , потоке 3 (для эффективности. Переключения контекста дороги).
Сценарий 2, приложение с графическим интерфейсом:
применяется правило 2): поток 1 вызывает await
, который попытается захватить текущий контекст (UI SynchronizationContext
). await
будет использовать фоновый поток поток 3 из ThreadPool
для выполнения асинхронного делегата.
После завершения делегата оставшийся код вызывающего потока будет выполнен в захваченном контексте, UI SynchronizationContext
thread 1 .
Сценарий 3, приложение с графическим интерфейсом и Task.ContinueWith
:
применяется правило 2) и правило 3): поток 1 вызывает await
, который будет пытаться захватить текущий контекст (UI SynchronizationContext
). await
будет использовать фоновый поток поток 3 из ThreadPool
для выполнения асинхронного делегата. После завершения делегата продолжение TaskContinueWith
. Так как мы все еще находимся в фоновом потоке, новый TreadPool
поток поток 4 используется с захваченным SynchronizationContext
из потоком 3 . После завершения продолжения контекст возвращается к потоку 3 , который будет выполнять оставшийся код вызывающего абонента в захваченном SynchronizationContext
, который является потоком пользовательского интерфейса потоком 1 .
Сценарий 4, приложение с графическим интерфейсом и Task.ConfigureAwait(false)
(await DoWorkAsync().ConfigureAwait(false);
):
применяется правило 1): поток 1 вызывает await
и выполняет асинхронный делегат в ThreadPool
фоновом потоке поток 3 . Но поскольку задача была настроена с Task.ConfigureAwait(false)
, поток 3 не захватывает SynchronizationContext
вызывающего (UI SynchronizationContext
). Поэтому свойство SynchronizationContext.Current
потока 3 будет иметь значение NULL, и по умолчанию применяется SynchronizationContext
: контекст будет представлять собой поток ThreadPool
. Из-за оптимизации производительности (переключение контекста дорого) контекст будет текущим SynchronizationContext
из потока 3 . Это означает, что после завершения thread 3 оставшийся код вызывающего абонента будет выполняться по умолчанию SynchronizationContext
thread 3 . Значение по умолчанию Task.ConfigureAwait
равно true
, что позволяет захватывать вызывающего абонента SynchronizationContext
.
Сценарий 5, приложение с графическим интерфейсом и Task.Wait
, Task.Result
или Task.GetAwaiter.GetResult
:
правило 2 применяется, но приложение будет в тупике. Текущий SynchronizationContext
из резьбы 1 фиксируется. Но поскольку асинхронный делегат выполняется синхронно (Task.Wait
, Task.Result
или Task.GetAwaiter.GetResult
превратит асинхронную операцию в синхронное выполнение делегата), поток 1 будет блокироваться до тех пор, пока не завершится теперь синхронный делегат .
Поскольку код выполняется синхронно, оставшийся код потока 1 не был поставлен в очередь как продолжение потока 3 и поэтому будет выполняться в потоке 1 после завершения делегата. Теперь, когда делегат в потоке 3 завершает работу, он не может вернуть SynchronizationContext
из нити 1 в нити 1 , поскольку нити 1 является все еще блокирует (и таким образом блокирует SynchronizationContext
). Поток 3 будет бесконечно ждать потока 1 , чтобы снять блокировку на SynchronizationContext
, что, в свою очередь, заставляет поток 1 бесконечно ждать потока 3 вернуть -> тупик.
Сценарий 6, консольное приложение и Task.Wait
, Task.Result
или Task.GetAwaiter.GetResult
:
правило 1 применяется. Текущий SynchronizationContext
из резьбы 1 фиксируется. Но поскольку это консольное приложение, контекст имеет значение NULL и применяется значение по умолчанию SynchronizationContext
. Асинхронный делегат выполняется синхронно (Task.Wait
, Task.Result
или Task.GetAwaiter.GetResult
превратит асинхронную операцию в синхронную операцию) в ThreadPool
фоновом потоке , потоке 3 и , потоке 1 будет блокироваться, пока делегат в потоке 3 не завершится. Поскольку код выполняется синхронно, оставшийся код не был поставлен в очередь как продолжение потока 3 и поэтому будет выполняться в потоке 1 после завершения делегата. Нет ситуации взаимоблокировки в случае применения консоли, поскольку SynchronizationContext
из потока 1 был равен NULL и потока 3 должен использовать контекст по умолчанию.
Код вашего примера соответствует сценарию 1. Это потому, что вы работаете с консольным приложением и применяется SynchronizationContext
по умолчанию, которое применяется, потому что SynchronizationContext
консольных приложений всегда равно NULL. Когда захваченный SynchronizationContext
равен NULL, Task
использует контекст по умолчанию, который является потоком ThreadPool
. Поскольку асинхронный делегат уже выполнен в потоке ThreadPool
, TaskScheduler
решает остаться в этом потоке и, следовательно, выполнить оставшийся в очереди оставшийся код потока вызывающего потока 1 в потока 3 .
В приложениях с графическим интерфейсом рекомендуется всегда использовать Task.ConfigureAwait(false)
везде, кроме случаев, когда вы явно хотите захватить SynchronizationContext
вызывающей стороны. Это предотвратит случайные блокировки в вашем приложении.