2 июля 2015 в 13:40

Недопонимание про async/await и многопоточность в C# из песочницы

C#*, .NET*
Привет, Хабр! Тема 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, который, помимо всего прочего, умеет удобно обрабатывать исключения.
(UPD: конечно же, нельзя забывать про SynchronizationContext: ContinueWith вызывает колбэк в потоке из пула потоков, а весь код после await неявно выполняется на контексте; чтобы этого избежать, нужно использовать .ConfigureAwait(false) у Task. Спасибо за замечание kekekeks)

Почему этот код хороший и правильный? Потому что 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<string> 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.

Спасибо за внимание.
@lawliet29
карма
7,0
рейтинг 0,0
Похожие публикации
Самое читаемое Разработка

Комментарии (16)

  • +12
    То есть, await — простой синтаксический сахар над ContinueWith, который, помимо всего прочего, умеет удобно обрабатывать исключения.
    «Простым синтаксическим сахаром» он будет после ConfigureAwait(false), т. к. ContinueWith вызовет continuation на потоке из пула. А так он ещё и захватывает контекст синхронизации и далее по тексту.
    Ещё забыли написать, что до EAP был паттерн с IAsyncResult.

    Так же следует добавить, что можно await-ить всякий хлам, который вообще целиком и полностью живёт в UI-потоке и сигнализирует о своём завершении через TaskCompletionSource. Очень удобно для анимаций, диалогов и во время инициализации приложения, когда не ясно, что в каком порядке отработает.

    Ну и must-read документ по TAP от MS.
    • +1
      (ошибся веткой)
  • 0
    После сравнения с jquery вроде бы и понятно стало. Но все равно в примере с await асинхронности выполнения не видно. В данном случае строка
    Console.WriteLine("Hello world!"); 
    

    всегда будет выполнена быстрее, чем
    Console.WriteLine("Got some data");
    

    независимо от скорости отрабатывания таска.
    Что скажете?
    • +1
      Да, всё именно так. Собственно, цель паттерна async/await — позволить писать асинхронный код в привычном, последовательном стиле: весь код после await неявно становится одним большим колбэком. Плюс подхода в том, что вызывающий поток работает до первого await, а после этого возвращается в пул потоков и может быть использован повторно (на самом деле всё несколько сложнее, тут ещё много магии с SynchronizationContext, но в принципе как-то так). Но при этом никто не запрещает завести столько Task'ов, сколько нужно, и собрать их в одном месте при помощи Task.WhenAll или Task.WhenAny.
      • 0
        Вот теперь все встало на свои места. Как говорится, на пальцах. Спасибо!
      • 0
        Плюс подхода в том, что вызывающий поток работает до первого await, а после этого возвращается в пул потоков

        Не совсем точно. До первого await и «обратно» по колстэку до точки «входа», потому что ж таски-то нужно еще повозвращать.
  • +1
    (Похоже, нужно сделать шаблончик для комментария к постам про async/await)

    Must read: Concurrency in C# Cookbook
  • 0
    Вот тут классно про async/await да еще и в глубинах покопался, но лучше посмотреть весь курс www.youtube.com/watch?v=fi_N_ghu4Ug&list=PLvItDmb0sZw-sOL6sOsEnSJ8etu7Kbgko&index=15
  • 0
    		private Task<string> DownloadStringWithWebClientAsync(System.Net.WebClient client, Uri url)
    		{
    			var tcs = new TaskCompletionSource<string>();
    			client.DownloadStringCompleted += (_s, _e) =>
    				{
    					if (_e.Cancelled)
    						tcs.TrySetCanceled();
    					else if (_e.Error != null)
    						tcs.TrySetException(_e.Error);
    					else
    						tcs.TrySetResult(_e.Result);
    				};
    			client.DownloadStringAsync(url);
    			return tcs.Task;
    		}
    
    

    Вот такой код будет корректным для DownloadStringAsync? (Подразумевается, что в реальном коде добавится отписка от события и проверка, что скачался «свой» url.)
    • +1
      Да, в целом идея такая.
      При этом, как выше упоминал kekekeks, TaskCompletionSource можно использовать и для создания искуственных тасков, не связанных с чем-то асинхронным, и await'ить их.
  • 0
    Да многие не понимают вообще как работает async/await, и равняют декларацию метода async как гарантию фоновой работы :)
  • –1
    Вот бы такой режим, чтобы по умолчанию все async использовались как await в месте вызова, ну и придется ввести ключевое слово «nowait». А то иногда 80% async-функций вызываются с await и писать его везде опять же утомляет. Ну и инкапсуляция была бы чище — можно не меняя сигнатуры вызова, изменить саму функцию.
    • +1
      Нет такой вещи, как async-функция.

      А то, что вы просите — это изменение, которое сломает существующий код, и MS — совершенно верно — на него не пойдет.
      • 0
        Да, я понимаю, но вы согласны, что в целом это интересная концепция (например, еще не поздно в ES7 :-)?
        • 0
          Интересная концепция — это когда весь язык по определению асинхронен.
  • 0
    По поводу полностью асинхронных методов:
    Мое мнение — особо смысла в этом нет. Visual Studio справедливо подмечает, что async метод, не использующий await, будет выполняться последовательно (синхронно). А таких методов большинство.
    По сути вся соль не в async методах, а в том, что они возвращают Task. Хочешь — жди результат сразу, хочешь — запусти еще один метод параллельно и дождись, пока отработает и то и то.
    А вызвать любой метод асинхронно, в принципе, не так и сложно — нужно вызов просто пережать в Task.Run.

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.