为什么要使用异步和返回await,而不是直接返回Task<T>?

379

是否存在任何这样的情况,需要编写像这样的方法:

public async Task<SomeResult> DoSomethingAsync()
{
    // Some synchronous code might or might not be here... //
    return await DoAnotherThingAsync();
}

不是这样做:

public Task<SomeResult> DoSomethingAsync()
{
    // Some synchronous code might or might not be here... //
    return DoAnotherThingAsync();
}

这样说是否合理呢?

为什么要使用return await结构,当你可以直接从内部的DoAnotherThingAsync()调用中返回Task<T>?

我看到很多地方都有return await的代码,我想我可能漏掉了什么。但据我所知,在这种情况下不使用async/await关键字,直接返回任务(Task)也是功能等效的。为什么要增加额外的await层的开销呢?


7
我认为你看到这种情况的唯一原因是人们学习的方式是通过模仿,通常(如果没有必要)他们会使用他们能找到的最简单的解决方案。所以人们看到那段代码并使用它,他们发现其可行,从此以后,对他们来说那就是正确的方法...在这种情况下等待是没有用的。 - Fabio Marcolini
22
至少有一个重要的区别:**异常传播**。 - noseratio - open to work
1
@monstro,OP的问题中确实有return语句吗? - David Klempfner
1
https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md#prefer-asyncawait-over-directly-returning-task - Oliver
1
如果DoAnotherThingAsync使用async/await编写,则此处的async/await是多余的,并且异常传播没有区别。 DoAnotherThingAsync已经实现了捕获任何抛出的异常并将其嵌入返回的Task的状态机。您唯一需要在此处添加async/await的时间是,如果调用的方法DoAnotherThingAsync不使用async/await并且可能会引发异常。在这种情况下,可能会在返回任务之前抛出异常,从而改变所需的错误处理方法。那真的是唯一的区别。 - Triynko
显示剩余2条评论
10个回答

268

当在普通方法中使用return和在async方法中使用return await时,有一种狡猾的情况它们会表现得不同:与using(或更一般而言,在try块中的任何return await)结合使用时。

考虑这个方法的两个版本:

Task<SomeResult> DoSomethingAsync()
{
    using (var foo = new Foo())
    {
        return foo.DoAnotherThingAsync();
    }
}

async Task<SomeResult> DoSomethingAsync()
{
    using (var foo = new Foo())
    {
        return await foo.DoAnotherThingAsync();
    }
}

第一种方法将在DoAnotherThingAsync()方法返回之后立即Dispose()Foo对象,这可能比实际完成工作时间要早。这意味着第一个版本可能存在缺陷(因为Foo被过早处理了),而第二个版本将正常工作。


5
为了完整起见,在第一种情况下,您应该返回foo.DoAnotherThingAsync().ContinueWith(_ => foo.Dispose()); - ghord
11
那样做行不通,因为 Dispose() 返回 void。你需要像这样使用:return foo.DoAnotherThingAsync().ContinueWith(t -> { foo.Dispose(); return t.Result; });。但是我不知道为什么要那样做,因为你可以使用第二个选项。 - svick
1
@svick 你是对的,它应该更像 { var task = DoAnotherThingAsync(); task.ContinueWith(_ => foo.Dispose()); return task; }。使用案例非常简单:如果你在 .NET 4.0 上(就像大多数人一样),你仍然可以用这种方式编写异步代码,而且从 4.5 应用程序中调用时也能正常工作。 - ghord
2
如果你正在使用 .Net 4.0,并且想编写异步代码,你应该使用 Microsoft.Bcl.Async。另外,你的代码仅在返回的 Task 完成后才处理 Foo,我不太喜欢这种写法,因为它会引入不必要的并发。 - svick
1
@svick,您的代码也会等待任务完成。此外,Microsoft.Bcl.Async 对我来说是无法使用的,因为它依赖于 KB2468871,并且在使用.NET 4.0异步代码库与正确的4.5异步代码时会发生冲突。 - ghord
显示剩余7条评论

142

如果您不需要async(即,可以直接返回Task),那么不要使用async

有些情况下,return await是有用的,比如如果您有两个异步操作需要执行:

var intermediate = await FirstAsync();
return await SecondAwait(intermediate);

了解更多关于async性能的内容,请参阅Stephen Toub在MSDN文章视频上的介绍。

更新:我已经写了一篇博客文章,其中详细讲解了这个话题。


20
你能否解释一下为什么在第二种情况中使用await是有用的?为什么不直接使用return SecondAwait(intermediate); - Matt Smith
2
我和Matt有同样的问题,如果这样写 return SecondAwait(intermediate); 是否也能达到目标呢?我认为在这里使用 return await 也是多余的... - TX_
33
那是无法编译的。如果你想在第一行使用 await ,那么第二行也必须使用它。 - svick
10
它确实无法编译。假设SecondAwait的返回类型为string,错误消息是:“CS4016:由于这是异步方法,返回表达式必须是类型为'string'而不是'Task <string>'”。 - svick
3
@svick 你说得对。一旦你将方法声明为“async”,你就失去了直接返回Task的能力。我被自己的一些代码搞糊涂了,这些代码直接从另一个异步方法中返回Task而不是等待它。 - Tom Lint
显示剩余4条评论

30

你想要这么做的唯一原因是如果之前的代码中有其他的await,或者在返回结果之前以某种方式操作它。另一种可能发生的情况是通过try/catch更改异常处理的方式。如果你没有做任何这样的事情,那么你是对的,没有理由增加将方法变为async的开销。


4
和Stephen的回答一样,即使之前的代码中有其他的await,我也不明白为什么需要使用return await(而不是只返回子调用的任务)。能否请您解释一下原因? - TX_
12
如果您想要移除“async”,那么您如何等待第一个任务呢?如果您想使用“await”,则需要将该方法标记为“async”。如果该方法被标记为“async”并且您在代码中有一个早于第二个异步操作的“await”,则需要“await”第二个异步操作才能正确获得类型。如果您只是删除了“await”,则它将无法通过编译,因为返回值将不是正确的类型。由于该方法是“async”,因此结果始终被包装在一个任务中。 - Servy
13
请尝试这两个。第一个可以编译通过,第二个则不行。错误信息会告诉你问题所在。你没有返回正确的类型。在async方法中,你不返回任务,而是返回任务的结果,然后再将其包装。 - Servy
3
@Servy,当然,你是正确的。在后面的情况下,我们会明确返回“Task<Type>”,而“async”则要求返回“Type”(编译器本身将其转换为“Task<Type>”)。 - noseratio - open to work
3
当然,async只是显式地连接连续操作的语法糖。你不 需要 async 做任何事情,但在进行几乎任何非平凡的异步操作时,使用 async 就会极大地简化工作。例如,您提供的代码实际上不会像您想要的那样传播错误,在更复杂的情况下,正确地这样做变得更加困难。虽然你从未需要 async,但我描述的情况是使用它增加价值的情况。 - Servy
显示剩余2条评论

22

另外一种情况是您可能需要等待结果的:

async Task<IFoo> GetIFooAsync()
{
    return await GetFooAsync();
}

async Task<Foo> GetFooAsync()
{
    var foo = await CreateFooAsync();
    await foo.InitializeAsync();
    return foo;
}

在这种情况下,需要等待GetFooAsync的结果后才能等待GetIFooAsync()的结果,因为两种方法中T的类型不同,而Task<Foo>不能直接赋值给Task<IFoo>。但是,如果你等待结果,它只变成了Foo,这个类型是可以直接赋值给IFoo的。然后,异步方法只是将结果重新打包到Task<IFoo>中即可。

1
同意,这真的很恼人 - 我相信根本原因在于Task<>是不变的。 - StuartLC

22
如果您不使用 return await,在调试或异常日志打印时可能会破坏堆栈跟踪。
当您返回任务时,方法已经完成其目的并退出了调用堆栈。当您使用 return await 时,它仍留在调用堆栈中。
例如: 使用 await 时的调用堆栈:A 等待从 B 返回的任务 => B 等待从 C 返回的任务
不使用 await 时的调用堆栈:A 等待 B 返回的任务,该任务已由 C 返回。

2
这里有一篇很好的文章:https://vkontech.com/exploring-the-async-await-state-machine-stack-traces-and-refactoring-pitfalls/ - Jonatan Dragon

12

将本来简单的“thunk”方法变为异步会在内存中创建一个异步状态机,而非异步版本则不会。虽然这通常会让人们使用非异步版本,因为它更高效(这是真的),但这也意味着,在出现停顿的情况下,你没有证据表明该方法涉及"返回/继续堆栈",这有时会使理解停顿更加困难。

因此,在性能不关键的情况下(通常如此),我会在所有这些thunk方法上加上异步,以便在之后帮助我诊断停顿的异步状态机,并确保如果这些thunk方法随时间演化,它们将确保返回故障任务而不是抛出异常。


5

这个问题也让我感到困惑,我觉得之前的答案忽略了您实际的问题:

当您可以直接从内部调用DoAnotherThingAsync()返回Task时,为什么要使用return await构造?

有时候您确实需要一个 Task<SomeType>,但大多数情况下您实际上需要一个 SomeType 实例,也就是任务的结果。

根据您的代码:

async Task<SomeResult> DoSomethingAsync()
{
    using (var foo = new Foo())
    {
        return await foo.DoAnotherThingAsync();
    }
}

一个对语法不熟悉的人(比如我)可能会认为这个方法应该返回一个Task<SomeResult>,但是由于它被标记为async,这意味着它的实际返回类型是SomeResult。 如果你只使用return foo.DoAnotherThingAsync(),那么你会返回一个Task,这将无法编译。正确的方式是返回任务的结果,因此需要使用return await


1
"实际返回类型"。嗯?async/await不会改变返回类型。在你的例子中,var task = DoSomethingAsync();会给你一个任务,而不是T - jamesSampica
5
@Shoe,我不确定我是否完全理解了async/await的含义。据我理解, Task task = DoSomethingAsync()Something something = await DoSomethingAsync()都可以工作。第一个代码行会给你一个任务对象,而第二个代码行因为使用了await关键字,在任务完成后会给你结果。例如,我可以这样写:Task task = DoSomethingAsync(); Something something = await task; - heltonbiker

3

你可能想要使用return await的另一个原因是:使用await语法可以避免Task<T>ValueTask<T>类型之间不匹配的问题。例如,下面的代码可以正常工作,即使SubTask方法返回Task<T>,但它的调用者返回ValueTask<T>

async Task<T> SubTask()
{
...
}

async ValueTask<T> DoSomething()
{
  await UnimportantTask();
  return await SubTask();
}

如果你在DoSomething()这一行跳过了await,你将会收到一个编译器错误CS0029:

无法隐式地将类型'System.Threading.Tasks.Task<BlaBla>'转换为'System.Threading.Tasks.ValueTask<BlaBla>'。

如果你尝试显式地进行类型转换,你也会得到CS0030。
顺便说一下,这是.NET Framework。我完全可以预见到会有人评论说“这在.NET 假设的版本中已经修复了”,但我没有测试过。 :)

将其称为转换有点误导,我认为这与转换无关。您上面所做的是从SubTask获取T结果,然后返回该结果,而不是任务。因此,T是相同的T,没有任何转换。 - KwahuNashoba
2
@KwahuNashoba 现在已经编辑过了。看起来怎么样? - Sedat Kapanoglu
1
太好了!感谢你的积极主动 :) - KwahuNashoba

1
这将防止在编译时在幕后创建任务状态机。但根据David的说法,出于以下原因,你应该优先选择async/await而不是直接返回Task
  • 异步和同步异常被规范化为始终是异步的。
  • 代码更容易修改(例如考虑添加using语句)。
  • 异步方法的诊断更容易(调试挂起等)。
  • 抛出的异常将自动包装在返回的Task中,而不是用实际异常意外地使调用者感到惊讶。
  • 异步局部变量不会泄漏出异步方法。如果在非异步方法中设置异步局部变量,它将“泄漏”出该调用。
注意:使用异步状态机而不是直接返回Task时,存在性能考虑。直接返回Task始终更快,因为它执行的工作较少,但你最终会改变行为,并可能失去一些异步状态机的好处。
如果你真的想摆脱异步,你应该知道异步是具有传染性的:
一旦你使用异步,所有的调用者都应该是异步的,因为除非整个调用栈都是异步的,否则异步的努力就毫无意义。在许多情况下,部分异步可能比完全同步更糟糕。因此,最好一次性全部转为异步。

1

非 await 方法的另一个问题是有时候你无法隐式转换返回类型,特别是对于 Task<IEnumerable<T>>

async Task<List<string>> GetListAsync(string foo) => new();

// This method works
async Task<IEnumerable<string>> GetMyList() => await GetListAsync("myFoo");

// This won't work
Task<IEnumerable<string>> GetMyListNoAsync() => GetListAsync("myFoo");

错误信息:

无法将类型为 'System.Threading.Tasks.Task<System.Collections.Generic.List>' 的值隐式转换为 'System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable>'


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