Правильный способ реализации методов, возвращающих Task <T>

Для простоты предположим, что у нас есть метод, который должен возвращать объект при выполнении тяжелой операции. Существует два способа реализации:

public Task<object> Foo()
{
    return Task.Run(() =>
    {
        // some heavy synchronous stuff.

        return new object();
    }
}

и

public async Task<object> Foo()
{
    return await Task.Run(() =>
    {
        // some heavy stuff
        return new object();
    }
}

После изучения сгенерированного ИЛ есть две совершенно разные вещи:

.method public hidebysig 
    instance class [mscorlib]System.Threading.Tasks.Task`1<object> Foo () cil managed 
{
    // Method begins at RVA 0x2050
    // Code size 42 (0x2a)
    .maxstack 2
    .locals init (
        [0] class [mscorlib]System.Threading.Tasks.Task`1<object>
    )

    IL_0000: nop
    IL_0001: ldsfld class [mscorlib]System.Func`1<object> AsyncTest.Class1/'<>c'::'<>9__0_0'
    IL_0006: dup
    IL_0007: brtrue.s IL_0020

    IL_0009: pop
    IL_000a: ldsfld class AsyncTest.Class1/'<>c' AsyncTest.Class1/'<>c'::'<>9'
    IL_000f: ldftn instance object AsyncTest.Class1/'<>c'::'<Foo>b__0_0'()
    IL_0015: newobj instance void class [mscorlib]System.Func`1<object>::.ctor(object, native int)
    IL_001a: dup
    IL_001b: stsfld class [mscorlib]System.Func`1<object> AsyncTest.Class1/'<>c'::'<>9__0_0'

    IL_0020: call class [mscorlib]System.Threading.Tasks.Task`1<!!0> [mscorlib]System.Threading.Tasks.Task::Run<object>(class [mscorlib]System.Func`1<!!0>)
    IL_0025: stloc.0
    IL_0026: br.s IL_0028

    IL_0028: ldloc.0
    IL_0029: ret
}

и

.method public hidebysig 
    instance class [mscorlib]System.Threading.Tasks.Task`1<object> Foo () cil managed 
{
    .custom instance void [mscorlib]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [mscorlib]System.Type) = (
        01 00 1a 41 73 79 6e 63 54 65 73 74 2e 43 6c 61
        73 73 31 2b 3c 42 61 72 3e 64 5f 5f 31 00 00
    )
    .custom instance void [mscorlib]System.Diagnostics.DebuggerStepThroughAttribute::.ctor() = (
        01 00 00 00
    )
    // Method begins at RVA 0x2088
    // Code size 59 (0x3b)
    .maxstack 2
    .locals init (
        [0] class AsyncTest.Class1/'<Foo>d__1',
        [1] valuetype [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<object>
    )

    IL_0000: newobj instance void AsyncTest.Class1/'<Foo>d__1'::.ctor()
    IL_0005: stloc.0
    IL_0006: ldloc.0
    IL_0007: ldarg.0
    IL_0008: stfld class AsyncTest.Class1 AsyncTest.Class1/'<Foo>d__1'::'<>4__this'
    IL_000d: ldloc.0
    IL_000e: call valuetype [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<!0> valuetype [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<object>::Create()
    IL_0013: stfld valuetype [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<object> AsyncTest.Class1/'<Foo>d__1'::'<>t__builder'
    IL_0018: ldloc.0
    IL_0019: ldc.i4.m1
    IL_001a: stfld int32 AsyncTest.Class1/'<Foo>d__1'::'<>1__state'
    IL_001f: ldloc.0
    IL_0020: ldfld valuetype [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<object> AsyncTest.Class1/'<Foo>d__1'::'<>t__builder'
    IL_0025: stloc.1
    IL_0026: ldloca.s 1
    IL_0028: ldloca.s 0
    IL_002a: call instance void valuetype [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<object>::Start<class AsyncTest.Class1/'<Foo>d__1'>(!!0&)
    IL_002f: ldloc.0
    IL_0030: ldflda valuetype [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<object> AsyncTest.Class1/'<Foo>d__1'::'<>t__builder'
    IL_0035: call instance class [mscorlib]System.Threading.Tasks.Task`1<!0> valuetype [mscorlib]System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<object>::get_Task()
    IL_003a: ret
}

Как вы можете видеть в первом случае логика проста, создается лямбда-функция, а затем вызывается вызов Task.Run и возвращается результат. Во втором примере создается экземпляр AsyncTaskMethodBuilder, и тогда задача фактически строится и возвращается. Поскольку я всегда ожидал, что метод foo будет называться как await Foo() на каком-то более высоком уровне, я всегда использовал первый пример. Тем не менее, я вижу последнее чаще. Итак, какой подход правильный? Какие плюсы и минусы у каждого есть?


Пример реального мира

Скажем, мы имеем UserStore, у которого есть метод Task<User> GetUserByNameAsync(string userName), который используется внутри веб-контроллера api, например:

public async Task<IHttpActionResult> FindUser(string userName)
{
    var user = await _userStore.GetUserByNameAsync(userName);

    if (user == null)
    {
        return NotFound();
    }

    return Ok(user);
}

Какая реализация Task<User> GetUserByNameAsync(string userName) была бы правильной?

public Task<User> GetUserByNameAsync(string userName)
{
    return _dbContext.Users.FirstOrDefaultAsync(user => user.UserName == userName);
}

или

public async Task<User> GetUserNameAsync(string userName)
{
    return await _dbContext.Users.FirstOrDefaultAsync(user => user.UserName == username);
}

Ответы

Ответ 1

Как вы можете видеть из IL, async/await создает конечный автомат (и дополнительный Task) даже в случае тривиальных хвостовых вызовов aync, т.е.

return await Task.Run(...);

Это приводит к ухудшению производительности за счет дополнительных инструкций и распределений. Итак, эмпирическое правило: если ваш метод заканчивается на await ... или return await ..., и это оператор один и только await, тогда в целом безопасно удалять ключевое слово async и прямо верните Task, который вы ожидаете.

Одно потенциально непреднамеренное следствие этого заключается в том, что если исключение выбрано внутри возвращенного Task, внешний метод не будет отображаться в трассировке стека.

В случае с return await ... есть скрытый заголовок. Если awaiter явно не сконфигурирован, чтобы не продолжать в захваченном контексте через ConfigureAwait(false), тогда внешний Task (тот, который был создан для машины состояния async) не может перейти в завершенное состояние до окончательной обратной передачи в SynchronizationContext (захвачен непосредственно перед await). Это не имеет никакой реальной цели, но все равно может привести к тупиковой ситуации, если по какой-то причине вы заблокируете внешнюю задачу (здесь подробное объяснение о том, что происходит в этом случае).

Ответ 2

Итак, какой подход правильный?

Ни.

Если у вас есть синхронная работа, тогда API должен быть синхронным:

public object Foo()
{
    // some heavy synchronous stuff.

    return new object();
}

Если вызывающий метод может блокировать свой поток (т.е. он представляет собой вызов ASP.NET или работает в потоке пула потоков), он просто вызывает его напрямую:

var result = Foo();

И если вызывающий поток не может заблокировать поток (т.е. он работает в потоке пользовательского интерфейса), тогда он может запустить Foo в пуле потоков:

var result = await Task.Run(() => Foo());

Как я описываю в своем блоге, Task.Run должен использоваться для вызова, а не для реализации.


Пример реального мира

(это совершенно другой сценарий)

Какая реализация Task GetUserByNameAsync (string userName) будет правильной?

Любой из них является приемлемым. Тот, у которого есть async и await, имеет некоторые дополнительные накладные расходы, но он не будет заметен во время выполнения (при условии, что вы await ing действительно делают I/O, что истинно в общем случае).

Обратите внимание, что если в методе есть другой код, то лучше с async и await. Это распространенная ошибка:

Task<string> MyFuncAsync()
{
  using (var client = new HttpClient())
    return client.GetStringAsync("http://www.example.com/");
}

В этом случае HttpClient устанавливается до завершения задачи.

Еще одна вещь, которую следует отметить, заключается в том, что исключения перед возвратом задачи различаются по-разному:

Task<string> MyFuncAsync(int id)
{
  ... // Something that throws InvalidOperationException
  return OtherFuncAsync();
}

Поскольку нет async, исключение не помещается в возвращаемую задачу; он бросается напрямую. Это может запутать вызывающий код, если он делает что-то более сложное, чем просто await выполнить задачу:

var task1 = MyFuncAsync(1); // Exception is thrown here.
var task2 = MyFuncAsync(2);
...
try
{
  await Task.WhenAll(task1, task2);
}
catch (InvalidOperationException)
{
  // Exception is not caught here. It was thrown at the first line.
}