Task.WhenAll with Select - это ножное ружье - но почему? - PullRequest
7 голосов
/ 20 июня 2020

Учтите: у вас есть набор идентификаторов пользователей, и вы хотите загрузить сведения о каждом пользователе, представленном их идентификатором, из API. Вы хотите собрать всех этих пользователей в некую коллекцию и отправить ее обратно вызывающему коду. И вы хотите использовать LINQ.

Примерно так:

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

Это было хорошо для моего приложения, когда у меня было относительно мало пользователей. Но наступил момент, когда он не масштабировался. Когда дело дошло до тысяч пользователей, это привело к одновременному запуску тысяч HTTP-запросов и начали происходить неприятные вещи . Мы не только осознали, что запускаем атаку отказа в обслуживании на API, который мы использовали, но и доводили наше собственное приложение до точки коллапса из-за нехватки потоков.

Не счастливый день.

Как только мы поняли, что причиной наших бед была комбинация Task.WhenAll / Select, мы смогли отойти от этого шаблона. Но у меня такой вопрос:

Что здесь происходит не так?

Когда я читал топи c, этот сценарий, кажется, хорошо описан в # 6 на Список асинхронных алгоритмов Марка Хита c антипаттерны : «Чрезмерное распараллеливание»:

Теперь это «работает», но что, если бы было 10 000 заказов? Мы заполнили пул потоков тысячами задач, что может помешать выполнению другой полезной работы. Если ProcessOrderAsyn c выполняет нисходящие вызовы другой службы, такой как база данных или микросервис, мы потенциально перегрузим ее слишком большим объемом вызовов.

Это действительно причина? Я спрашиваю, поскольку мое понимание async / await становится менее ясным, чем больше я читаю о топи c. Из многих частей ясно, что «потоки - это не задачи». Это круто, но мой код, похоже, исчерпывает количество потоков, которые может обрабатывать ASP. NET Core.

Так вот что? Моя комбинация Task.WhenAll и Select исчерпывает пул потоков или что-то подобное? Или есть другое объяснение этому, о котором я не знаю?

Обновление:

Я превратил этот вопрос в сообщение в блоге с немного более подробной информацией / вафлей. Вы можете найти его здесь: https://blog.johnnyreilly.com/2020/06/taskwhenall-select-is-footgun.html

Ответы [ 3 ]

5 голосов
/ 20 июня 2020

Проблема N + 1

Помещение потоков, задач, асинхронности c, параллелизма в одну сторону, то, что вы описываете, является проблемой N + 1, которой следует избегать именно того, что случилось с вами. Все хорошо, когда N (ваше количество пользователей) мало, но оно останавливается по мере роста пользователей.

Возможно, вы захотите найти другое решение. Вы должны делать эту операцию для всех пользователей? Если да, то, возможно, переключитесь на фоновый процесс и разветвление для каждого пользователя.

Вернемся к ножному ружью (мне пришлось искать это, кстати BT).

Задачи - это обещания, аналогичные JavaScript. В. NET они могут завершаться в отдельном потоке - обычно потоке из пула потоков.

В. NET Core, они обычно завершаются в отдельном потоке, если не завершены, и точка ожидания , для HTTP-запроса, который почти наверняка так и будет.

Возможно, вы исчерпали пул потоков, но, поскольку вы делаете HTTP-запросы, я подозреваю, что вы исчерпали количество одновременных исходящих HTTP-запросов вместо. «По умолчанию ограничение на количество подключений составляет 10 для ASP. NET размещенных приложений и 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);
}

Вы все равно получаете десять асинхронных HTTP-запросов на GetUserDetailsAsync(), и вы не исчерпываете потоки или одновременные HTTP-запросы (или, по крайней мере, максимально используете 10).

Теперь, если это интенсивно используемая операция или сервер с GetUserDetailsAsync() активно используется в другом месте приложения вы можете достичь тех же пределов, когда ваша система находится под нагрузкой, поэтому такая группировка не всегда является хорошей идеей. YMMV.

4 голосов
/ 23 июня 2020

У вас уже есть отличный ответ, но просто для разговора:

Нет проблем с созданием тысяч задач. Это не потоки.

Основная проблема в том, что вы слишком сильно используете API. Итак, лучшие решения изменят способ вызова этого API:

  1. Вам действительно нужны данные о пользователях для тысяч пользователей одновременно? Если это для отображения панели мониторинга, измените свой API, чтобы принудительно разбить на страницы; если это пакетный процесс, то посмотрите, можете ли вы получить доступ к данным непосредственно из пакетного процесса.
  2. Используйте маршрут batch для этого API, если он его поддерживает.
  3. Использовать кеширование если возможно.
  4. Наконец, если ничего из вышеперечисленного невозможно, рассмотрите возможность регулирования вызовов 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 в первую очередь.

1 голос
/ 20 июня 2020

Пока нет потока , ожидающего операции asyn c, если операция asyn c чистая, есть поток для продолжения, поэтому предполагая, что ваш GetUserDetailsAsync будет ожидать некоторого ввода-вывода -связанная операция, продолжение (анализ вывода, возврат результата ...) необходимо будет запустить в каком-то потоке, чтобы можно было установить ваш Task.Result, созданный GetUserDetailsAsync, поэтому каждый из них будет ждать потока из потока бассейн до финиша sh.

...