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

515

С 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()));

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

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

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

Matsemann
источник

Ответы:

736

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

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

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

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

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

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

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

Дж. Б. Низет
источник
18
Хороший ответ. Я бы добавил, что если у вас есть огромное количество элементов для обработки, это только увеличивает проблемы координации потоков; распараллеливание может быть полезным только тогда, когда обработка каждого элемента занимает время и распараллеливается.
Уоррен Дью
16
@WarrenDew Я не согласен. Система Fork / Join просто разделит N элементов, например, на 4 части, и обработает эти 4 части последовательно. 4 результата будут уменьшены. Если массив действительно массивный, даже для быстрой обработки единиц, распараллеливание может быть эффективным. Но, как всегда, вы должны измерить.
Дж. Б. Низет
у меня есть коллекция объектов, которые реализуют, Runnableчто я вызываю, start()чтобы использовать их как Threads, это нормально, чтобы изменить это на использование потоков Java 8 в .forEach()распараллеленном? Тогда я смогу удалить код потока из класса. Но есть ли минусы?
Ycomp
1
@JBNizet Если 4 части работают последовательно, то нет никакой разницы в том, что это параллели процесса или вы знаете, последовательно?
Просьба
3
@Harshana он, очевидно, означает, что элементы каждой из 4 частей будут обрабатываться последовательно. Однако сами части могут обрабатываться одновременно. Другими словами, если у вас есть несколько доступных процессорных ядер, каждая часть может работать на своем собственном ядре независимо от других частей, одновременно обрабатывая свои собственные элементы. (ПРИМЕЧАНИЕ: я не знаю, если именно так работают параллельные потоки Java, я просто пытаюсь уточнить, что имел в виду JBNizet.)
завтра,
258

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

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

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

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

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

(A) вы знаете, что на самом деле есть преимущества для повышения производительности и

(B) что он на самом деле доставит повышенную производительность.

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

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

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

Брайан Гетц
источник
18
Этот пост дает дополнительную информацию о модели NQ: gee.cs.oswego.edu/dl/html/StreamParallelGuidance.html
Пино,
4
@specializt: переключение потока с последовательного на параллельный действительно меняет алгоритм (в большинстве случаев). Упомянутый здесь детерминизм касается свойств, на которые могут полагаться ваши (произвольные) операторы (реализация Stream не может этого знать), но, конечно, не следует полагаться на них. Вот что этот раздел этого ответа пытался сказать. Если вы заботитесь о правилах, вы можете получить детерминированный результат, как вы говорите (в противном случае параллельные потоки были бы совершенно бесполезны), но есть также возможность преднамеренно разрешенного недетерминизма, как при использовании findAnyвместо findFirst
Хольгер,
4
«Во-первых, обратите внимание, что параллелизм не дает никаких преимуществ, кроме возможности более быстрого выполнения, когда доступно больше ядер» - или если вы применяете действие, которое включает IO (например myListOfURLs.stream().map((url) -> downloadPage(url))...).
Жюль
6
@Pacerier Это хорошая теория, но, к сожалению, наивная (см. Для начала 30-летнюю историю попыток создания автопараллельных компиляторов). Поскольку нецелесообразно угадывать достаточно времени, чтобы не раздражать пользователя, когда мы неминуемо ошибаемся, нужно было просто дать пользователю возможность сказать, что он хочет. Для большинства ситуаций значение по умолчанию (последовательное) является правильным и более предсказуемым.
Брайан Гетц
2
@Jules: никогда не используйте параллельные потоки для ввода-вывода. Они предназначены исключительно для интенсивной работы процессора. Параллельные потоки используются, ForkJoinPool.commonPool()и вы не хотите блокировать задачи, чтобы идти туда.
R2C2
68

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

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

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

Модель NQ :

N x Q > 10000

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

Рам Патра
источник
13

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

edharned
источник
10
Потоки не повторяются, потому что потоки выполняют внутреннюю итерацию вместо внешней. В этом вся причина потоков. Если у вас проблемы с учебой, функциональное программирование может быть не для вас. Функциональное программирование === математика === академическое. И нет, J8-FJ не сломан, просто большинство людей не читают руководство f ******. В документации Java ясно сказано, что это не среда параллельного выполнения. Вот и вся причина всех сплитераторов. Да, это академично, да, это работает, если вы знаете, как его использовать. Да, это должно быть проще использовать пользовательский исполнитель
Kr0e
1
Stream имеет метод iterator (), так что вы можете выполнять итерации по ним, если хотите. Насколько я понимаю, они не реализуют Iterable, потому что вы можете использовать этот итератор только один раз, и никто не мог решить, все ли в порядке.
Трейказ
14
если честно: вся ваша статья выглядит как массивная, тщательно продуманная напыщенная речь - и это в значительной степени сводит на нет ее авторитет ... я бы порекомендовал повторить ее с гораздо менее агрессивным подтекстом, иначе немногие люди действительно потрудятся прочитать ее полностью ... я просто говорю
специалист
Несколько вопросов о вашей статье ... во-первых, почему вы, очевидно, приравниваете сбалансированные древовидные структуры к ориентированным ациклическим графам? Да, сбалансированные деревья - это группы обеспечения доступности баз данных, но так же связаны списки и почти все объектно-ориентированные структуры данных, кроме массивов. Кроме того, когда вы говорите, что рекурсивная декомпозиция работает только на сбалансированных древовидных структурах и, следовательно, не имеет коммерческого значения, как вы обосновываете это утверждение? Мне кажется (по общему признанию, без тщательного изучения вопроса), что он должен работать так же хорошо на структурах данных на основе массива, например ArrayList/ HashMap.
Жюль
1
Эта тема с 2013 года, с тех пор многое изменилось. Этот раздел для комментариев, а не подробных ответов.
edharned
3

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

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

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

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

ruhong
источник
2

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

    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, в результате чего это займет очень много времени.

tkruse
источник