Как работает async/await
Внутренности async/await хорошо описаны Алексом Дэвисом в его книге, так что я только вкратце опишу их здесь. Рассмотрим следующий пример кода:
public async Task ReadFirstBytesAsync(string filePath1, string filePath2)
{
using (FileStream fs1 = new FileStream(filePath1, FileMode.Open))
using (FileStream fs2 = new FileStream(filePath2, FileMode.Open))
{
await fs1.ReadAsync(new byte[1], 0, 1); // 1
await fs2.ReadAsync(new byte[1], 0, 1); // 2
}
}
Эта функция читает по одному первому байту из двух файлов, пути к которым переданы через параметры. Что произойдет в строках “1” и “2”? Будут ли они выполнены параллельно? Нет. Эта функция будет «разбита» ключевым словом «await» на три части: часть, предшествующая «1», часть между «1» и «2» и часть, следующая за «2».
Функция запустит новый I/O bound поток в строке «1», передаст ему вторую часть себя же (ту часть, которая между «1» и «2») в качестве callback-а и возвратит управление. После того как I/O поток завершит работу, будет вызван callback, и метод продолжит выполнение. Метод создаст еще один I/O поток в строке «2», передаст ему третью часть себя в качестве callback-а и опять возвратит управление. После того как второй I/O поток завершит выполнение, будет запущена остальная часть метода.
Магия здесь присутствует благодаря компилятору, который преобразует методы, помеченные ключевым словом «async» в конечный автомат, по аналогии с тем, как он преобразует методы-итераторы.
Когда использовать async/await?
Существуют два основных сценария, в которых использование async/await предпочтительно.
В первую очередь, эта фича может быть использована в толстых клиентах для предоставления пользователям лучшего user experience. Когда пользователь нажимает на кнопку, стартуя тяжелую вычислительную операцию, наилучшим выходом будет выполнить эту операцию асинхронно, без блокировки UI потока. До .NET 4.5 подобная логика требовала гораздо больших усилий. Теперь ее можно запрограммировать примерно так:
private async void btnRead_Click(object sender, EventArgs e)
{
btnRead.Enabled = false;
using (FileStream fs = new FileStream(“File path”, FileMode.Open))
using (StreamReader sr = new StreamReader(fs))
{
Content = await sr.ReadToEndAsync();
}
btnRead.Enabled = true;
}
Обратите внимание, что флаг Enabled в обоих случаях устанавливается UI-потоком. Этот подход устраняет необходимость написания такого некрасивого кода:
if (btnRead.InvokeRequired)
{
btnRead.Invoke((Action)(() => btnRead.Enabled = false));
}
else
{
btnRead.Enabled = false;
}
Другими словами, весь «легкий» код выполняется вызывающим потоком, в то время как «тяжелые» части делегируются отдельному потоку (I/O или CPU-bound). Такой подход позволяет существенно сократить количество усилий, необходимых для синхронизации доступа к UI элементам, т.к. управление ими происходит только из UI потока.
Во-вторых, async/await может быть использован в веб-приложениях для лучшей утилизации потоков. Команда ASP.NET MVC сделала асинхронные контроллеры очень простыми в имплементации. Вы можете просто написать action-метод как на примере ниже и ASP.NET сделает всю остальную работу:
public class HomeController : Controller
{
public async Task<string> Index()
{
using (FileStream fs = new FileStream(“File path”, FileMode.Open))
using (StreamReader sr = new StreamReader(fs))
{
return await sr.ReadToEndAsync(); // 1
}
}
}
В этом примере рабочий поток, выполняющий метод, стартует новый I/O поток на строке «1» и возвращается в пул потоков (thread pool). После того как I/O поток завершает работу, CLR выбирает новый поток из пула и тот продолжает выполнение метода. Таким образом, CPU-bound потоки из пула потоков используются намного более экономно.
Async/await в C#: подводные камни
Если вы разрабатываете стороннюю библиотеку, очень важно всегда настраивать await таким образом, чтобы остальная часть метода была выполнена произвольным потоком из пула. Другими словами, в коде сторонних библиотек всегда необходимо добавлять ConfigureAwait(false).
В первую очередь, сторонние библиотеки обычно не работают с UI контролами (если конечно это не UI библиотека), поэтому нет никакой необходимости связывать UI поток. Вы можете немного увеличить производительность если позволите CLR выполнять ваш код любым потоком из пула. Во-вторых, используя дефолтную имплементацию (или явно проставляя ConfigureAwait(true)), вы оставляете потенциальную дыру для дедлоков. Рассмотрим следующий пример:
private async void button1_Click(object sender, EventArgs e)
{
int result = DoSomeWorkAsync().Result; // 1
}
private async Task<int> DoSomeWorkAsync()
{
await Task.Delay(100).ConfigureAwait(true); //2
return 1;
}
Клик по кнопке здесь приводит к дедлоку. UI поток стартует новый I/O поток на строке «2» и уходит в спящий режим на строке «1», ожидая завершения работы. После того как I/O поток заканчивает выполнение, оставшаяся часть метода DoSomeWorkAsync передается на выполнение вызывающему (UI) потоку. Но тот в это время находится в спящем режиме, ожидая завершения метода. Дедлок.
ASP.NET ведет себя таким же образом. Несмотря на то, что в ASP.NET нет выделенного UI потока, код в action-ах котроллеров не может выполняться более чем одним рабочим потоком одновременно.
Конечно, мы можем использовать await вместо обращения к свойству Result для того, чтобы избежать дедлока:
private async void button1_Click(object sender, EventArgs e)
{
int result = await DoSomeWorkAsync();
}
private async Task<int> DoSomeWorkAsync()
{
await Task.Delay(100).ConfigureAwait(true);
return 1;
}
Но в .NET все равно существует как минимум один кейс, в котором у вас не получится обойти дедлок. Вы не можете использовать асинхронные методы внутри child action-ов ASP.NET MVC, т.к. они не поддерживаются. Таким образом, вам придется обращаться к свойству Result напрямую и если асинхронный метод, который вызывается вашим контроллером, не сконфигурирован правильно, вы получите дедлок. К примеру, если вы попытаетесь написать следующий код и SomeAction обращается к свойству Result асинхронного метода, который не был сконфигурирован через ConfigureAwait(false), вы опять же получите дедлок:
@Html.Action(“SomeAction“, “SomeController“)
Пользователи ваших библиотек как правило не имеют прямого доступа к коду этих библиотек, поэтому всегда заблаговременно проставляйте ConfigureAwait(false) в ваших асинхронных методах.
Как не нужно использовать PLINQ и async/await
Рассмотрим пример:
private async void button1_Click(object sender, EventArgs e)
{
btnRead.Enabled = false;
string content = await ReadFileAsync();
btnRead.Enabled = true;
}
private Task<string> ReadFileAsync()
{
return Task.Run(() => // 1
{
using (FileStream fs = new FileStream(“File path”, FileMode.Open))
using (StreamReader sr = new StreamReader(fs))
{
return sr.ReadToEnd(); // 2
}
});
}
Выполняется ли этот код асинхронно? Да. Является ли этот код корректным примером написания асинхронного кода? Нет. UI поток здесь стартует новый CPU-bound поток на строке «1» и возвращает управление. Этот поток затем стартует новый I/O поток на строке «2» и переходит в спящий режим, ожидая выполнения.
Что происходит здесь? Вместо того, чтобы создать единственный I/O поток, мы создаем и CPU поток на строке «1», и I/O поток на строке «2». Это пустая трата потоков. Чтобы исправить ситуацию, нам нужно использовать асинхронную версию метода Read:
private Task<string> ReadFileAsync()
{
using (FileStream fs = new FileStream(“File path”, FileMode.Open))
using (StreamReader sr = new StreamReader(fs))
{
return sr.ReadToEndAsync();
}
}
Еще один пример:
public void SendRequests()
{
_urls.AsParallel().ForAll(url =>
{
var httpClient = new HttpClient();
httpClient.PostAsync(url, new StringContent(“Some data”));
});
}
Выглядит так, будто мы отправляем запросы параллельно, не так ли? Да, это так, но здесь мы имеем ту же проблему, что в предыдущем примере: вместо того, чтобы создать единственный I/O поток, мы создаем и I/O, и CPU-bound поток для каждого запроса. Исправить ситуацию можно используя метод Task.WaitAll:
public void SendRequests()
{
IEnumerable<Task> tasks = _urls.Select(url =>
{
var httpClient = new HttpClient();
return httpClient.PostAsync(url, new StringContent(“Some data”));
});
Task.WaitAll(tasks.ToArray());
}
Всегда ли необходимо выполнять I/O операции без связывания CPU-bound потоков?
Зависит от ситуации. В некоторых случаях это невозможно, в некоторых привносит слишком много сложности в код. К примеру, в NHibernate нет возможностей по асинхронной загрузке данных из БД. С другой стороны, в EntityFramework она есть, но использование ее не всегда имеет смысл.
Также, толстые клиенты (например, WPF или WinForms приложения) обычно не имеют больших нагрузок, так что для них подобная оптимизация по большей части не обязательна. Но в любом случае, необходимо знать что происходит «под капотом» этой фичи, чтобы иметь возможность принять сознательное решение в каждом конкретном случае.
Ссылка на оригинал статьи: Async/await in C#: pitfalls
Комментарии (44)
a553
04.05.2015 15:27-6Продолжение цикла «вредные советы» на хабре.
Если вы разрабатываете стороннюю библиотеку, очень важно всегда настраивать await таким образом, чтобы остальная часть метода была выполнена произвольным потоком из пула.
Вот только это заставит вас писать тот самый некрасивый код, который вы так хотели избежать. Браво! А проблема всего лишь в том, что не надо вызыватьget_Result
вручную, блокируя поток.
Когда какой-то библиотеке надо вернуться в оригинальный контекст, то на это скорее всего есть веская причина — например, библиотека хочет вызвать колбек. Если же такой причины нет, то запрещать ей возвращаться в оригинальный поток надо не средствамиConfigureAwait
, а черезTask.Run
.
… использовать асинхронную версию метода Read:
Вот только конструкторFileStream
блокирующий, и вы блокируете UI поток. Метод, который вы отмели как «некорректный», гораздо корректнее вашего.Saladin
04.05.2015 15:48+2Если же такой причины нет, то запрещать ей возвращаться в оригинальный поток надо не средствами ConfigureAwait, а через Task.Run.
Из вашего объяснения я не понял, почему следует использоватьTask.Run
вместоConfigureAwait
. Не могли бы вы пояснить этот момент более подробно?
vkhorikov Автор
04.05.2015 15:53+2Вот только это заставит вас писать тот самый некрасивый код, который вы так хотели избежать. Браво!
Вы не правы. Запустите этот код и посмотрите чему равна переменная areEqual:
private async void btnRead_Click(object sender, EventArgs e) { Context currentContext1 = Thread.CurrentContext; int result = await DoSomeWorkAsync(); Context currentContext2 = Thread.CurrentContext; bool areEqual = ReferenceEquals(currentContext1, currentContext2); } private async Task<int> DoSomeWorkAsync() { await Task.Delay(100).ConfigureAwait(false); return 1; }
ConfigureAwait(false) в методах, вызываемых вшешним методом не приводят к продолжению выполнения внешнего метода (в этом примере — btnRead_Click) в произвольном потоке, это указывает, как выполнять только внутренний метод (DoSomeWorkAsync). Это происходит потому, что каждый метод, помеченный async, разворачивается в state machine независимо от тех, которые он вызывает.a553
04.05.2015 16:16-3Вы ранее писали:
… в коде сторонних библиотек всегда необходимо добавлять ConfigureAwait(false).
Отсюда я делаю вывод, чтоDoSomeWorkAsync
— это код библиотеки. Если ей нужно зачем-то переключать контекст, то это происходит, скорее всего, для вызова колбека, в котором вам придется использовать ручное переключение контекста, если библиотека делает такую гадость, какConfigureAwait(false)
. Если же библиотеке не нужно переключать контекст, то её код не является асинхронным, и помечать его асинхронным не требуется, а для последующего исполнения в другом потоке можно и нужно использовать, например,Task.Run
.vkhorikov Автор
04.05.2015 17:06+4ConfigureAwait(false) во внутреннем методе не оказывает влияния на контекст выполнения во внешнем методе.
private async Task F1() { // 1. Контекст тот же, что у вызывающего метода await F2(); // 4. Контекст тот же, что у вызывающего метода, // ConfigureAwait(false) в F1 не повлиял на смену контекста в этом методе } private async Task F2() { // 2. Контекст тот же, что у вызывающего метода await Task.Delay(100).ConfigureAwait(false); // 3. Нет контекста. Поток, выполняющий эту часть метода - произвольный из пула }
Проставив ConfigureAwait(false), автор библиотеки не сделает никакой гадости, он таким образом повлияет только на контекст выполнения оставшейся части метода (пункт 3).
Использование Task.Run приводит к неоптимальной утилизации потоков, что для сторонней библиотеки — серьезное дело, если автор рассчитывает на то, что она будет использована в высоконагруженных проектах
vkhorikov Автор
04.05.2015 15:57+1А проблема всего лишь в том, что не надо вызывать get_Result вручную, блокируя поток.
Как я написал в статье, не всегда возможно использовать асинхронные методы. К примеру child actions в ASP.NET MVC их не поддерживают. Также, если вы работаете с legacy ASP.NET WebForms, то там их использовать также будет проблематично. Все это приводит к тому, что библиотеки необходимо писать с учетом того, что клиентский код будет обращаться к Result проперти, а не работать через await
Когда какой-то библиотеке надо вернуться в оригинальный контекст, то на это скорее всего есть веская причина — например, библиотека хочет вызвать колбек. Если же такой причины нет, то запрещать ей возвращаться в оригинальный поток надо не средствами ConfigureAwait, а через Task.Run.
Внутренний метод не может определять каким потоком будет выполнена оставшаяся часть внешнего метода (см. комментарий выше)a553
04.05.2015 16:22Когда вам требуется вызвать асинхронный код из синхронного, то это ваша обязанность разруливать переключения контекста, а не самого асинхронного кода. Например, вы можете его вызвать в отдельном потоке, а затем дождаться его. Например, через
Task.Run
, хотя я не уверен, что это сработает в ASP.NET.int19h
05.05.2015 05:42>> Например, вы можете его вызвать в отдельном потоке, а затем дождаться его.
И что, вы думаете, это как-то поможет с описанным в статье дедлоком?a553
05.05.2015 06:02Разумеется.
void button1_Click(object sender, EventArgs e) { object result = Task.Run(async () => await DoSomeWorkAsync()).Result; } async Task<object> DoSomeWorkAsync() { await Task.Delay(100); return null; }
int19h
05.05.2015 06:07+1Отлично, т.е. у вас вызывающий код должен быть в курсе деталей реализации DoSomeWorkAsync, чтобы знать, нужно его оборачивать в Task.Run или нет.
Или вы всерьез предлагаете вообще все таски так оборачивать «на всякий случай»?
Не лучше ли делать так, как рекомендуют авторы всего этого дела, и по возможности писать контексто-независимый асинхронный код (т.е. — с ConfigureAwait), особенно в библиотеках, где его планируется повторно использовать?a553
05.05.2015 06:12Вы не внимательно читали топик. Это workaround на случай вызова асинхронного кода из синхронного.
int19h
05.05.2015 06:15+1Если сразу писать код с ConfigureAwait, то ваш воркараунд не нужен. Причем профит этим не ограничивается — не будет ненужных переходов на главный поток, вне зависимости от контекста вызова, и не будет разницы в поведении из-за неизвестного начального шедулера.
Nova_M5
05.05.2015 09:53+1Просто оборачивая синхронный код в Task.Run(), а ещё хуже в Task.Factory.StartNew(), в ASP.NET мы не получим ожидаемых результатов. Эти методы только выглядят асинхронно. Такой код всё равно будет блокировать рабочий процесс в пуле на время выполнения задачи. При этом, мы получаем проблемы с SynchronizationContext. Правильный подход заключается в вызове асинхронных методов и использовании async/await.
Task.Run() полезно использовать для выполнения CPU-нагруженных задач в асинхронном режиме для UI-приложений, но не в ASP.NET.
В .NET 4.5.2 появился новый метод QueueBackgroundWorkItem, который позволяет надежно планировать и выполнять фоновые процессы в ASP.NET в случае необходимости.
a553
04.05.2015 18:13-5Судя по минусам, у аудиотории существуют сомнения в моей правоте. Поэтому вот падающий код с подходом автора топика:
async void button1_Click(object sender, EventArgs e) { await DoSomeWorkAsync(ReportProgress); } void ReportProgress() { if (this.InvokeRequired) throw new Exception(); } async Task DoSomeWorkAsync(Action progressReporter) { progressReporter(); await Task.Delay(100).ConfigureAwait(false); progressReporter(); }
Исправляется, как я и сказал, тем самым некрасивым кодом из до-async эры.withkittens
04.05.2015 18:44+8А всего-то нужно не изобретать чёрти что, а использовать рекомендуемый паттерн с IProgress(Of T):
async void button1_Click(object sender, EventArgs e) { var progressReporter = new Progress<object>(ReportProgress); await DoSomeWorkAsync(progressReporter); } void ReportProgress(object value) { if (this.InvokeRequired) throw new Exception(); } async Task DoSomeWorkAsync(IProgress<object> progressReporter) { progressReporter.Report(null); await Task.Delay(100).ConfigureAwait(false); progressReporter.Report(null); }
Progress(Of T) позаботится о том, чтобы захватить требуемый контекст.a553
04.05.2015 18:50-2И сразу же наткнуться на то, что он вешает UI поток при частых вызовах, потому что не ждет окончания исполнения. А поскольку вы передаёте его библиотеке, вам придется позаботиться об этом, используя ещё менее красивый код, чем был раньше.
Наступал я на такие грабли, да.withkittens
04.05.2015 19:25+3вешает UI поток при частых вызовах
Ограничьте количество репортов или не пихайте в обработчик репортов тяжеловесный код, способный повесить поток?a553
04.05.2015 19:29-2И всё ради того, чтобы добиться возможности вызывать
get_Result
. Вместо того, чтобы решить эту проблему на стороне клиента, где она и должна решаться, например, черезTask.Run
(30 символов).withkittens
04.05.2015 19:40+1Проблема в самом вызове
get_Result
. Если вы мешаете асинхронный и синхронный код, то вы ССЗБ и должны быть готовы к появлению проблем. К повсеместному использованию.ConfigureAwait(false)
, как мне кажется, это отношения не имеет.a553
04.05.2015 19:42Автору топика это скажите :)
withkittens
04.05.2015 19:54Мне хочется надеяться, автор и так понимает проблемы смешивания sync/async. Лично я бы прикопался к формулировке:
Конечно, мы можем использовать await вместо обращения к свойству Result для того, чтобы избежать дедлока
— и написал бы, что нам нужно использоватьawait
вместоget_Result
. За исключением случаев, где это невозможно, но, мол, тогда смотрите сами.vkhorikov Автор
04.05.2015 20:46+3Само собой, там где это возможно, следует использовать await. Проблема в том, что это не везде возможно и вы, как автор библиотеки, не можете знать где она будет использоваться.
Плюс при проектировании библиотек всегда следует придерживаться принципа наименьшего удивления, это означает, что ваш код не должен требовать workaround-ов (таких как вызов Task.Run) для корректного выполнения.
crea7or
04.05.2015 16:49+3Главное с этим async/await не переборщить. Вот под Windows Store библиотека Azure вся асинхронная и насколько же сложнее с ней работать. Я уже все маты сложил, так приемлемой работы и не добился.
Krey
07.05.2015 15:12Да там все WinRT API по большей части асинхронное без синхронных вариантов. Особенно пикантно это выглядит, когда система вызывает ивенты в UI потоке, а в обработчике тебе нужно дергать асинхронные методы АПИ, что характерно тоже работающие в UI потоке, потому что все запихано в DependencyObject.
Как пример вот мой подводныйкаменьайсберг:
social.msdn.microsoft.com/Forums/ru-RU/5e97ef50-884a-4e79-8432-01ce31e245c0/how-use-paginate-event-async-problem?forum=winappswithcsharp
olen
04.05.2015 17:17А в примере с btnRead_Click не будет проблем из-за обращения из рабочего потока к кнопке (btnRead.Enabled = false)?
a553
04.05.2015 17:23-5Нет, потому что произойдет переключение на оригинальный UI поток через его контекст синхронизации. Но следуя советам автора статьи вы вполне на эти проблемы можете нарваться.
vkhorikov Автор
04.05.2015 17:25+3Ну хватит уже :) Я в двух комментариях попытался расписать, что проблем не будет до тех пор, пока сам метод btnRead_Click не проставит ConfigureAwait(false). Любые другие методы, которые он вызывает могут проставлять ConfigureAwait(false), это не повлияет на то, что доступ к UI элементам в btnRead_Click произойдет из UI потока.
vkhorikov Автор
04.05.2015 17:23+2В примере с btnRead_Click весь обращения к UI элементам происходят из UI потока. В этом как раз самое большое преимущество фичи async/await
Wyrd
04.05.2015 17:48Для справки. Асинхронные вызовы не всегда приводят к созданию потоков: habrahabr.ru/post/216659
Ordos
04.05.2015 20:36+3Давно интересует такой вопрос, часто вижу код вроде этого:
public async Task<SomeResult> FooAsync() { return await BarAsync(); }
Есть ли какие-то веские причины не переписать его просто так:
public Task<SomeResult> FooAsync() { return BarAsync(); }
т.е. сразу вернуть задачу из метода, без вызова await.
Просто нигде внятного объяснения не могу найти, может кто-то сможет объяснить?vkhorikov Автор
04.05.2015 20:50+5В данном случае никакого, за исключением того, что если вы бросите исключение перед return, то в первом случае оно будет выкинуто при вызове get_Result либо при await, а во втором — сразу.
Разница есть если вы хотите, чтобы после await выполнился еще какой-либо код, если нет — то второй метод даже предпочтительней.steck
05.05.2015 11:51+1разница будет в StackTrace, если произойдёт исключение.
И если вариант с
используется часто, то понять что-же именно произошло становится сложнее.return SometingAsync();
Поэтому я предпочитаю пользоваться первым вариантом, если не разрабатываю библиотеку.
dj_raphael
06.05.2015 22:48Когда то давно написал тест для проверки, в чем конкретно преимущество async подхода в ASP.NET.
Главные вопросы
1. Снимается ли ограничение на 50 потоков на ядро? — Да снимается, потоков заметно больше.
2. Есть ли выигрыш по производительности? — и да и нет. Выигрыш появляется если Вы уперлись в ограничение 50 потоков на ядро, и у Вас есть тяжелые запросы в sql. Тогда да пока sql запросы обрабатываются на sql сервере освободившиеся потоки могут обрабатывать другие запросы.Nova_M5
07.05.2015 18:05Посмотрел ваш проект. Видимо очень давно писали тест для проверки. С такой асинхронной реализацией вы не получите никакого преимущества от async/await подхода в ASP.NET:
await Task.Factory.StartNew(() => Thread.Sleep(100));
Это очень плохой код. Вместо Thread.Sleep() надо использовать await Task.Delay(), а вместо Task.Factory.StartNew() новый метод HostingEnvironment.QueueBackgroundWorkItem(). Результаты тестов будут другими.dj_raphael
07.05.2015 18:33Спасибо, Да, уже не помню когда — но как только анонсировали асинки. На гитхаб залил гораздо позже.
кстати я тогда обнаружил особенность, что:
Task.WhenAll — стартует все таски сразу.
Task.WhenAny — стартует таски по очереди с интервалом примерно 100мс.
Ну и тестов было много, просто они переписывались поверх. после проверки результатов.
malan
Вы перевели англоязычную статью написанную вами же?
Я правильно понял? :)
vkhorikov Автор
Да :)
malan
За это должна выдаваться специальная ачивка :)