Правила работы с 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 и т.д.
    Share post

    Similar posts

    Comments 11

      +1
      Маленький нюанс:
      TResult result = highCpuFunc();
      Это вполне себе синхронный вызов, который сводит на нет все старание вокруг него.
      Мы ведь должны сначала вернуть tcs.Task, а потом, где-то в другой вселенной, вызвать tcs.SetResult(result);.
      В противном случае все это не имеет смысла. Или я что-то пропустил и не прав?
        0
        Все верно. Собственно об этом и речь (проблема №1 из статьи): ожидание (await'e) некоторого метода, который может работать в синхронном режиме. И это может произойти (да что там — происходит) в любом месте цепочки вызовов.
        Ответ очевиден: если задача интенсивная по вычислениям, тогда
        где-то в другой вселенной, вызвать tcs.SetResult(result)

        Безусловно. Но мы же рассматриваем evil code, не так ли?
          0
          Злой код — это одно. Но в вашем примере мы в любом случае сначала вызываем tcs.SetX, а уже потом return tcs.Task;
          Т.е. ComputeAsync выполняется всегда синхронно.
            0
            Тогда давайте отделим вопрос синхронного вызова и установки SetResult до возвращения return tcs.Task.
            Если стоит второй вопрос, то даже при вызове SetResult хоть в отдельном потоке установка результата возможна (на самом деле) еще до возвращения return tcs.Task. Пример был упрощен специально дабы проблему №2 (изменения состояния тасков) показать.
          0
          CPU-bound задачи вообще никогда не следует заворачивать в асинхронные функции типа ComputeAsync. Если эта задача выполняется при обработке запроса в нагруженном веб-сервисе, то выгоднее вызвать HighCpuFunc() напрямую и синхронно, ибо нет никакого смысла создавать новый поток и переключаться в него. Если же задача выполняется в пользовательском приложении с UI, и нужно позаботиться об отзывчивости, то это задача приложения вызвать Task.Run(() => HighCpuFunc()).

          Сигнатура функции типа ComputeAsync() предполагает, что в ней делается I/O-bound задача, и использовать её для CPU-bound — это вводить пользователей этой функции в заблуждение, чреватое серьёзными проблемами.
            0
            Я того же мнения. Имхо, это косяк посерьёзнее забытых catch
              0
              Из метода вернется уже завершенный таск. Вы правы в случае истинной асинхронности, в статье описывается использование TBAP в синхронном коде.
              –3
              1. «пост», «таск», «скоуп», «паттерн», «фреймфорк», «недетерминированный» я бы заменил на русские слова, мы же для русских тут пишем. Заодним исправьте «итогого» на «итогового».
              2. В разделе «Истоки», где якобы всё объясняется, написано много непонятного. Например, что такое «вызов таска»? Или «Таск может выполнится в том же потоке» — странная фраза учитывая что задачи собственно никак не привязаны к потокам и могут выполнятся вообще без единого потока.
              3. По-моему, гораздо проще было всё объяснить просто обратив внимание на то, что создание задачи и выполнение задачи отделены друг от друга и могут бросать разные исключения. Достаточно вместо

              Task.WaitAll(ComputeAsync(() =>
                  {
                      // do work
                  }));

              написать так
              var task = ComputeAsync(() =>
                  {
                      // do work
                  });
              Task.WaitAll(task);

              Сразу станет видно что тут ДВА места где могут возникнуть исключения.
                +2
                По порядку:
                1. Слово недетерминированный является русским словом, принятым в научной сфере, а в математике — в частности. Учите мат. часть, как говорится.
                  Если интересует этимология слова: конечно же, корень determine — определять.
                  А слово фреймворк уже является неологизмом. остальные — на усмотрение автора.
                  Спасибо, что хоть NB (но́та бэ́нэ) мне не запрещаете употреблять.
                2. ОК. Что такое таск, и что такое поток?
                  Каждый из них является наименьшей единицей в моделях вытесняющей и кооперативной многозадачности. Так последнее эмулируется поверх первого, что очевидно.
                  Кажется в статье, я четко указал на это:
                  Концепция тасков весьма тесно связано с мыслью об асинхронности, которую иногда путают с многопоточным выполнением. А это в свою очередь приводит к выводу о том, что каждый вызов таска — нечто выполняющееся где-то там.
                  Таск может выполнится в том же потоке, что и вызывающий код. Причем выполнение таска необязательно означает выполнение инструкций — это может быть просто Task.FromResult, например.

                  Фраза о выполнении в каком-либо потоке ясно означает задачу (таск) с вычислениями.
                3. А что Хабр стал Твиттером? А немного поразмыслить? Все должно быть так просто?!
                  0
                  Я же не редактор, не надо принимать мои личные пожелания так близко к сердцу! Я описываю, как лично мне статья была бы более понятна.
                  Главное. Задачи (которые Task) в общем случае никак не связаны с потоками выполнения и многозадачностью (о чём вы сами кое-где упоминаете). Поэтому, для применения ваших правил независимо от ситуации, хотелось чтобы трудные ситуации описывались без привязки к потокам (которые Thread).
                  Ну и давайте улыбнёмся в тему.
                +1
                Статья мне понравилась, продолжайте в том же духе! Было бы еще интересно ваши исследования в TPL Dataflow.

                Only users with full accounts can post comments. Log in, please.