[Из песочницы] Недопонимание про async/await и многопоточность в C#
Привет, Хабр! Тема async/await в .NET Framework и C# 5.0 не нова и объезженна: все давно знают, что это, как оно работает, все знакомы с тем скромным фактом, что это очень текучая абстракция и поведение зависит от SynchronizationContext. Об этом оченьмного писали на хабре, ещё чаще этот вопрос размусоливался в блогах различных респектабельных персон .NET-сообщества.
Тем не менее, мне очень часто приходится сталкиваться с тем, что не только новички, но и матёрые тимлиды не совсем понимают, как правильно пользоваться этим инструментом в разработке.
Моё мнение таково — корень всех зол кроется в мнении о том, что async/await и Task Asynchronous Pattern нужно использовать для написания многопоточной логики.
Начитавшись большого количества информации с различных ресурсов про async/await у разработчиков формируется миф: ожидание происходит в отдельном потоке, или код выполняется в отдельном потоке, или что-то ещё происходит с отдельным потоком. Нет, нет и ещё раз нет. Изначально TAP задумывался не для этого — для многопоточности существует Task Parallel Library (MSDN). Путаница возникает не в последнюю очередь из-за того, что и в TAP, и в TPL используется Task.
Тем не менее, в коде дожлно быть четкое разделение между многопоточными операциями (CPU-bound) и асинхронными операциями.
В моей среде обитания (ASP.NET) многие по долгу службы работают с Javascript. В этом замечательном языке существует простой паттерн для асинхронных операций — callbacks, функции обратного вызова. В объяснение отличий TAP и TPL люблю приводить следующий пример на Javascript с использованием jQuery:
$.get('/api/blabla', function(data) {
console.log("Got some data.");
});
console.log("Hello world!")
В большинстве случаев при выполнении правильного ajax-запроса в консоли увидим следующее:
Hello world!
Got some data.
Что, в общем-то, и ожидалось. Это — очень яркий пример асинхронного программирования. Здесь нет никакой многопоточности — Javascript строго однопоточен и никаких наворотов вроде WebWorkers этот код не использует.
Новомодные javascript-библиотеки любят для таких задач оперировать новой фичей ES6 (или ES2015?) — Promise API. Например, похожий код с использованием $http из AngularJS выглядел бы так:
$http.get('/api/blabla').success(function(data) {
console.log("Got some data.");
});
console.log("Hello world!")
Здесь вызов $http.get (…) возвращает Promise, к которому можно прикрепить коллбэк вызовом success (…). Код, естественно, всё так же остаётся однопоточным.
А теперь рассмотрим похожий по назначению код на C#:
var client = new WebClient();
client
.DownloadStringTaskAsync("/api/blabla")
.ContinueWith(result => {
Console.WriteLine("Got some data.");
});
Console.WriteLine("Hello world!");
Здесь client.DownloadStringTaskAsync возвращает Task, ContinueWith прикрепляет к ему коллбэк. То есть, по сути, Task и Promise — сущности с одной и той же задумкой в .NET и Javascript соответственно.
Этот же код можно записать с использованием await:
var client = new WebClient();
var task = client.DownloadStringTaskAsync("/api/blabla");
Console.WriteLine("Hello world!");
var result = await task;
Console.WriteLine("Got some data");
То есть, await — простой синтаксический сахар над ContinueWith, который, помимо всего прочего, умеет удобно обрабатывать исключения.
Почему этот код хороший и правильный? Потому что DownloadStringTaskAsync возвращает Task, который инкапсулирует операцию ввода-вывода — то есть I/O bound операцию. И практически весь ввод-вывод является асинхронным — то есть, для его осуществления, нигде, начиная с самого верхнего уровня вызова метода DownloadStringTaskAsync и заканчивая драйвером сетевой карты, абсолютно нигде не нужен дополнительный поток, который будет «ждать» или «обрабатывать» эту операцию.
Предположим на секунду, что у нас нету удобного API, который возвращает Task, и мы не можем использовать await для осуществления этой асинхронной операции. Как ни странно, разработчики .NET Framework с ранних версий создавали API таким образом, чтобы можно было работать с асинхронным вводом-выводом, и в том же классе WebClient остался ныне устаревший метод для осуществления всё того же DownloadString с использованием Event Asynchronous Pattern (EAP): можно подписаться на событие DownloadStringCompleted и вызвать метод DownloadStringAsync.
Тем не менее, я очень часто сталкиваюсь с тем, что, даже если какой-то legacy-код предоставляет EAP API, при необходимости обернуть его в TAP матёрые программисты поступают просто и в лоб:
private Task DownloadStringWithWebClientAsync(WebClient client, string url)
{
return Task.Run(() => client.DownloadString(url));
}
В чём проблема? А проблема, собственно, в том, что Task.Run запускает переданную в него лямбду () => client.DownloadString (url) в новом CPU-bound потоке из пула потоков. При том что, в данном случае, никакой необходимости в отдельном потоке нет.
Как «сделать правильно»? Использовать TaskCompletionSource. Продолжая аналогию с Promise API, TaskCompletionSource выполняет те же функции, что и Deferred. Таким образом, можно создать Task, который не будет создавать дополнительных потоков. Это очень удобно, когда нужно обернуть в Task ожидание срабатывания какого-либо события, такой сценарий неплохо описан в примере на MSDN.
Так что же получается, Task Asynchronous Pattern нельзя использовать для многопоточности? Можно. Но, как ни раз упоминалось в статьях, на которые я ссылался в начале, необходимо:
а) Четкое разделение CPU-bound и I/O-bound операций, скрывающихся за Task.
б) При необходимости выолнить какую-то операцию параллельно, в отдельном потоке, лучше позволить разрулить эту ситуацию вызывающему коду. Например, определиться, что все методы, возвращающие Task, являются I/O-bound, а для вызова CPU-bound методов параллельно можно использовать Task.Run.
Спасибо за внимание.