适当的实现返回 Task<T> 的方法的方式

19

为简单起见,让我们假设有一个方法,在执行一些繁重的操作时应该返回一个对象。有两种实现方法:

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();
    }
}

检查生成的IL后,发现生成了两个完全不同的东西:

.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
}

正如您在第一个示例中所看到的,逻辑是简单明了的,创建了Lambda函数,然后生成了对Task.Run的调用,并返回结果。在第二个示例中,创建了AsyncTaskMethodBuilder实例,然后实际构建并返回任务。由于我一直期望foo方法在更高层次上被称为await Foo(),因此我一直使用第一个示例。但是,我更经常看到后者。那么哪种方法是正确的?每种方法都有什么优缺点?


现实世界的例子

假设我们有一个UserStore,它有一个方法Task<User> GetUserByNameAsync(string userName),该方法在Web 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
踩个负分?好吧,我至少想听听这个问题有什么问题。 - Leri
2个回答

12

那么哪种方法是正确的?

都不正确。

如果你有需要同步处理的任务,那么API应该是同步的

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

    return new object();
}

如果调用方法可以阻塞其线程(即它是一个ASP.NET调用,或者它正在运行于线程池线程上),则直接调用它:

var result = Foo();

如果调用线程无法阻止它的线程(即它正在UI线程上运行),则它可以在线程池上运行 Foo

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

如我在博客中所描述的,Task.Run 应该用于调用而不是实现


真实世界的例子

(这是完全不同的情况)

哪种 Task GetUserByNameAsync(string userName) 的实现是正确的?

任何一种都可以接受。带有 asyncawait 的方法会有一些额外的开销,但在运行时不会有明显的影响(假设你所等待的东西确实在进行 I/O 操作,这在通常情况下是成立的)。

请注意,如果方法中还有其他代码,则使用带有 asyncawait 的方法会更好。这是一个常见的错误:

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.
}

首先,关于你的回答中的第一部分,“Task.Run”只是为了演示。至于第二部分,如果一个应用程序使用异常来控制应用程序流程,你是完全正确的。然而,在我的当前架构中,我让异常保持异常状态,也就是说,如果抛出异常,意味着发生了非常严重的错误,所以我不会像你展示的那样处理它们。 - Leri
你不使用 using 关键字来处理数据库、http客户端和其他类吗?即使你无法处理异常,你仍然应该进行适当的清理 @Leri。这个答案的第二部分是重要的。 - Benjamin Gruenbaum
@BenjaminGruenbaum 对于Web开发来说,我不需要这样做。我让作用域为我服务。 - Leri

11

从IL(Intermediate Language,中间语言)可以看出,async/await即使在普通的异步尾递归调用的情况下也会创建一个状态机(以及额外的Task)。

return await Task.Run(...);

由于额外的指令和分配,这会导致性能下降。因此,经验法则是:如果您的方法以 await ...return await ... 结尾,并且它是唯一的 await 语句,那么通常可以安全地删除 async 关键字并直接返回要等待的 Task

这样做可能带来一个潜在的意外后果,即如果抛出异常在返回的 Task 内部发生,则外部方法将不会出现在堆栈跟踪中。

return await ... 的情况也有一个隐藏的问题。如果等待者没有显式地配置为通过 ConfigureAwait(false) 不继续在捕获的上下文上运行,则外部任务(由异步状态机为您创建的任务)不能转换为已完成状态,直到最后一次回调到 SynchronizationContext(在 await 之前捕获)完成为止。这没有任何实际用途,但如果您由于某种原因在外部任务上阻塞,仍然可能导致死锁(这里详细解释了在这种情况下会发生什么)。


谢谢。讲解得很好。在我看来,堆栈跟踪从来都不值得性能损失。除此之外,人们使用微不足道的异步尾调用还有什么原因吗? - Leri
@Leri,我想不出其他原因来做这件事,而且我在Roslyn团队的讨论中也没有看到将其作为编译器优化的内容:https://github.com/dotnet/roslyn/issues/1981 - Kirill Shlenskiy
我明白了。好的,从这个角度来看,我认为我的问题的答案非常明显。谢谢。 - Leri

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接