Прокидывает ли 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-ом. На этом пока всё. Надеюсь, кто-то что-то интересное из этих букв для себя взял.

👍НравитсяПонравилось8
В избранноеВ избранном2
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Я бы сказал так. Вас вообще не должно волновать, в какой поток возвращается await. Почти никогда. За вас всё делает инфраструктура, и делает это правильно. Просто не занимайте этим голову, если пишете обычный бизнес-код. И перестаньте везде лепить ConfigureAwait(false).

Исключение — случаи, когда вы используете какие-то thread local переменные, или всякие manual reset event-ы, мониторы, семафоры и прочие низкоуровневые вещи. Вот тогда поток важен. Но тогда сразу возникает вопрос — почему бы вам не переписать ваш странный код с использованием современных средств TPL (task chains, continuations, concurrent collections etc)? Большинство нужных вам кейсов можно реализовать, не выходя за рамки их использования, вообще не углубляясь на более низкий уровень.

Всё это не отменяет того факта, что знать вещи, описанные в посте, всё равно полезно для общего развития.

якось не вистачає порівнянь з .Net Core або хоч пару слів що в .Net Core (або вже як правильно говорити) в .Net 5+ все не так і ця стаття стосується тільки .Net Framework до 4.8

Вы не правы. async/await работает точно так же и в .NET 5.

Вы будете удивлены, но в .NET 5 мы можем делать WPF приложения. И Windows Forms, наверное. И все это там работает так же, как и до .NET 5. И, как я писал в статье, важен фреймворк под который мы пишем приложение.

Насчет .NET Core-а я понял о чем вы хотели сказать, но с вашего позволения попробую это сформулировать более правильно. Вы правы, что в .net core это все не так. Но «все не так» это не означает, что все не так. Все как раз таки так, но есть нюанс. Принцип работы именно async/await там точно такой же. Разница в инфраструктуре .net core-овских приложений, которая ничего особо не делает для прокидывания. А инфраструктура там такая потому, что .net core приложения сделаны «не чувствительными» к вот этим потоковым вещам.

Возможно, когда-то в будущем, когда выйдет .NET 22 и мы избавимся от всех приложений предыдущих поколений, нас это совсем не будет беспокоить. Но сейчас, на мой взгляд, понимать как это работает, лишним не будет.

в .net core это все не так. Но «все не так» это не означает, что все не так. Все как раз таки так, но есть нюанс

обережно! ще трішечки і можно призвти Кличко :)

в .Net Core і вже в .Net 5+ не має

SynchronizationContext.Current

тому не має сенсу писати

ConfigureAwait(false) VS ConfigureAwait(true)

Можна писати в netstandart якщо цю бубліотеку будуть використовувати і під .Net Core/.Net 5 та .Net Framework

в .Net Core не має SynchronizationContext.Current

Правильный ответ.

в .Net 5+ не має SynchronizationContext.Current

Неправильный ответ.

Нате Вам для .NET 6 docs.microsoft.com/...​text.current?view=net-6.0 . Не понимаю, о чём спор.

Я так понял, что вы дальше 1 строки

Вы не правы. async/await работает точно так же и в .NET 5.

не читали. Давайте может вы дочитаете до конца. Ну хотя бы следующий абзац. Маякните, когда дочитаете и продолжим, если вопросы ещё останутся.

тому не має сенсу писати

Для десктопа, к примеру, всё осталось на высоком уровне так же, и там писать есть смысл.

Ребята, убрал первый абзац. Надеюсь, пожар в коментах удалось ликвидировать без жертв. Я искренне не понимаю, почему некоторые комментаторы восприняли это как «провокацию». Цель была противоположная. Надеюсь, сейчас конфликт исчерпан.

Вот пилять, самую мякотку пропустил — целый день насмарку :)

так пиши: «а почему на русском? и так поймут типа?»

Не думал что скажу это, но на доу не хватает какой-то премодерации, хотя бы чтобы не пропускать абзацы, подобные первому в этой статье, которые как раз и триггерят срач.

Без срача будет мертвый ресурс, посмотри на темы с премодерацией, там в лучшем случае 100 комментов из которых половина ответ автора на каждое сообщение в стиле «понял, спасибо»

Радует видеть углубленный подход со ссылками на исходники. Вот только мне кажется с ASP.NET и AspNetSynchronizationContext тема не раскрыта.
Я хорошо помню следующую проблему в ASP.Net (не Core):
blog.stephencleary.com/...​-block-on-async-code.html
То есть для ASP.Net ИМЕЕТ значение куда возвращается после await. Я думаю это связанно с тем, что все данные доступные из HttpContext.Current как статические проперти — на самом деле хранятся в ThreadContext! Как и идентити текущего пользователя. Так что возврат в другой поток приведет к потере контекста.
В .Net Core они решили эту проблему просто выбросив HttpContext.Current !
Но даже если не брать ASP.Net то ThreadContext может использоваться для многих полезных вещей. От хранения идентити текущего пользователя, его культуры и т.д. до ThreadStatic полей и повторного использования объектов в Dependency Injection контейнерах. Так что контекст синхронизации не всегда пофиг (хотя я видел рекомендации всегда ставить .ConfigureAwait(false))!

Спасибо за комент!

для ASP.Net ИМЕЕТ значение куда возвращается после await.

