Должен ли я всегда использовать параллельный поток, когда это возможно?

С Java 8 и лямбдами легко перебирать коллекции как потоки, и так же просто использовать параллельный поток. Два примера из документов, второй с использованием parallelStream:

myShapesCollection.stream()
    .filter(e -> e.getColor() == Color.RED)
    .forEach(e -> System.out.println(e.getName()));

myShapesCollection.parallelStream() // <-- This one uses parallel
    .filter(e -> e.getColor() == Color.RED)
    .forEach(e -> System.out.println(e.getName()));

Пока меня не волнует порядок, всегда ли будет выгодно использовать параллель? Казалось бы, быстрее разделить работу на большее количество ядер.

Есть ли другие соображения? Когда следует использовать параллельный поток и когда следует использовать непараллельный?

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

7 ответов

Решение

Параллельный поток имеет намного более высокие издержки по сравнению с последовательным. Координация потоков занимает значительное количество времени. Я бы использовал последовательные потоки по умолчанию и только параллельные, если

  • У меня огромное количество элементов для обработки (или обработка каждого элемента занимает много времени и распараллеливается)

  • У меня проблема с производительностью в первую очередь

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

В вашем примере производительность в любом случае будет зависеть от синхронизированного доступа к System.out.println()и параллельный процесс не будет иметь никакого эффекта или даже отрицательный.

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

В любом случае, мера, не угадай! Только измерение покажет вам, стоит ли параллелизм того или нет.

Stream API был разработан, чтобы упростить написание вычислений таким способом, который был абстрагирован от способа их выполнения, что облегчало переключение между последовательным и параллельным.

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

Во-первых, обратите внимание, что параллелизм не дает никаких преимуществ, кроме возможности более быстрого выполнения, когда доступно больше ядер. Параллельное выполнение всегда будет включать в себя больше работы, чем последовательное, потому что в дополнение к решению проблемы, оно также должно выполнять диспетчеризацию и координацию подзадач. Мы надеемся, что вы сможете быстрее получить ответ, разбив работу на несколько процессоров; произойдет ли это на самом деле, зависит от многих вещей, включая размер вашего набора данных, объем вычислений, которые вы выполняете для каждого элемента, характер вычислений (в частности, взаимодействует ли обработка одного элемента с обработкой других?) количество доступных процессоров и число других задач, конкурирующих за эти процессоры.

Кроме того, обратите внимание, что параллелизм также часто выставляет недетерминированность в вычислениях, которые часто скрыты последовательными реализациями; иногда это не имеет значения или может быть смягчено путем ограничения задействованных операций (т. е. операторы сокращения должны быть без сохранения состояния и ассоциативными).

В действительности иногда параллелизм ускоряет ваши вычисления, иногда нет, а иногда даже замедляет их. Лучше всего сначала разрабатывать с использованием последовательного выполнения, а затем применять параллелизм, когда (A) вы знаете, что на самом деле есть преимущества для повышения производительности, и (B) что это действительно приведет к увеличению производительности. (А) это бизнес-проблема, а не техническая. Если вы эксперт по производительности, вы, как правило, сможете посмотреть на код и определить (B), но разумный путь - это измерить. (И даже не беспокойтесь, пока не убедитесь в (A); если код достаточно быстрый, лучше применить свои мозговые циклы в другом месте.)

Простейшей моделью производительности для параллелизма является модель "NQ", где N - количество элементов, а Q - вычисление на элемент. В общем, вам нужно, чтобы продукт NQ превысил некоторое пороговое значение, прежде чем вы начнете получать выигрыш в производительности. Для задачи с низким Q, такой как "сложение чисел от 1 до N", вы обычно увидите безубыточность между N=1000 и N=10000. При проблемах с более высоким Q вы увидите безубыточности при более низких порогах.

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

Я смотрел одну из презентаций Брайана Гетца (ведущий разработчик языка Java и спецификация для лямбда-выражений). Он подробно объясняет следующие 4 момента, которые следует рассмотреть перед переходом к распараллеливанию:

Расходы на разделение / разложение
- Иногда разделение стоит дороже, чем просто работа!
Расходы на диспетчеризацию / управление задачами
- Может выполнять много работы за время, необходимое для ручной работы в другом потоке.
Расходы на комбинирование результатов
- Иногда комбинация включает в себя копирование большого количества данных. Например, добавление чисел дешево, тогда как объединение наборов стоит дорого.
Местонахождение
- Слон в комнате. Это важный момент, который каждый может упустить. Вы должны учитывать пропуски в кеше: если процессор ожидает данные из-за пропусков в кеше, вы ничего не получите от распараллеливания. Вот почему источники на основе массива распараллеливаются лучше всего, так как следующие индексы (рядом с текущим индексом) кэшируются, и вероятность того, что ЦП будет пропускать кэш, будет меньше.

