Подтвердить что ты не робот

Parallel.ForEach может вызвать исключение "Out Of Memory", если вы работаете с перечислимым с большим объектом

Я пытаюсь перенести базу данных, в которой изображения хранятся в базе данных, в запись в базе данных, указывающую на файл на жестком диске. Я пытался использовать Parallel.ForEach чтобы ускорить процесс, используя этот метод для запроса данных.

Однако я заметил, что получаю исключение OutOfMemory. Я знаю, что Parallel.ForEach будет запрашивать партию перечислимых элементов, чтобы уменьшить стоимость накладных расходов, если есть такая для интервалов между запросами (так что ваш источник, скорее всего, будет иметь следующую кэшированную запись в памяти, если вместо этого вы выполните сразу несколько запросов интервал их). Проблема связана с тем, что одна из записей, которые я возвращаю, - это байтовый массив размером 1-4 Мб, из-за которого из-за кэширования используется все адресное пространство (программа должна работать в режиме x86, поскольку целевая платформа будет 32-разрядной). машина)

Есть ли способ отключить кэширование или сделать меньше для TPL?


Вот пример программы, чтобы показать проблему. Это должно быть скомпилировано в режиме x86, чтобы показать проблему, если она занимает много времени или не происходит на вашей машине, увеличьте размер массива (я обнаружил, что 1 << 20 занимает около 30 секунд на моей машине и 4 << 20 было почти мгновенно)

class Program
{

    static void Main(string[] args)
    {
        Parallel.ForEach(CreateData(), (data) =>
            {
                data[0] = 1;
            });
    }

    static IEnumerable<byte[]> CreateData()
    {
        while (true)
        {
            yield return new byte[1 << 20]; //1Mb array
        }
    }
}
4b9b3361

Ответ 1

Параметры по умолчанию для Parallel.ForEach работают хорошо, когда задача привязана к ЦП и линейно масштабируется. Когда задача связана с ЦП, все работает отлично. Если у вас есть четырехъядерный процессор и нет других процессов, то Parallel.ForEach использует все четыре процессора. Если у вас есть четырехъядерный процессор и какой-то другой процесс на вашем компьютере использует один полный процессор, то Parallel.ForEach использует примерно три процессора.

Но если задача не привязана к ЦП, тогда Parallel.ForEach продолжает запускать задачи, пытаясь удержать все процессоры. Но независимо от того, сколько задач выполняется параллельно, всегда остается больше неиспользуемой мощности процессора, и поэтому он продолжает создавать задачи.

Как вы можете определить, связана ли ваша задача с CPU? Надеюсь, просто осмотрев его. Если вы факторизуете простые числа, это очевидно. Но другие случаи не столь очевидны. Эмпирический способ определить, связана ли ваша задача с процессором, - ограничить максимальную степень parallelism ParallelOptions.MaximumDegreeOfParallelism и наблюдать за тем, как работает ваша программа. Если ваша задача связана с процессором, вы должны увидеть такой же шаблон в четырехъядерной системе:

  • ParallelOptions.MaximumDegreeOfParallelism = 1: используйте один полный процессор или 25% загрузки процессора.
  • ParallelOptions.MaximumDegreeOfParallelism = 2: используйте два процессора или 50% загрузки процессора.
  • ParallelOptions.MaximumDegreeOfParallelism = 4: используйте все процессоры или 100% использование процессора.

Если он ведет себя так, вы можете использовать параметры Parallel.ForEach по умолчанию и получить хорошие результаты. Линейное использование ЦП означает хорошее планирование задач.

Но если я запустил образец приложения на своем Intel i7, я получаю около 20% загрузки процессора независимо от того, какую максимальную степень parallelism я устанавливаю. Почему это? Так много памяти выделяется, что сборщик мусора блокирует потоки. Приложение привязано к ресурсам, а ресурс - это память.

Аналогично задача, связанная с I/O, которая выполняет длительные запросы к серверу базы данных, также никогда не сможет эффективно использовать все ресурсы центрального процессора, доступные на локальном компьютере. И в таких случаях планировщик задач не может "знать, когда остановиться", запуская новые задачи.

Если ваша задача не связана с ЦП или использование ЦП не масштабируется линейно с максимальной степенью parallelism, тогда вы должны посоветовать Parallel.ForEach не запускать слишком много задач одновременно. Самый простой способ - указать число, которое позволяет некоторым parallelism для перекрытия задач, связанных с I/O-привязкой, но не настолько, чтобы вы подавляли потребность локального компьютера в ресурсах или перенаправляли любые удаленные серверы. Для получения наилучших результатов используются проб и ошибок:

