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

С Java 8 и lambdas легко перебирать коллекции как потоки и так же просто использовать параллельный поток. Два примера из документов, второй - с помощью 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()));

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

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

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

Ответ 1

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

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

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

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

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

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

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

Ответ 2

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

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

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

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

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

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

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

Ответ 3

Я смотрел один из presentations Brian Goetz (Java Language Architect и руководство по спецификации для Lambda Expressions). Он подробно объясняет следующие 4 момента, которые следует рассмотреть перед переходом к распараллеливанию:

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

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

Модель NQ:

N x Q > 10000

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

Ответ 4

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

Ответ 5

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

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

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

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

Ответ 6

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

    public static void main(String[] args) {
        // let 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 count to 1 in parallel
    System.out.println(
            IntStream.range(1, 1000_000_000)
                    .parallel()
                    .skip(100)
                    .findFirst()
                    .getAsInt());
}

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

Ответ 7

Я сделал HashMap, содержащий 1.000.000 элементов, и я пытаюсь получить определенное количество элементов через map::get, используя последовательный и паралелл Stream. Чтобы найти хорошее число, когда начать использовать поток параллеллов, я использовал метод бинарного поиска:

public class StreamPerformanceTest {

    @Test
    public void testParalellStream() throws Exception {
        final int repeat = 10;

        final Map<String, Object> map = new HashMap<>();
        // fill hash map with objects
        IntStream.range(0, 1000000).mapToObj(i -> new Object()).forEach(o -> map.put(o.toString(), o));

        int iteration = 0;
        int min = 1;
        int max = 10000;
        while (iteration < 10) {
            final int keyCount = (max + min) / 2;

            final List<String> keys = Rnd.getElements(map.keySet(), keyCount);

            final long sequentialTime = testStream(map, keys, repeat, false);
            final long paralellTime = testStream(map, keys, repeat, true);

            if (sequentialTime > paralellTime) max = keyCount;
            else min = keyCount;
            iteration++;
        }
    }

    private long testStream(final Map<String, Object> map, final Collection<String> keys, final int repeat,
            final boolean paralell) {
        final StopWatch sw = new StopWatch();
        sw.run(() -> {
            Stream<String> stream = keys.stream();
            if (paralell) stream = stream.parallel();
            stream.map(map::get).collect(Collectors.toList());
        }, repeat);
        System.out.println("keyCount: " + keys.size() + ", paralell: " + paralell + " " + sw.toString());
        return sw.getAverageTimeNanos();
    }

}

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