Tag Archives: await

Что стоит за async/await и почему опытным разработчикам надо быть осторожными

1. Базовые возможности Task Parallel Library (TPL)

Все возможности TPL базируются на старых Thread и ThreadPool, если точнее, то асинхронное выполнение задач будет производится путем вполнения их через класс ThreadPool. И фактический самый простой способ запустить асинхронную задачу при помощи новых инструментов не слишком отличается от ThreadPool, и выглядит так:

Parallel.Invoke(() => DoSomeWork(), () => DoSomeOtherWork());

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

Основным элементом модели параллельных вычислений является класс Task. Этот класс инкапсулирует в себе некую вычислительную задачу. Никакой “арифметики” задач TPL не предлагает. Другими словами, вы не можете из двух задач собрать цепочку последовательных, или напор параллельных. Разработчики с опытом в TPL скажут, а как же метод ContinueWith? Но дело в том, что этот метод не имеет перегрузки работающей с тасками, он всего лишь позволяет дополнить существующий делегатом. Все равно что дописать в конец кода таска еще одну или несколько строк.

Так же есть статические методы Task.WhenAny и Task.WhenAll, которые создают задачу, завершающуюся после завершения всех или любой из заданных в параметрах задач. Но опять же они не позволяют без дополнительных усилий строить цепочки задач. За это приверженцы Reactive Extensions (Rx) очень ругают TPL.

Использовать Task можно следующим нехитрым способом:

Task task = new Task(() => Console.WriteLine("Hello from taskA."));
task.Start();
// Some code here
task.Wait();
// Some code that should wait before task finish.

Этот же код можно заменить на аналогичный, где метод Task.Run создает и запускает задачу :

Task task = Task.Run(() => Console.WriteLine("Hello from taskA."));
// Some code here
task.Wait();
// Some code that should wait before task finish.

В описании класса вы найдете функции для ожидания нескольких задач Task.WaitAll и Task.WaitAny. На этом этапе все вроде бы проcто и понятно. Но продолжим наши исследования дальше.

Рассмотрим новый синтаксис C# с await и async. Эти два ключевых слова на самом деле просто облегчают работу с классом Task избавляя разработчика от написания дополнительных методов и другого служебного кода. Для того, чтобы можно было воспользоваться await метод который мы “ждем” должен возвращать Task а метод, в котором мы “ждем” должен быть помечен как async.

Рассмотрим следующий код консольного приложения (Debug используется потому что дальше мы будем этот код запускать в других типах приложений)

static void Main(string[] args)
{
   Debug.WriteLine("Main started in thread {0}", Thread.CurrentThread.ManagedThreadId);
   var task = Task.Run(() =>
   {
      Debug.WriteLine("Task1 started in thread {0}", Thread.CurrentThread.ManagedThreadId);
      Thread.SpinWait(5000000);
      Debug.WriteLine("Task1 finished in thread {0}", Thread.CurrentThread.ManagedThreadId);
   });
   task.Wait();
   Debug.WriteLine("Main finished in thread {0}", Thread.CurrentThread.ManagedThreadId);
}

Результат его работы достаточно ожидаем:

Main started in thread 8
Task1 started in thread 9
Task1 finished in thread 9
Main finished in thread 8

Главный метод запустился в 8-м потоке затем асинхронная задача в 9-м потоке затем завершился 8-й поток.

Перепишем этот код с использованием await и async. Нам придется создать дополнительный метод static async void Run() потому что точку входа помечать async нельзя. Вызов этого метода имеет обычный вид Run();

static void Main(string[] args)
{
   Run();
}
 
static async void Run()
{
   Debug.WriteLine("Main started in thread {0}", Thread.CurrentThread.ManagedThreadId);
   await Task.Run(() =>
   {
      Debug.WriteLine("Task1 started in thread {0}", Thread.CurrentThread.ManagedThreadId);
      Thread.SpinWait(5000000);
      Debug.WriteLine("Task1 finished in thread {0}", Thread.CurrentThread.ManagedThreadId);
   });
 
   Debug.WriteLine("Main finished in thread {0}", Thread.CurrentThread.ManagedThreadId);
}

