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

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

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

Проблема


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

static async Task Compute(Task 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 Run(Func inner)
{
    return Task.Run(inner);
}

static Task RunFactory(Func inner)
{
    return Task.Factory.StartNew(inner);
}


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

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

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

static Task Compute(Task inner)
{
    return Task.Run(async () => await inner);
}

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


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

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

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

public Task StartNew(Func 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 ComputeWithFactory(Task inner)
{
    return await Task.Factory.StartNew(async () => await inner).Unwrap();
}


И применить к

static async Task Compute(Task 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 и т.д.

© Habrahabr.ru