带有 Select 的 Task.WhenAll 是一把枪——但为什么呢?

Joh*_*lly 7 linq task threadpool async-await

考虑:您有一组用户 ID,并希望从 API 加载由其 ID 表示的每个用户的详细信息。您希望将所有这些用户打包到某种集合中并将其发送回调用代码。并且您想使用 LINQ。

像这样的东西:

var userTasks = userIds.Select(userId => GetUserDetailsAsync(userId));
var users = await Task.WhenAll(tasks); // users is User[]
Run Code Online (Sandbox Code Playgroud)

当我的用户相对较少时,这对我的应用程序来说很好。但是,它出现了无法扩展的情况。当它到达成千上万的用户时,这导致同时触发了数千个 HTTP 请求,并且开始发生不好的事情。我们不仅意识到我们正在对我们正在使用的 API 发起拒绝服务攻击,而且我们还通过线程饥饿使我们自己的应用程序崩溃。

不是骄傲的一天。

一旦我们意识到我们的困境的原因是Task.WhenAll/Select组合,我们就能够摆脱这种模式。但我的问题是:

这里出了什么问题?

当我阅读有关该主题的内容时,Mark Heath 的异步反模式列表中的#6 似乎很好地描述了这种情况:“过度并行化”:

现在,这确实“有效”,但是如果有 10,000 个订单呢?我们已经用数千个任务淹没了线程池,可能会阻止其他有用的工作完成。如果 ProcessOrderAsync 对另一个服务(如数据库或微服务)进行下游调用,我​​们可能会因调用量过高而使该服务过载。

这真的是原因吗?我问的越多,我对async/ 的理解await就越不清晰。从许多文章中可以清楚地看出“线程不是任务”。这很酷,但我的代码似乎耗尽了 ASP.NET Core 可以处理的线程数。

那么是这样吗?我的Task.WhenAllSelect组合是否耗尽了线程池或类似的东西?或者还有其他我不知道的解释吗?

更新:

我把这个问题变成了一篇博客文章,里面有更多细节/华夫饼。你可以在这里找到它:https : //blog.johnnyreilly.com/2020/06/taskwhenall-select-is-footgun.html

Ste*_*ary 6

您在这里已经有了一个很好的答案,但只是插话一下:

创建数千个任务没有问题。它们不是线程。

核心问题是你对 API 的使用太多了。因此,最好的解决方案将改变您调用该 API 的方式:

  1. 您真的需要一次性提供数千名用户的用户详细信息吗?如果这是用于仪表板显示,则更改 API 以强制分页;如果这是批处理过程,那么看看是否可以直接从批处理过程访问数据。
  2. batch如果该 API 支持的话,请使用该 API 的路由。
  3. 如果可能的话使用缓存。
  4. 最后,如果上述方法均不可行,请考虑限制 API 调用。

异步节流的标准模式是使用SemaphoreSlim,如下所示:

使用 varthrottler = new SemaphoreSlim(10);
var userTasks = userIds.Select(异步 userId =>
{
  等待throttler.WaitAsync();
  尝试 { 等待 GetUserDetailsAsync(userId); }
  最后{throttler.Release(); }
});
var users = wait Task.WhenAll(tasks); // 用户是 User[]

同样,只有当您无法进行设计更改以避免首先避免数千次 API 调用时,这种限制才是最好的。


Jam*_*ing 5

N+1 问题

将线程、任务、异步、并行性放在一边,您所描述的是一个 N+1 问题,对于发生在您身上的事情,这是要避免的。当 N(您的用户数)很小时,一切都很好,但随着用户的增长,它会停止。

您可能想找到不同的解决方案。必须对所有用户都做这个操作吗?如果是这样,那么可能会切换到后台进程并为每个用户扇出

回到footgun(顺便说一句,我不得不查一下)。

任务是一种承诺,类似于 JavaScript。在 .NET 中,它们可能在一个单独的线程上完成——通常是线程池中的一个线程。

在 .NET Core 中,对于几乎肯定会出现的 HTTP 请求,如果未完成和等待点,它们通常会在单独的线程上完成。

您可能已经耗尽了线程池,但是由于您正在发出 HTTP 请求,我怀疑您已经耗尽了并发出站 HTTP 请求的数量。“ASP.NET 托管应用程序的默认连接限制为 10 个,所有其他应用程序的默认连接限制为 2 个。” 请参阅此处的文档。

有没有办法实现一些并行性而不消耗资源(线程或 http 连接)?- 是的。

这是我经常出于这个原因实现的模式,使用Batch()from morelinq

IEnumerable<User> users = Enumerable.Empty<User>();
IEnumerable<IEnumerable<string>> batches = userIds.Batch(10);
foreach (IEnumerable<string> batch in batches)
{
    Task<User> batchTasks = batch.Select(userId => GetUserDetailsAsync(userId));
    User[] batchUsers = await Task.WhenAll(batchTasks);
    users = users.Concat(batchUsers);
}
Run Code Online (Sandbox Code Playgroud)

您仍然会收到 10 个对 的异步 HTTP 请求GetUserDetailsAsync(),并且不会耗尽线程或并发 HTTP 请求(或至少最多使用 10 个)。

现在,如果这是一个频繁使用的操作或服务器GetUserDetailsAsync()在应用程序的其他地方大量使用,当您的系统负载不足时,您可能会遇到相同的限制,因此这种批处理并不总是一个好主意。天啊。

  • 你又开始了另一批 10 人的比赛。十分之九的人几乎立即开始竞争。最后一项需要 11 分钟才能完成。在这 11 分钟内,您正在等待一项任务,而不是启动任何其他任务,即使您的活动任务数量限制为 10 个,并且您本可以在 11 分钟前启动另外 9 个任务。 (3认同)