Task.WhenAll 和 Select 是步兵枪 - 但为什么呢?
Task.WhenAll with Select is a footgun - but why?
考虑:您有一组用户 ID,并希望从 API 中加载每个用户的 ID 所代表的详细信息。您希望将所有这些用户打包到某种集合中并将其发送回调用代码。并且您想使用 LINQ。
像这样:
var userTasks = userIds.Select(userId => GetUserDetailsAsync(userId));
var users = await Task.WhenAll(tasks); // users is User[]
当我的用户相对较少时,这对我的应用来说很好。但是,有一点它没有扩展。当它达到成千上万的用户时,这会导致同时触发数以千计的 HTTP 请求,并且 坏事开始发生 。我们不仅意识到我们正在对我们正在使用的 API 发起拒绝服务攻击,我们还通过线程饥饿将我们自己的应用程序带到了崩溃的地步。
不是值得骄傲的一天。
一旦我们意识到问题的根源是 Task.WhenAll
/ Select
组合,我们就能够摆脱这种模式。但我的问题是:
这里出了什么问题?
当我阅读该主题时,Mark Heath's list of Async antipatterns 上的#6 似乎很好地描述了这种情况:“过度并行化”:
Now, this does "work", but what if there were 10,000 orders? We've flooded the thread pool with thousands of tasks, potentially preventing other useful work from completing. If ProcessOrderAsync makes downstream calls to another service like a database or a microservice, we'll potentially overload that with too high a volume of calls.
真的是这个原因吗?我问,因为我对 async
/ await
的理解随着我对这个主题的了解越来越多而变得越来越不清楚。从许多文章中可以清楚地看出“线程不是任务”。这很酷,但我的代码似乎耗尽了 ASP.NET Core 可以处理的线程数。
是这样吗?我的 Task.WhenAll
和 Select
组合是否耗尽了线程池或类似资源?或者还有其他我不知道的解释吗?
更新:
我把这个问题变成了一个博客post,其中包含更多细节/华夫饼。您可以在这里找到它:https://blog.johnnyreilly.com/2020/06/taskwhenall-select-is-footgun.html
虽然 there is no thread 等待异步操作,如果异步操作是纯的,则有一个线程用于继续,因此假设您的 GetUserDetailsAsync
将等待一些 IO 绑定操作继续(解析输出,返回结果...) 将需要在某个线程上 运行 以便可以设置由 GetUserDetailsAsync
创建的 Task.Result
,因此它们中的每一个都将等待来自线程池完成。
N+1 题
将线程、任务、异步、并行性放在一边,你描述的是一个 N+1 问题,这正是发生在你身上的事情要避免的事情。当 N(您的用户数)很小时一切都很好,但随着用户的增长它会逐渐停止。
您可能想找到不同的解决方案。你必须为所有用户做这个操作吗?如果是这样,那么也许可以切换到后台进程并为每个用户 fan-out。
回到脚枪(顺便说一句,我不得不查一下)。
任务是一个承诺,类似于 JavaScript。在 .NET 中,它们可能在单独的线程上完成 - 通常是线程池中的线程。
在 .NET Core 中,他们通常在一个单独的线程上完成,如果没有完成和等待的点,对于几乎可以肯定是这样的 HTTP 请求。
您可能已经耗尽了线程池,但由于您正在发出 HTTP 请求,我怀疑您已经耗尽了并发出站 HTTP 请求的数量。 “ASP.NET 托管应用程序的默认连接限制为 10,所有其他应用程序的默认连接限制为 2。” 请参阅文档 here。
有没有一种方法可以实现某种并行性而不耗尽资源(线程或 http 连接)? - 是的。
这是我经常出于这个原因实施的模式,使用 morelinq 中的 Batch()
。
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);
}
您仍然收到十个异步 HTTP 请求到 GetUserDetailsAsync()
,并且您不会耗尽线程或并发 HTTP 请求(或者至少用完 10 个)。
现在,如果这是一个频繁使用的操作,或者具有 GetUserDetailsAsync()
的服务器在应用程序的其他地方被大量使用,当您的系统处于负载下时,您可能会达到相同的限制,因此这种批处理并不总是好主意。 YMMV.
你在这里已经有了一个很好的答案,但只是想补充一下:
创建数千个任务没有问题。它们不是线程。
核心问题是您对 API 的打击太多了。所以最好的解决方案将改变你如何称呼它 API:
- 您真的需要一次性获取成千上万用户的详细信息吗?如果这是用于仪表板显示,请更改 API 以强制分页;如果这是用于批处理,那么看看您是否可以直接从批处理访问数据。
- 如果 API 支持
batch
路由。
- 尽可能使用缓存。
- 最后,如果上述 none 可行,请考虑限制 API 调用。
异步节流的标准模式是使用 SemaphoreSlim
,如下所示:
using var throttler = new SemaphoreSlim(10);
var userTasks = userIds.Select(async userId =>
{
await throttler.WaitAsync();
try { await GetUserDetailsAsync(userId); }
finally { throttler.Release(); }
});
var users = await Task.WhenAll(tasks); // users is User[]
同样,只有当您无法进行设计更改以避免一开始就避免数千次 API 调用时,这种限制才是最好的。
考虑:您有一组用户 ID,并希望从 API 中加载每个用户的 ID 所代表的详细信息。您希望将所有这些用户打包到某种集合中并将其发送回调用代码。并且您想使用 LINQ。
像这样:
var userTasks = userIds.Select(userId => GetUserDetailsAsync(userId));
var users = await Task.WhenAll(tasks); // users is User[]
当我的用户相对较少时,这对我的应用来说很好。但是,有一点它没有扩展。当它达到成千上万的用户时,这会导致同时触发数以千计的 HTTP 请求,并且 坏事开始发生 。我们不仅意识到我们正在对我们正在使用的 API 发起拒绝服务攻击,我们还通过线程饥饿将我们自己的应用程序带到了崩溃的地步。
不是值得骄傲的一天。
一旦我们意识到问题的根源是 Task.WhenAll
/ Select
组合,我们就能够摆脱这种模式。但我的问题是:
这里出了什么问题?
当我阅读该主题时,Mark Heath's list of Async antipatterns 上的#6 似乎很好地描述了这种情况:“过度并行化”:
Now, this does "work", but what if there were 10,000 orders? We've flooded the thread pool with thousands of tasks, potentially preventing other useful work from completing. If ProcessOrderAsync makes downstream calls to another service like a database or a microservice, we'll potentially overload that with too high a volume of calls.
真的是这个原因吗?我问,因为我对 async
/ await
的理解随着我对这个主题的了解越来越多而变得越来越不清楚。从许多文章中可以清楚地看出“线程不是任务”。这很酷,但我的代码似乎耗尽了 ASP.NET Core 可以处理的线程数。
是这样吗?我的 Task.WhenAll
和 Select
组合是否耗尽了线程池或类似资源?或者还有其他我不知道的解释吗?
更新:
我把这个问题变成了一个博客post,其中包含更多细节/华夫饼。您可以在这里找到它:https://blog.johnnyreilly.com/2020/06/taskwhenall-select-is-footgun.html
虽然 there is no thread 等待异步操作,如果异步操作是纯的,则有一个线程用于继续,因此假设您的 GetUserDetailsAsync
将等待一些 IO 绑定操作继续(解析输出,返回结果...) 将需要在某个线程上 运行 以便可以设置由 GetUserDetailsAsync
创建的 Task.Result
,因此它们中的每一个都将等待来自线程池完成。
N+1 题
将线程、任务、异步、并行性放在一边,你描述的是一个 N+1 问题,这正是发生在你身上的事情要避免的事情。当 N(您的用户数)很小时一切都很好,但随着用户的增长它会逐渐停止。
您可能想找到不同的解决方案。你必须为所有用户做这个操作吗?如果是这样,那么也许可以切换到后台进程并为每个用户 fan-out。
回到脚枪(顺便说一句,我不得不查一下)。
任务是一个承诺,类似于 JavaScript。在 .NET 中,它们可能在单独的线程上完成 - 通常是线程池中的线程。
在 .NET Core 中,他们通常在一个单独的线程上完成,如果没有完成和等待的点,对于几乎可以肯定是这样的 HTTP 请求。
您可能已经耗尽了线程池,但由于您正在发出 HTTP 请求,我怀疑您已经耗尽了并发出站 HTTP 请求的数量。 “ASP.NET 托管应用程序的默认连接限制为 10,所有其他应用程序的默认连接限制为 2。” 请参阅文档 here。
有没有一种方法可以实现某种并行性而不耗尽资源(线程或 http 连接)? - 是的。
这是我经常出于这个原因实施的模式,使用 morelinq 中的 Batch()
。
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);
}
您仍然收到十个异步 HTTP 请求到 GetUserDetailsAsync()
,并且您不会耗尽线程或并发 HTTP 请求(或者至少用完 10 个)。
现在,如果这是一个频繁使用的操作,或者具有 GetUserDetailsAsync()
的服务器在应用程序的其他地方被大量使用,当您的系统处于负载下时,您可能会达到相同的限制,因此这种批处理并不总是好主意。 YMMV.
你在这里已经有了一个很好的答案,但只是想补充一下:
创建数千个任务没有问题。它们不是线程。
核心问题是您对 API 的打击太多了。所以最好的解决方案将改变你如何称呼它 API:
- 您真的需要一次性获取成千上万用户的详细信息吗?如果这是用于仪表板显示,请更改 API 以强制分页;如果这是用于批处理,那么看看您是否可以直接从批处理访问数据。
- 如果 API 支持
batch
路由。 - 尽可能使用缓存。
- 最后,如果上述 none 可行,请考虑限制 API 调用。
异步节流的标准模式是使用 SemaphoreSlim
,如下所示:
using var throttler = new SemaphoreSlim(10); var userTasks = userIds.Select(async userId => { await throttler.WaitAsync(); try { await GetUserDetailsAsync(userId); } finally { throttler.Release(); } }); var users = await Task.WhenAll(tasks); // users is User[]
同样,只有当您无法进行设计更改以避免一开始就避免数千次 API 调用时,这种限制才是最好的。