На мой взгляд не совсем так. Там важно не куда возвращаемся, а как. В ASP.NET нам важен ExecutionContext потока, который может прокидываться из потока в поток. В случае же с UI приложениями может быть важно именно то, что бы код выполнился именно в конкретном потоке.

То есть для ASP.Net ИМЕЕТ значение куда возвращается после await. Я думаю это связанно с тем, что все данные доступные из HttpContext.Current как статические проперти — на самом деле хранятся в ThreadContext! Как и идентити текущего пользователя. Так что возврат в другой поток приведет к потере контекста.

Не хранятся. Но если шедулить continuation вне aspnetsynccontext то этот стейт доступен не будет.

о даже если не брать ASP.Net то ThreadContext может использоваться для многих полезных вещей. От хранения идентити текущего пользователя, его культуры и т.д. до ThreadStatic полей и повторного использования объектов в Dependency Injection контейнерах.

Не может — в asp.net асинхронные .net api и с той конфигурацией контекстов, что есть по дефолту всегда юзают тред пулл, юзать threadcontext в такой модели программирования вообще не имеет никакого смысла. Для этого есть asynclocal (callcontext).

А понять о каком ЯП статья? Нужно смотреть картиночки и искать глазами должность автора, по-другому никак.

А вступ — це типу такий завуальований закид? Мовляв «...ну ви вєдь панімаітє — еті біндеравци за русскій язик расстрєлівают...». Шановний, у нас вільна країна і це багатомовний ресурс — пишіть як вам удобно. Проблеми мови вигадані і навязані

Вступлением я попытался уберечь от возгорания жопы некоторых читателей. Типа как табличка на трансформаторе, которая предупреждает о возможных последствиях. Просто иногда я на ДОУ наблюдаю, как они (некоторые жопы) начинают дымиться под русскоязычным материалом. Не знаю в чем причина, но такое мной было замечено несколько раз.

ну так бы там есть два типа операций, первая IO bound и там управление ставит на «паузу» ожидание завершение работы внешнего источника и CPU bound, а вот тут уже стартует новый поток

docs.microsoft.com/...​t/standard/async-in-depth

Для меня разница между IO bound и CPU bound операциями заключается в том, в потоке какого пула будет выполняться продолжение: из IO пула или CPU пула. Действительно асинхронная по своей природе операция после await-а будет выполняться в потоке IO пула, в то время, как «псевдо асинхронная» операция типа Task.Delay() будет выполняться в потоке CPU пула.

псевдо не всегда псевдо если есть несколько ядер

На мой взгляд, псевдо это таки всегда псевдо, независимо ни от чего. Мне кажется, что вы немного перепутали асинхронность и многопоточность. Или же я что-то путаю.

Лучше почитать подробней о Контекстах Синхронизации и всей подноготной здеся

devblogs.microsoft.com/...​otnet/configureawait-faq

треба було відразу написати що це не про js)

Добавил, спасибо!

Та навiть не про Java :)

Спасибо, отличная статья! А вот вступление лишнее — как будто вы перед кем-то за что-то оправдываетесь.

Набегут же «чому не украйинською», все равно придется оправдываться :)

А оправдываться зачем? Оправдываются, когда что-то плохое сделали, или когда виноваты в чем-то. А когда человек написал отличную статью на родном языке, который к тому же является одним из двух основных языков, используемых в стране — оправдываться не за что. Я понимаю, если бы материал был на английском. Но подобные дисклеймеры для русского языка — это как-то даже неуместно.

Оправдываются, когда что-то плохое сделали, или когда виноваты в чем-то.

многие разговаривают в таком стиле что выглядит как оправдание, как, например, данный случай

Явна провокація, ніхто ніколи не набігає. Вашу маніпуляцію легко спростувати почитавши коментарі під російськомовними статтями.

Поддержу вас. Кому интересен материал — прочитает статью. Кто захочет похоливарить — всегда найдет для этого повод. По этой причине подобные дисклеймеры и не имеют смысла. Они не остановят тех, кому нужен холивар и не нужны для всех остальных. Всегда есть смысл сосредотачиваться на том, что объединяет, а не на том, что разъединяет людей.

Ну конечно...
dou.ua/...​rums/topic/35272/#2267343
Подобных ответов у меня в профиле можешь найти огромное количество, а рядом с ними и чувака, который хочет украинскую локализацию

Сокращу до смысла: Нет. И нет гарантии, что кинет в другой. В этом магия: спрятать под капот всю реализацию. Если вам нужно что-то своё, можете написать сами, можете лезть под капот с целью чего-то поменять (и разумеется, сбегать по граблям).

Но вот ещё скрытый смысл: эта магия достаточно нагруженная, и злоупотреблять ей не следует. Самый быстрый код — это написанный линейно, без ветвлений, без каких-то синхронизаций, без диспетчеров и подписок. А просто взял и сделал. Вам нужен асинхрон только в одном случае — когда вы зависите от другого ресурса, а он либо асинхронный, либо блокируемый (что по сути одно и то же). Либо же вы сами являетесь поставщиком данных для асинхронного ресурса, но даже тогда лучше 100 раз подумать, насколько работа вашего кода может быть чем-то прервана, и если ничем — то нефиг и диспетчеризацию плодить, и не подохнет ресурс если выждет лишние наносекунды. Он будет ждать гораздо дольше танцев с обслуживаемыми очередями.

Подписаться на комментарии