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