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

    С момента появления тасков в .NET прошло почти 6 лет. Однако я до сих пор вижу некоторую путаницу при использовании Task.Run() и Task.Factory.StartNew() в коде проектов. Если это можно списать на их схожесть, то некоторые проблемы могут возникнуть из-за dynamic в C#.

    В этом посте я попытаюсь показать проблему, решение и истоки.

    Проблема


    Пусть у нас есть код, который выглядит так:

    static async Task<dynamic> Compute(Task<dynamic> inner)
    {
        return await Task.Factory.StartNew(async () => await inner);
    }
    

    Вопрос знатокам: есть ли в данном примере проблема? Если да, то какая? Код компилируется, возвращаемый тип Task на месте, модификатор async при использовании await — тоже.

    Думаете, речь идет о пропущенном ConfigureAwait? Хаха!

    NB: вопрос о ConfigureAwait я опущу, ибо о другом статья.

    Истоки


    До идиомы async/await основным способом использования Tasks API был метод Task.Factory.StartNew() с кучей перегрузок. Так, Task.Run() немного облегчает данный подход, опуская указание планировщика (TaskScheduler) и т.п.

    static Task<T> Run<T>(Func<T> inner)
    {
        return Task.Run(inner);
    }
    
    static Task<T> RunFactory<T>(Func<T> inner)
    {
        return Task.Factory.StartNew(inner);
    }
    

    Ничего особенно в примере выше нет, но именно здесь начинаются отличия, и возникает главная проблема — многие начинают думать, что Task.Run() — это облегченный Task.Factory.StartNew().

    Однако это не так!

    Чтобы стало нагляднее, рассмотрим пример:

    static Task<T> Compute<T>(Task<T> inner)
    {
        return Task.Run(async () => await inner);
    }
    
    static async Task<T> ComputeWithFactory<T>(Task<T> inner)
    {
        return await await Task.Factory.StartNew(async () => await inner);
    }
    

    Что? Два await'a? Именно так.

    Все дело в перегрузках:

    public static Task<TResult> Run<TResult>(Func<Task<TResult>> function)
    {
      // code
    }
    
    public Task<TResult> StartNew<TResult>(Func<TResult> function)
    {
      // code
    }
    

    Несмотря на то, что возвращаемый тип у обоих методов — Task<TResult>, входным параметром у Run является Func<Task<TResult>>.

    В случае с async () => await inner Task.Run получит уже готовую state-машину (а мы знаем, что await — есть не что иное, как трансформация кода в state-машину), где все оборачивается в Task.
    StartNew получит то же самое, но TResult уже будет Task<Task<T>>.

    — OK, но почему изначальный пример не падает с ошибкой компиляции, т.к. отсутствует второй await?
    Ответ: dynamic.

    В одной статье, я уже описывал работу dynamic: каждый statement в C# превращается в узел вызова (call-site), который относится ко времени исполнения, а не компиляции. При этом сам компилятор старается побольше метаданных передать рантайму.

    Метод Compute() использует и возвращает Task<dynamic>, что заставляет компилятор создавать эти самые узлы вызовов.
    Причем, это корректный код — результатом в рантайме будет Task<Task<dynamic>>.

    Решение


    Оно весьма простое: необходимо использовать метод Unwrap().

    В коде без dynamic вместо двух await'ов можно обойтись одним:

    static async Task<T> ComputeWithFactory<T>(Task<T> inner)
    {
        return await Task.Factory.StartNew(async () => await inner).Unwrap();
    }
    

    И применить к

    static async Task<dynamic> Compute(Task<dynamic> inner)
    {
        return await Task.Factory.StartNew(async () => await inner).Unwrap();
    }
    

    Теперь, как и ожидалось, результатом будет Task<dynamic>, где dynamic — именно возвращаемое значение inner'a, но не еще один таск.

    Выводы


    Всегда используйте метод-расширение Unwrap для Task.Factory.StartNew(). Это сделает ваш код более идиоматичным (один await на вызов) и не допустит хитростей dynamic.

    Task.Run() — для обычных вычислений.
    Task.Factory.StartNew() + Unwrap() — для обычных вычислений с указанием TaskScheduler'a и т.д.
    Share post

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 10

      0
      Меня терзают сомнения на счет полезности dynamic.

      Для вдохновления могу посоветовать почитать:

      Processing Sequences of Asynchronous Operations with Task
      http://blogs.msdn.com/b/pfxteam/archive/2010/11/21/10094564.aspx?Redirected=true

      The task monad in C#
      http://ruudvanasseldonk.com/2013/05/01/the-task-monad-in-csharp
        0
        Dapper?
        0
        А зачем вообще сложности типа использования async/await когда результатом функции является задача?
        Зачем передавать в создание таски async () => await inner.
        Это же и есть причина ошибки.
          0

          Например чтобы не получить TaskCancelledException в основном потоке при простой попытке остановить запущенную задачу.

          0
          > есть ли в данном примере проблема?

          Да, есть проблема, но, как и у g_DiGGeR, проблема в понимании: зачем в Compute передавать Task; a если уж приспичило передавать именно Task, то зачем его там стартовать вместо того, чтобы сразу отдать (безо всякого return await); а если вдруг нужно дождаться результата inner и использовать внутри Compute, то почему не использовать await inner напрямую, без StartNew с асинхронной блямбдой и т.п.?

          > Всегда используйте метод-расширение Unwrap для Task.Factory.StartNew()

          Вызывать Unwrap после StartNew нужно лишь в случае когда передаётся асинхронный делегат в качестве параметра (как в вашем примере), потому что такой делегат вернёт Task, который завернётся в ещё один Task, и его нужно развернуть. В прочих случаях никакой Unwrap не нужен.
          См. http://blogs.msdn.com/b/pfxteam/archive/2011/10/24/10229468.aspx

          А dynamic тут вообще не при чём — Unwrap нужен с любым типом результата. Task.Run уже содержит внутри Unwrap() (там, где это нужно), так что да, Run — это не просто StartNew с дефолтными параметрами.

          Вместо Unwrap можно использовать двойной await (return await await ...)
            0
            Вызывать Unwrap после StartNew нужно лишь в случае когда передаётся асинхронный делегат в качестве параметра (как в вашем примере), потому что такой делегат вернёт Task, который завернётся в ещё один Task, и его нужно развернуть. В прочих случаях никакой Unwrap не нужен.

            Естественно. об этом и речь. Так в статье я писал, что все дело в перегрузках:
            public static Task<TResult> Run<TResult>(Func<Task<TResult>> function)
            public Task<TResult> StartNew<TResult>(Func<TResult> function);

            Несмотря на то, что возвращаемый тип у обоих методов — Task, входным параметром у Run является Func<Task<TResult>>.
            Кстати у Вас идет противоречие:
            В прочих случаях никакой Unwrap не нужен
            +
            Unwrap нужен с любым типом результата
            +
            Вместо Unwrap можно использовать двойной await (return await await ...)

            т.е. Вы статью не читали, так? ибо особенно показательно я призываю не использовать просто так два await'a подряд:
            Всегда используйте метод-расширение Unwrap для Task.Factory.StartNew(). Это сделает ваш код более идиоматичным (один await на вызов) и не допустит хитростей dynamic.
            Чтобы стало нагляднее, рассмотрим пример:
            static Task<T> Compute<T>(Task<T> inner)
            {
            return Task.Run(async () => await inner);
            }

            static async Task<T> ComputeWithFactory<T>(Task<T> inner)
            {
            return await await Task.Factory.StartNew(async () => await inner);
            }


            И, естественно, если читатель понял контекст всей статьи, что мы рассматриваем именно не-void случаи, тогда таких вопросов не должно возникать.
            Кстати, если будет выбрана перегрузка с простым Action, а внутри окажется таск, то, соответсвенно, это уже будет асинхронная лямбда с возвращаемым типом void.
            Кажется, в каждом туториале пишут большими буквами: никогда не используйте async void.
            Мне это пересказывать? зачем? Если читатель не знаком с данным постулатом, то и остальное не пригодится.
            И последнее:
            Да, есть проблема, но, как и у g_DiGGeR, проблема в понимании: зачем в Compute передавать Task; a если уж приспичило передавать именно Task, то зачем его там стартовать вместо того, чтобы сразу отдать (безо всякого return await); а если вдруг нужно дождаться результата inner и использовать внутри Compute, то почему не использовать await inner напрямую, без StartNew с асинхронной блямбдой и т.п.?

            In real life так просто не будет. Рефакторинги происходят, код пишется разными людьми. Мира с единорогами нет, где, допустим, в ComputeAsync попадет что-простое. А уж таких ComputeAsync с проблемами было n-ое кол-во.
            Моя мысль простая: один await на async метод. Если возвращаемый тип Task<Task<T>>, тогда Unwrap() нужен, ибо для Task<T> бесполезно.
              0
              Типичный пример, когда нужно: https://blogs.msdn.microsoft.com/pfxteam/2011/01/15/asynclazyt/

              That could be perfectly reasonable if the taskFactory delegate does very little work before returning the task instance. If, however, the taskFactory delegate does any non-negligable work, a call to Value would block until the call to taskFactory completes. To cover that case, the second approach is to run the taskFactory using Task.Factory.StartNew, i.e. to run the delegate itself asynchronously, just as with the first constructor, even though this delegate already returns a Task. Of course, now StartNew will be returning a Task<Task>, so we use the Unwrap method in .NET 4 to convert the Task<Task> into a Task, and that’s what’s passed down the base type.
              0
              > Кстати у Вас идет противоречие:
              > В прочих случаях никакой Unwrap не нужен
              > +
              > Unwrap нужен с любым типом результата

              Под «любым типом результата» подразумевался не результат функции, а TResult — хоть dynamic, хоть int. Так что тут нет противоречия.

              С двойным await каюсь, был невнимателен.

              > если будет выбрана перегрузка с простым Action, а внутри окажется таск, то, соответсвенно, это уже будет асинхронная лямбда с возвращаемым типом void.

              Не совсем понял, внутри чего окажется таск? И я что-то в три часа ночи уже не соображу, как совместить перегрузку по Action (который и сам синхронный и не возвращает ничего асинхронного) с асинхронной лямбдой? Ну если только не ставить async-и и лямбды бездумно куда ни попадя.
                0
                Не совсем понял, внутри чего окажется таск? И я что-то в три часа ночи уже не соображу, как совместить перегрузку по Action (который и сам синхронный и не возвращает ничего асинхронного) с асинхронной лямбдой? Ну если только не ставить async-и и лямбды бездумно куда ни попадя.

                Я имел ввиду момент, когда даже если передается Action, то последний может быть помечен async модификатором как из-за await'a внутри тела определенного таска, так и по ошибке.
                Например:
                static void ComputeNoReturn(Action inner)
                {
                    inner();
                }
                
                ComputeNoReturn(async () =>
                {
                    throw new Exception();
                });
                ComputeNoReturn(async () =>
                {
                    await Task.Run(() => {});
                });

                Так что, просто Action и async Action — две разные вещи, т.к. обрабатываются компилятором совершенно по-разному. Ну и в тему.
                0

                Удивляет, зачем использовать динамик (сознательно) вместо нормальной параметризации и жаловаться на это? Как только типы проставлены, компилятор заставит подумать.


                Решение использвать Unwrap это не решение, а просто корректный способ использования TPL.

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