Правила работы с Tasks API. Часть 2

от автора

В этом посте я бы хотел поговорить о временами неправильном понимания концепции тасков. Также попытаюсь показать несколько неочевидностей при работе с TaskCompletionSource и просто выполненными (completed) тасками, их решение и истоки.

Проблема

Пусть у нас есть некий код:

static Task<TResult> ComputeAsync<TResult>(Func<TResult> highCpuFunc) {     var tcs = new TaskCompletionSource<TResult>();      try     {         TResult result = highCpuFunc();         tcs.SetResult(result);          // some evil code     }     catch (Exception exc)     {         tcs.SetException(exc);     }      return tcs.Task; } 

И пример использования:

try {     Task.WaitAll(ComputeAsync(() =>     {         // do work     })); } catch (AggregateException) {      } Console.WriteLine("Everything is gonna be ok"); 

Есть ли проблемы у кода выше вместе с примером? Если да, то какие? Вроде бы AggregateException ловим. Everything is gonna be ok?

NB: тема отмены (cancellation) тасков будет раскрыта в следующий раз, поэтому само отсутствие токена отмены рассматривать не будем.

Истоки

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

Таск может выполнится в том же потоке, что и вызывающий код. Причем выполнение таска необязательно означает выполнение инструкций — это может быть просто Task.FromResult, например.

Итак, проблема №1 заключена в примере использования: необходимо ловить InvalidOperationException (почему станет очевидно чуть ниже) или любое др. исключение наряду с AggregateException.
Task.WhenAll и ко. методы задокументированы как throws AggregateException, ArgumentNullException, ObjectDisposedException — это правда.

Но следует понимать очередность выполнения кода: если тело ComputeAsync начало выполняться в вывывающем потоке, то дело не дойдет до Task.WhenAll. Хотя это немного и неочевидно.

Правильный вариант:

try {     Task.WaitAll(ComputeAsync(() =>     {         // do work     })); } catch (AggregateException) {      } catch (InvalidOperationException) {      } Console.WriteLine("Everything is gonna be ok"); 

ОК, с этим разобрались. Идем дальше.

Сам по себе API, предоставляемый классом TaskCompletionSource, — весьма интуитивен. Методы SetResult, SetCanceled, SetException говорят сами за себя. Но тут-то и кроется проблема: они манипулируют состоянием итогого таска.

Хм… Уже поняли фокус? Рассмотрим подробнее.

В методе ComputeAsync есть участок кода, где выставляется SetResult, меняющий состояние таска на RanToCompletion.
После этого в строке с evil code (что как бы намекает) если будет вызвано исключение, то оно будет обработано и захвачено в SetException, что будет попыткой №2 изменить состояние таска.

При этом само состояние класса Task является неизменяемым (immutable).

NB: Почему такое поведение — есть хорошо? Рассмотрим пример:

static async Task<bool> ReadContentTwice() {     using (var stringReader = new StringReader("blabla"))     {         Task<string> task = stringReader.ReadToEndAsync();          string content = await task;         // something happens with task. oh no!         string contentOnceAgain = await task;          return content.Equals(contentOnceAgain);     } } 

Если бы состояние таска можно было менять, то это приводило к ситуации недетерминированного поведения кода. А мы знаем правило, что mutable structs are “evil” (хотя Task — это класс, но все же вопрос поведения актуален).

Далее все просто — InvalidOperationException и бла-бла.

Решение

Все весьма очевидно: вызывать SetResult прямо перед выходом из метода всегда.

Упорядоченный SetResult

static Task<TResult> ComputeAsync<TResult>(Func<TResult> highCpuFunc) {     var tcs = new TaskCompletionSource<TResult>();      try     {         TResult result = highCpuFunc();         // some evil code          // set result as last action         tcs.SetResult(result);     }     catch (Exception exc)     {         tcs.SetException(exc);     }      return tcs.Task; } 

Почему мы не рассматриваем методы TrySetResult, TrySetCanceled, TrySetException?!

Для использования оных необходимо ответить на вопрос:

  • Ограничивается ли скоуп использования самого TaskCompletionSource лишь данным методом?

Если ответ на вопрос выше — НЕТ, тогда обязательно использовать TryXXX. Сюда же относятся паттерны APM, EAP.
Если же код простой как в исходном примере — простое упорядочивание методов.


Bonus track

Каждый раз вызывать Task.FromResult — неэффективно. Зачем тратить память? Для этого можно воспользоваться встроенными возможностями фреймфорка… которых нет!

Именно так. Понятие CompletedTask пришло лишь в .NET 4.6. Причем (как Вы уже догадались) есть некоторая особенность.

Начнем со свежего: новое свойство свойство Task.CompletedTask: является просто статическим свойством типа Task (хочу заметить именно что не-generic варианта). Ну ОК. Вряд ли пригодится, ибо редко таски бывают без результата.

А еще… в документации сказано: May not always return the same instance. Сделано специально.

Собственно код

/// <summary>Gets a task that's already been completed successfully.</summary> /// <remarks>May not always return the same instance.</remarks>         public static Task CompletedTask {     get     {         var completedTask = s_completedTask;         if (completedTask == null)             s_completedTask = completedTask = new Task(false, (TaskCreationOptions)InternalTaskOptions.DoNotDispose, default(CancellationToken)); // benign initialization race condition         return completedTask;     } } 

Чтобы никогда не кешеровать и не сравнивать со значением (т.е. ссылкой) на Task.CompletedTask при проверке на предмет completed task.

Решение данной проблемы весьма простое:

public static class CompletedTaskSource<T> {     private static readonly Task<T> CompletedTask = Task.FromResult(default(T));      public static Task<T> Task     {         get         {             return CompletedTask;         }     } } 

И все. К счастью, для .NET 4 существует отдельный NuGet пакет Microsoft Async, который позволяет компилировать C# 5 код для .NET 4 + приносит недостающие Task.FromResult и т.д.

ссылка на оригинал статьи https://habrahabr.ru/post/280344/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *