为什么在控制器中应该返回Task<IActionResult>?

9

我已经尝试了相当长一段时间,但无法理解为什么需要将每个控制器端点声明为异步方法。

让我们通过GET请求来看待这个问题。

对于简单的请求,我的方式就是处理请求并发送响应。

[HttpGet]
public IActionResult GetUsers()
{
      // Do some work and get a list of all user-objects.
      List<User> userList = _dbService.ReadAllUsers(); 
      return Ok(userList);
} 

以下是我经常看到的 async Task<IActionResult> 选项,它与上面的方法执行相同的操作,但方法本身返回一个 Task。有人可能会认为这个方法更好,因为您可以有多个请求同时进行异步处理,但是我使用相同的结果测试了这种方法和上面的方法。两者都可以同时处理多个请求。那么为什么我应该选择这个签名而不是上面的签名?我只能看到这样做的负面影响,例如将代码转换为状态机,因为它是异步的。
[HttpGet]
public async Task<IActionResult> GetUsers()
{
      // Do some work and get a list of all user-objects.
      List<User> userList = _dbService.ReadAllUsers(); 
      return Ok(userList);
} 

下面这种方法也是我不太理解的。我看到很多代码都采用了这样的设置。一个async方法,他们用await来等待结果,然后再返回。这样等待会使代码重新变成顺序执行,而失去了多任务/多线程的好处。我的理解对吗?
[HttpGet]
public async Task<IActionResult> GetUsers()
{
      // Do some work and get a list of all user-objects.
      List<User> userList = await _dbService.ReadAllUsersAsync(); 
      return Ok(userList);
} 

如果您能为我提供一些事实,让我了解是否由于误解概念而错误地开发,或者继续像现在这样进行开发才是正确的,那将是很好的。


1
由于异步操作返回一个任务,其结果直到任务完成后才可用。 - Ashkan Mobayen Khiabani
实际上,您应该从这个角度来看待它:当您创建一个基于任务的方法时,您实际上是在说您的方法正在使用一些异步操作。这与调用者本身无关。调用者只知道它应该等待该操作。 - Silvermind
6个回答

9
请阅读ASP.NET上异步请求处理的介绍中的同步与异步处理部分

两种方法都可以同时处理多个请求。

是的。这是因为ASP.NET是多线程的。所以在同步情况下,你只需要有多个线程调用相同的操作方法(在不同的控制器实例上)。
对于非多线程平台(例如Node.js),你必须使代码异步才能在同一进程中处理多个请求。但是在ASP.NET上,这是可选的。

像这样等待会使代码变成顺序执行而不是享受多任务/多线程的好处。

是的,它是“顺序执行”,但不是“同步执行”。它是“顺序执行”,因为异步方法一次执行一个语句,并且直到异步方法完成后该请求才算完成。但它不是“同步”的,因为同步代码也是按顺序执行,但它会在方法完成之前“阻塞一个线程”。

那我为什么要选择这个签名而不是上面的签名?

如果你的后端可以扩展,那么异步操作方法的好处就是可扩展性。具体来说,异步操作方法在异步操作进行中会释放其线程 - 在这种情况下,当数据库执行查询时,GetUsers不会占用线程。
在测试环境中很难看到这种好处,因为服务器有多余的线程,因此调用异步方法10次(不占用任何线程)和调用同步方法10次(占用10个线程,还有54个线程空闲)之间没有可观察的区别。你可以人为地限制ASP.NET服务器的线程数,然后进行一些测试以查看差异。
在实际的服务器中,通常希望尽可能使其异步化,以便您的线程可用于处理其他请求。或者,如此处所述
请注意,异步代码不会取代线程池。这不是线程池或者异步代码的问题,而是线程池异步代码的问题。异步代码使您的应用程序能够最大程度地利用线程池。它将现有的线程池提升到了另一个层次。
请注意上面的"如果";这尤其适用于现有代码。如果您只有一个 SQL 服务器后端,并且几乎所有操作都需要查询数据库,那么将它们更改为异步操作可能没有什么用处,因为可扩展性瓶颈通常是数据库服务器而不是 Web 服务器。但是,如果您的 Web 应用程序可以使用线程来处理非数据库请求,或者如果您的数据库后端是可扩展的(NoSQL,SQL Azure 等),那么将操作方法更改为异步操作很可能会有所帮助。
对于新代码,默认情况下建议使用异步方法。异步方法更好地利用了服务器资源,更加云友好(即按使用量付费的托管服务更加便宜)。

@ZesaRex: 不是这样的ReadAllUsersAsync 向数据库发送查询,然后返回一个未完成的任务。当等待该任务时,线程将返回到线程池,并可以在数据库执行查询时处理其他请求。 - Stephen Cleary
1
@ZesaRex:Thread.Sleep 的异步等效是 await Task.Delay,它不会阻塞线程。如果你在谈论使用 async 但没有使用 await 的真实代码,那么编译器会警告你该方法实际上会同步运行。 - Stephen Cleary
好的,这意味着我的问题得到了肯定的答复!:) 谢谢 - Zesa Rex

4
如果你的DB服务类有一个异步方法来获取用户,则应该会看到一些好处。一旦请求发送到DB,它就在等待来自另一端服务的网络或磁盘响应。当它这样做时,线程可以被释放来做其他工作,比如为其他请求提供服务。在非异步版本中,线程将只是阻塞并等待响应。
通过异步控制器操作,您还可以获得CancellationToken,如果标记被激活,则允许您尽早退出,因为连接端的客户端已终止连接(但这可能不适用于所有Web服务器)。
[HttpGet]
public async Task<IActionResult> GetUsers(CancellationToken ct)
{
    ct.ThrowIfCancellationRequested();

    // Do some work and get a list of all user-objects.
    List<User> userList = await _dbService.ReadAllUsersAsync(ct); 
    return Ok(userList);
} 

因此,如果您有一组昂贵的操作且客户端断开连接,您可以停止处理该请求,因为客户端永远不会收到它。这样可以释放应用程序的时间和资源来处理客户端对返回结果感兴趣的请求。

然而,如果您有一个非常简单的动作,不需要async调用,那么我可能不必担心它,并将其保留为原样。


提到使用“CancellationToken”进行操作取消是一个非常好的观点。我认为如果您在答案中进一步扩展这个概念并解释为什么它对当前主题有帮助,那将作为一条很棒的信息。 - zhulien

2

如您所知,ASP.NET 基于多线程的请求执行模型。简单来说,每个请求在自己的线程中运行,与其他单线程执行方法(例如 Node.js 中的事件循环)相反。

现在,线程是有限的资源。如果你耗尽了线程池(可用线程),你就不能高效地处理任务(或者在大多数情况下根本无法处理)。如果你对你的操作处理程序使用同步执行,你会占用来自池中的那些线程(即使它们不需要被占用)。重要的是要理解这些线程处理请求,它们不执行输入/输出。I/O由独立于ASP.NET请求线程的单独进程处理。因此,如果在你的操作中有一个DB获取操作,需要15秒才能执行完成,你就强制当前线程等待空闲15秒钟,以便DB结果返回,从而可以继续执行代码。因此,如果你有50个这样的请求,你将有50个线程处于基本休眠模式。显然,这不是非常可扩展的,并且很快就会成为一个问题。为了有效地利用你的资源,在达到I/O操作时保留正在执行的请求状态并释放线程以同时处理另一个请求是一个好主意。当I/O操作完成时,状态将被重新组装并赋予池中的一个空闲线程以恢复处理。这就是为什么要使用异步处理。它让你更有效地使用有限的资源,并防止线程饥饿。当然,这不是最终解决方案。它帮助你为更高的负载扩展应用程序。如果你没有这个需求,不要使用它,因为它会增加开销。

1

你在这里缺少了一些基础知识。

当你使用async Task<>时,实际上是在说,“异步运行所有I/O代码并释放处理线程以进行处理”。

在规模上,你的应用程序将能够每秒提供更多的请求,因为你的I/O不会占用你的CPU密集型工作,反之亦然。

现在你可能看不到太多好处,因为你可能正在本地测试,手头有足够的资源。

参见


你的第一个例子会绑定一个线程。你的第二个例子无法编译,因为没有使用 await。只要没有后端瓶颈,第三个例子就可以扩展。是的,我已经测试过这种情况。 - Kit
谢谢。顺便说一下,第二个例子实际上可以编译。它只会被视为同步。 - Zesa Rex
1
哦,你说得对。我习惯于在IDE中看到那个红色的波浪线,然后只是添加“async”。我将警告设置为错误。 - Kit

0
异步操作返回一个任务(它不是结果,而是一种承诺,一旦任务完成就会有结果)。
异步方法应该被await,以便等待任务完成。如果你await一个异步方法,返回值将不再是任务,而是你期望的结果。
想象一下这个异步方法:
async Task<SomeResult> SomeMethod()
{
    ...
} 

以下代码:

var result = SomeMethod(); // the result is a Task<SomeResult> which may have not completed yet

var result = await SomeMethod(); // the result is type of SomeResult

现在,为什么我们使用async方法而不是同步方法:

想象一下,一个方法正在执行可能需要很长时间才能完成的工作,当它完成时,同步所有其他请求将等待直到这个长时间的任务执行完成,因为它们都发生在同一个线程中。然而,当它是异步的时候,任务将在(可能是另一个)线程中完成,并且不会阻塞其他请求。


因为异步操作不会阻塞您的其他代码并在另一个线程中运行。让我更新我的答案。 - Ashkan Mobayen Khiabani

0

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