Pull to refresh

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

Reading time3 min
Views52K
С момента появления тасков в .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&ltTResult&gt, входным параметром у Run является Func&ltTask&ltTResult&gt&gt.

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

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

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

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

Решение


Оно весьма простое: необходимо использовать метод 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&ltdynamic&gt, где dynamic — именно возвращаемое значение inner'a, но не еще один таск.

Выводы


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

Task.Run() — для обычных вычислений.
Task.Factory.StartNew() + Unwrap() — для обычных вычислений с указанием TaskScheduler'a и т.д.
Tags:
Hubs:
Total votes 23: ↑20 and ↓3+17
Comments10

Articles