Он также упоминает относительно простую формулу для определения вероятности параллельного ускорения.

Модель NQ:

N x Q > 10000

где,
N = количество элементов данных
Q = объем работы на единицу

JB ударил гвоздь по голове. Единственное, что я могу добавить, - это то, что Java8 не выполняет чисто параллельную обработку, а выполняет параллельную работу. Да, я написал статью и уже тридцать лет занимаюсь F/J, поэтому понимаю проблему.

Никогда не распараллеливайте бесконечный поток с пределом. Вот что происходит:

    public static void main(String[] args) {
        // let's count to 1 in parallel
        System.out.println(
            IntStream.iterate(0, i -> i + 1)
                .parallel()
                .skip(1)
                .findFirst()
                .getAsInt());
    }

Результат

    Exception in thread "main" java.lang.OutOfMemoryError
        at ...
        at java.base/java.util.stream.IntPipeline.findFirst(IntPipeline.java:528)
        at InfiniteTest.main(InfiniteTest.java:24)
    Caused by: java.lang.OutOfMemoryError: Java heap space
        at java.base/java.util.stream.SpinedBuffer$OfInt.newArray(SpinedBuffer.java:750)
        at ...

То же самое, если вы используете .limit(...)

Объяснение здесь: Java 8, использование.parallel в потоке вызывает ошибку OOM

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

public static void main(String[] args) {
    // let's count to 1 in parallel
    System.out.println(
            IntStream.range(1, 1000_000_000)
                    .parallel()
                    .skip(100)
                    .findFirst()
                    .getAsInt());
}

Это может работать намного дольше, потому что параллельные потоки могут работать на множестве диапазонов номеров вместо критического 0-100, в результате чего это займет очень много времени.

Другие ответы уже охватили профилирование, чтобы избежать преждевременной оптимизации и накладных расходов при параллельной обработке. Этот ответ объясняет идеальный выбор структур данных для параллельной потоковой передачи.

Как правило, выигрыш в производительности от параллелизма является лучшим для потоков над ArrayList, HashMap, HashSet, а также ConcurrentHashMap экземпляры; массивы; int диапазоны; а также long диапазоны. Общим для этих структур данных является то, что все они могут быть точно и дешево разбиты на поддиапазоны любых желаемых размеров, что позволяет легко распределять работу между параллельными потоками. Абстракция, используемая библиотекой потоков для выполнения этой задачи, является сплитератором, который возвращается spliterator метод на Stream а также Iterable,

Другим важным фактором, который объединяет все эти структуры данных, является то, что они обеспечивают отличное расположение ссылок при последовательной обработке: последовательные ссылки на элементы хранятся вместе в памяти. Объекты, на которые ссылаются эти ссылки, могут не находиться близко друг к другу в памяти, что уменьшает локальность ссылок. Ссылочная локальность оказывается критически важной для распараллеливания массовых операций: без нее потоки проводят большую часть своего времени в режиме ожидания, ожидая передачи данных из памяти в кэш процессора. Структуры данных с наилучшим местоположением ссылок являются примитивными массивами, потому что сами данные хранятся непрерывно в памяти.

Источник: № 48. Будьте осторожны при создании параллельных, эффективных потоков Java 3e. Автор - Joshua Bloch

это отличный способ сделать работу параллельно. Однако вы должны иметь в виду, что это эффективно использует общий пул потоков только с несколькими внутренними рабочими потоками (количество потоков равно количеству ядер процессора по умолчанию), см. . Если некоторые из задач пула являются длительной работой, связанной с вводом-выводом, то другие, потенциально быстрые, вызовы застревают в ожидании свободных потоков пула. Это, очевидно, приводит к тому, что задачи fork-join должны быть неблокирующими и короткими или, другими словами, привязанными к процессору. Для лучшего понимания деталей настоятельно рекомендую внимательно прочитать javadoc, вот несколько соответствующих цитат:

Эффективность ForkJoinTasks проистекает из... их основного использования в качестве вычислительных задач, вычисляющих чистые функции или работающих с чисто изолированными объектами.

Вычисления в идеале должны избегать синхронизированных методов или блоков и должны сводить к минимуму другую блокировку синхронизации.

Разделяемые задачи также не должны выполнять блокирующий ввод-вывод.

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

Другие вопросы по тегам