Прокидывает ли async/await код в тот же поток
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Привет!
Меня зовут Владимир Вердыш. Я хотел бы поговорить про async/await в .NET. Вообще про async/await много написано. В том числе на DOU я встречал интересные статьи. Но, как по мне, asycn/await — настолько обширная тема, что разложить про него все по полочкам и уместить в одну статью — невозможно.
Даже бывает так, что какой-то один конкретный аспект async/await сложно понять по одной статье, и понимание приходит после прочтения нескольких статей, где разными буквами написано одно и то же. Может, у вас такого и не было. У меня было.
Поговорим об одном, на мой взгляд, из самых популярных вопросов, связанных с async/await: как async/await прокидывает код в тот же поток? Про это уже написано, знаю. Я напишу еще раз. Постараюсь напихать конкретики в виде ссылок на исходники.
Прокидывает ли async/await что-то куда-то?
У меня плохие новости: async/await ничего никуда не прокидывает. Хотя нет. Если точнее, то не всегда, а при стечении определенных обстоятельств. Магия происходит не в самом async/await, а в инфраструктуре приложения конкретного вида, а async/await использует эту инфраструктуру. Мы также можем её использовать для прокидывания своего кода в «тот же поток» (тут кавычки не просто так, а потому что на самом деле не в тот же, и мы про это далее поговорим).
Async/await — это не какие-то волшебные слова в языке C#, это, можно сказать, «псевдоним» для более сложного кода, который в момент компиляции создает за нас компилятор. Синтаксический сахар, если хотите. В Microsoft решили, что бестолковым .NET-чикам будет сложно это писать самим, поэтому решили облегчить нам жизнь. Может и правильно решили, не знаю.
Вообще, к синтаксису async/await у меня отношение двоякое. С одной стороны здорово, что, казалось бы, просто добавим волшебные слова и беспокоиться ни о чем не надо. И кода меньше, наши ручки меньше устанут «контрол цэкать» и «контрол вэкать». Но с другой стороны, это наглый пиз не совсем правда, и беспокоиться тебе все равно придется. И голова у тебя будет болеть от того, что ты не сможешь понять, почему приложение «висит». Как по мне, лучше бы синтаксис был менее красивый, но позволял более точно понимать, что же тут, мать его, будет происходить на самом деле.
Я, когда слышу про прокидывание в другой поток, представляю себе код вроде:
thread.Post( () => {...} );
Вроде как у нас должен быть поток, и мы можем как-то сказать «этот код надо выполнить в этом потоке». Но такой возможности нету. Или я про неё не знаю. Единственный известный мне способ, которым можно прокинуть код в другой поток, это условно сделать цикл в потоке, в который мы хотим что-то прокидывать, внутри которого будет опрашиваться какая-то очередь на предмет того, нет ли там чего выполнить. А другие потоки в эту очередь будут накидывать то, что надо выполнить этому потоку-бедолаге.
Именно это и происходит в случае приложений с UI. У них есть главный поток, который создает форму и элементы управления. Этот поток обрабатывает очередь сообщений от операционной системы и реагирует на них. Очень интересно, но ничего непонятно? Тогда вот кое-какие детали для WPF-приложений:
1. Когда надо выполнить код в главном потоке, этот код помещается в специальную очередь диспетчера:
private PriorityQueue<DispatcherOperation> _queue;
Полный исходный код можно посмотреть здесь.
2. В очередь сообщений окна добавляется специальное сообщение, которое на человеческий язык можно было бы перевести как «эй, там в специальной очереди есть код, который надо выполнить»:
bool succeeded = UnsafeNativeMethods.TryPostMessage(new HandleRef(this, _window.Value.Handle), _msgProcessQueue, IntPtr.Zero, IntPtr.Zero);
_msgProcessQueue
= «эй, там в специальной очереди есть код, который надо выполнить»;
Полный исходный код можно посмотреть здесь.
3. Приложение подписывается на сообщения окна:
_hook = new HwndWrapperHook(WndProcHook); _window.Value.AddHook(_hook);
Полный исходный код можно посмотреть здесь.
4. Вот происходит проверка, что это сообщение типа «эй, там в специальной очередь есть код, который надо бы выполнить». И если это оно, запустим некий обработчик:
else if(message == _msgProcessQueue) { ProcessQueue(); }
Полный исходный код можно посмотреть здесь.
5. Внутри этого диспетчер достает сообщение из своей очереди (про которую мы говорили в пункте 1):
op = _queue.Dequeue();
Полный исходный код можно посмотреть здесь.
6. И код оттуда выполняется:
op.Invoke();
Полный исходный код можно посмотреть здесь.
Когда, например, при нажатии на кнопку в форме, мы долго выполняем какой-то синхронный код, наше приложение становится залипшим по той причине, что поток, который обрабатывает очередь сообщений для окна, на самом деле её не обрабатывает сейчас, а трудится над нашим кодом. И операционная система, видимо, как-то понимает, что очередь сообщений окна не уменьшается, а лишь растет и считает, что приложению плохо.
В случае с приложениями Windows Forms там немного другие классы все это делают, но принцип схожий.
Все объяснения «прокидывания» async/await-ом строятся вокруг UI-приложений. Хотя может не все, откуда я знаю. Я ж наверняка все не читал. Но многое, что я читал по этой теме описывается именно вокруг WPF/Windows Forms приложений. И в них действительно выглядит так, что async/await прокидывает код в тот поток, из которого был запущен асинхронный обработчик, а если точнее, то в главный поток, т. к. все обработчики событий элементов управления стартуют в нем. Но это делает не async/await. async/await только использует эту возможность в определенных случаях.
Вот это вот все прокидывание на самом деле управляется контекстом синхронизации. SynchronizationContext по правильному. async/await не вызывает напрямую Dispatcher.Invoke(...)
или Dispatcher.BeginInvoke(...)
в WPF приложениях или что-то другое в WindowsForms
. async/await обращается к SynchronizationContext
, который в свою очередь это и делает. Тут может возникнуть вопрос, откуда контест синхронизации берется? Его устанавливает фреймворк, под который мы пишем приложение. В случае с WPF, например, вот:
_defaultDispatcherSynchronizationContext = new DispatcherSynchronizationContext(this);
Полный исходный код можно посмотреть здесь.
ConfigureAwait(false) VS ConfigureAwait(true)
Ещё хочу добавить, что когда мы что-то await-им, то мы можем к Task прицепить вызов .ConfigureAwait(false)
. Если мы так не напишем, то это будет то же самое, что мы бы написали .ConfigureAwait(true)
. В случае с true, у нас используется объект типа SynchronizationContextAwaitTaskContinuation
, в котором вызывается
c.m_syncContext.Post(s_postCallback, c.m_action);
Полный исходный код можно посмотреть здесь.
А m_syncContext
, в случае с WPF, это DispatcherSynchronizationContext
, о котором я говорил 27 секунд тому назад. А вот и метод Post в нем:
public override void Post(SendOrPostCallback d, Object state) { _dispatcher.BeginInvoke(_priority, d, state); }
Полный исходный код можно посмотреть здесь.
Все это создается тут:
internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext) { if (continueOnCapturedContext) { SynchronizationContext? syncCtx = SynchronizationContext.Current; if (syncCtx != null && syncCtx.GetType() != typeof(SynchronizationContext)) { var tc = new SynchronizationContextAwaitTaskContinuation(syncCtx, stateMachineBox.MoveNextAction, flowExecutionContext: false); if (!AddTaskContinuation(tc, addBeforeOthers: false)) { tc.Run(this, canInlineContinuationTask: false); } return; } else { TaskScheduler? scheduler = TaskScheduler.InternalCurrent; if (scheduler != null && scheduler != TaskScheduler.Default) { var tc = new TaskSchedulerAwaitTaskContinuation(scheduler, stateMachineBox.MoveNextAction, flowExecutionContext: false); if (!AddTaskContinuation(tc, addBeforeOthers: false)) { tc.Run(this, canInlineContinuationTask: false); } return; } } } if (!AddTaskContinuation(stateMachineBox, addBeforeOthers: false)) { ThreadPool.UnsafeQueueUserWorkItemInternal(stateMachineBox, preferLocal: true); } }
Полный исходный код можно посмотреть здесь.
Параметр continueOnCapturedContext
— это как раз то, что мы передали в ConfigureAwait(...)
. Если там true, то продолжение после асинхронного вызова с помощью объекта типа SynchronizationContextAwaitTaskContinuation
, который в случае с WPF обратиться за помощью к DispatcherSynchronizationContext
, прокинется в очередь сообщений окна и выполнится главным потоком. Если же там false, то ничего этого не происходит и продолжение Task запланируется в каком-то потоке из пула.
Хух, бляха. Если вы сейчас ничего не поняли, это нормально. Я сам подзакипел, пока это писал. При том, что я это все не за один присест сделал.
Пару слов отдельно про контекст синхронизации
Не во всех типах приложений есть контекст синхронизации. В Windows Forms и WPF приложениях он есть. На этом моменте можно подумать, что все понятно: мол SynchronizationContext — это штука, которая добавляет сообщение в очередь сообщений окна, которая обрабатывается бла-бла-бла и код выполняется там, где надо, то есть в главном потоке. Да. Но нет. Не всегда. В случае с UI-приложениями, это так. Кстати говоря, у Windows Forms приложений свой же отдельный SynchronizationContext: WindowsFormsSynchronization
.
Но общеизвестных SynchronizationContext
на самом деле три. Может их и больше, но я знаю три. Кроме двух, которые я указал выше, есть ещё SynchronizationContext
у старых ASP.NET приложений: AspNetSynchronizationContext
.
Сейчас будет интересно. Если в старом ASP.NET приложении вы будете использовать async/await, то продолжение асинхронной операции не обязательно будет в том же потоке, в котором эта асинхронная операция началась. Если не верите мне, сделайте такой метод в контроллере и, возможно, он убедит вас:
public async Task<string> Get() { var responseText = $"Thread Id {Thread.CurrentThread.ManagedThreadId}"; HttpClient client = new HttpClient(); var response = await client.GetAsync("https://microsoft.com/"); responseText += $"\r\nThread Id {Thread.CurrentThread.ManagedThreadId}"; HttpClient client2 = new HttpClient(); var response2 = await client2.GetAsync("https://microsoft.com/"); responseText += $"\r\nThread Id {Thread.CurrentThread.ManagedThreadId}"; return responseText; }
Результат может быть, например, таким: Thread Id 10 Thread Id 8 Thread Id 6.
И здесь мы уже не сомневаемся в том, должен ли код после await всегда выполняться в том же потоке, в котором начался. В UI-приложениях может быть важно, чтобы код выполнялся именно в основном потоке, если в продолжении вы что-то хотите сделать с элементом управления или формой. А для ASP NET MVC приложений нет. Как следствие, результат работы async/await может отличаться в принципе.
Мы можем написать свой контекст синхронизации, который будет делать то, что мы сами захотим. Я встречал примеры. Но мне самому этого делать не приходилось.
В заключение
Основной вывод, который хотелось бы сделать: async/await НЕ прокидывает код в тот же поток. В определенных случаях async/await просто использует инфраструктуру, которая для этих определенных случаев была сделана и которую мы также можем использовать из своего кода. Но лучше не использовать, а пользоваться async/await-ом. На этом пока всё. Надеюсь, кто-то что-то интересное из этих букв для себя взял.
39 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів