С момента появления тасков в .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 и т.д.

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


  1. Melz
    27.03.2016 20:48

    Меня терзают сомнения на счет полезности 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


    1. Razaz
      27.03.2016 21:15

      Dapper?


  1. g_DiGGeR
    28.03.2016 18:26

    А зачем вообще сложности типа использования async/await когда результатом функции является задача?
    Зачем передавать в создание таски async () => await inner.
    Это же и есть причина ошибки.


  1. Bronx
    30.03.2016 08:42

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

    Да, есть проблема, но, как и у 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 ...)


    1. szKarlen
      30.03.2016 12:27

      Вызывать 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> бесполезно.


  1. Bronx
    30.03.2016 13:07

    > Кстати у Вас идет противоречие:
    > В прочих случаях никакой Unwrap не нужен
    > +
    > Unwrap нужен с любым типом результата

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

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

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

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


    1. szKarlen
      30.03.2016 13:38

      Не совсем понял, внутри чего окажется таск? И я что-то в три часа ночи уже не соображу, как совместить перегрузку по 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 — две разные вещи, т.к. обрабатываются компилятором совершенно по-разному. Ну и в тему.