static void Main(string[] args)
{
    Parallel.ForEach(CreateData(),
        new ParallelOptions { MaxDegreeOfParallelism = 4 },
        (data) =>
            {
                data[0] = 1;
            });
}

Ответ 2

Итак, хотя то, что предложил Рик, определенно важный момент, еще одна вещь, о которой я думаю, отсутствует, - это обсуждение partitioning.

Parallel::ForEach будет использовать реализацию по умолчанию Partitioner<T>, которая для IEnumerable<T>, которая не имеет известной длины, будет использовать кусок стратегия разделения. Это означает, что каждый рабочий поток, который Parallel::ForEach собирается использовать для набора данных, будет читать некоторое количество элементов из IEnumerable<T>, которое затем будет обрабатываться только этим потоком (игнорируя сейчас кражу работы). Он делает это, чтобы сэкономить расходы, связанные с постоянным возвратом к источнику, и выделить новую работу и запланировать ее для другого рабочего потока. Итак, обычно это хорошо. Однако, в вашем конкретном сценарии, представьте, что вы на четырехъядерном ядре, и вы установили MaxDegreeOfParallelismот > до 4 потоков для вашей работы, и теперь каждый из них вытаскивает кусок из 100 элементов из вашего IEnumerable<T>. Ну, что 100-400 мегабайт прямо именно для этого конкретного рабочего потока, правильно?

Итак, как вы это решаете? Легко, вы напишите пользовательскую реализацию Partitioner<T>. Теперь, chunking по-прежнему полезно в вашем случае, поэтому вы, вероятно, не хотите идти с одной стратегией разбиения элементов, потому что тогда вы должны ввести накладные расходы со всей необходимой для этого необходимой координацией задач. Вместо этого я бы написал настраиваемую версию, которую вы можете настроить с помощью приложения, пока не найдете оптимальный баланс для своей рабочей нагрузки. Хорошей новостью является то, что при написании такой реализации довольно прямолинейно, вам на самом деле не нужно даже писать ее самостоятельно, потому что команда PFX уже сделала это, и put это в проект параллельных программных образцов.

Ответ 3

В этой проблеме есть все, что связано с разделителями, но не со степенью parallelism. Решением является внедрение пользовательского разделителя данных.

Если набор данных большой, кажется, что моно-реализация TPL гарантирована закончилась память. Это случилось со мной в последнее время (по сути, я работал над этим циклом и обнаружил, что память увеличилась линейно, пока она не дала мне исключение OOM).

После отслеживания проблемы я обнаружил, что по умолчанию mono будет делить перечислитель с использованием класса EnumerablePartitioner. Этот класс имеет поведение в том, что каждый раз, когда он выдаёт данные заданию, это "куски", данные все возрастающим (и неизменным) коэффициентом 2. Итак, первый время, когда задача запрашивает данные, получает кусок размера 1, в следующий раз размер 2 * 1 = 2, в следующий раз 2 * 2 = 4, затем 2 * 4 = 8 и т.д. И т.д. В результате количество данных, переданных задаче, и, следовательно, хранится в памяти одновременно увеличивается с длиной задачи, и если много данных обрабатывается, неизбежно возникает исключение из памяти.

Предположительно, первоначальная причина такого поведения заключается в том, что он хочет избежать каждый поток возвращается несколько раз, чтобы получить данные, но, похоже, основанный на предположении, что все обрабатываемые данные могут вписываться в память (не в случае чтения из больших файлов).

Эту проблему можно избежать с помощью пользовательского разделителя, как указано ранее. Один общий пример того, который просто возвращает данные в каждую задачу по одному элементу за раз:

https://gist.github.com/evolvedmicrobe/7997971

Просто создайте экземпляр этого класса и передайте его в Parallel.For вместо самого перечислимого

Ответ 4

Хотя использование пользовательского разделителя, несомненно, является наиболее "правильным" ответом, более простое решение позволяет сборщику мусора наверстать упущенное. В случае, когда я пытался, я делал повторные вызовы в параллельный цикл for внутри функции. Несмотря на выход из функции каждый раз, объем памяти, используемой программой, продолжал линейно увеличиваться, как описано здесь. Я добавил:

//Force garbage collection.
GC.Collect();
// Wait for all finalizers to complete before continuing.
GC.WaitForPendingFinalizers();

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