На первый взгляд все то же самое и те, кто уже немного поработал с await и async, могут подумать, что результат будет таким же. Но результат такой:

Main started in thread 9
Task1 started in thread 10

Куда же делось завершение обоих потоков спросите вы. Для того чтоб разобраться давайте посмотрим на получившуюся сборку при помощи Reflector или ILSpy, отключив при этом декомпиляцию в await и async. Мы увидим, что метод стал выглядеть странно:

private static void Run()
{
   Program.<Run>d__2 <Run>d__;
   <Run>d__.<>t__builder = AsyncVoidMethodBuilder.Create();
   <Run>d__.<>1__state = -1;
   AsyncVoidMethodBuilder <>t__builder = <Run>d__.<>t__builder;
   <>t__builder.Start<Program.<Run>d__2>(ref <Run>d__);
}

И появилась структура private struct <Run>d__2 : IAsyncStateMachine с большим количеством кода в котором попадаются наши строки Debug.WriteLine. Я не буду приводить здесь полный код кому интересно сможете проделать процедуру сами. Смысл этого кода в том, что исходный метод Run был разобран на части и построен некоторый автомат, выполняющий эти части в разных потоках.

Но почему все-таки мы не увидели двух строк с завершением потоков. Ответ на это даст модифицированный код примера. Добавим задержку после вызова Run:

static void Main(string[] args)
{
   Run();
   Thread.SpinWait(10000000);
}
Main started in thread 9
Task1 started in thread 6
Task1 finished in thread 6
Main finished in thread 6

Последняя строка на первый взгляд выглядит более чем странно – метод Main начался в 9-м потоке, а закончился в 6-м. В том же, что и задача “Task1”.

Давайте теперь выполним все то же в WPF приложении, которое имеет единственную кнопку и обработчик нажатия на эту кнопку Button_Click:

private async void Button_Click(object sender, RoutedEventArgs e)
{
   Debug.WriteLine("Main started in thread {0}", Thread.CurrentThread.ManagedThreadId);
   await Task.Run(() =>
   {
      Debug.WriteLine("Task1 started in thread {0}", Thread.CurrentThread.ManagedThreadId);
      Thread.SpinWait(5000000);
      Debug.WriteLine("Task1 finished in thread {0}", Thread.CurrentThread.ManagedThreadId);
   });
   Debug.WriteLine("Main finished in thread {0}", Thread.CurrentThread.ManagedThreadId);
}

Результат будет таким:

Main started in thread 9
Task1 started in thread 6
Task1 finished in thread 6
Main finished in thread 9

Здесь главный метод, почему то, начался и закончился в том же потоке, в 9-м. Если открыть сгенерированную сборку в ILSpy то код будет примерно такой же, не считая инициализации переменных, отвечающих за параметры sender и e имя “главного” метода и содержащего его класса. Выходит, “самые страшные опасения не оправдались код генерируется одинаковый и от типа приложения не зависит. Что же все-таки отличается?

2. SyhchronizationContext и TPL

Причиной разного поведения консольного и WPF приложения были разные контексты синхронизации, разные реализации базового класса SynchronizationContext, заданные в качестве текущего контекста синхронизации. Добавив в оба приложения строку

Debug.WriteLine(SynchronizationContext.Current.GetType().FullName);

видим следующий результат:

– для WPF: System.Windows.Threading.DispatcherSynchronizationContext
– для консоли ничего не видим потому, что SynchronizationContext.Current равен null

При помощи ILSpy можно увидеть, что метод AsyncVoidMethodBuilder.Create(), вызов которого добавлен компилятором в метод Run внутри обращается к контексту синхронизации:

public static AsyncVoidMethodBuilder Create()
{
    return new AsyncVoidMethodBuilder(SynchronizationContext.CurrentNoFlow);
}

Думаю, тут можно остановиться и не копать глубже в код TPL. Таким образом результаты выполнения задач мы будем получать через контекст синхронизации и в WPF мы будем через очередь обработки сообщений попадать в UI поток, а в консольном приложении будет использоваться произвольный другой поток.

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

Так что начиная использовать async и await постарайтесь забыть все то, что раньше знали о потоках и оперируйте задачами.

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