Каков должен быть размер у Thread Pool?

    В нашей статье Stream API & ForkJoinPool мы уже рассказывали про возможности изменять размер пула потоков, который мы можем использовать в параллельных обработчиках, использующих Stream API или Fork Join. Надеюсь эта информация вам пригодилась, когда находясь на должности Senior Java Developer, вы смогли увеличить производительность разработанной вами системы, изменив размер пула по умолчанию. Так как наши курсы, в целом, заточены на переход ступеньку выше от джуниора и миддла выше, то часть программы строится исходя из основных вопросов задаваемых на собеседованиях. Один из из которых звучит так: «У вас есть приложение. И есть задача использующая Stream API или Fork Join, которая поддается распараллеливанию. При каких условиях вы можете счесть разумным изменить размер пула потоков заданный по умолчанию? Какой размер вы предложите в этом случае?»

    Можете попробовать ответить на этот вопрос сами, прежде чем читать дальше, чтобы проверить собственную готовность к подобному интервью на данный момент.

    Чтобы теоретические рассуждения подкрепить настоящими цифрами предлагаем погонять небольшой бенчмарк для стандартного метода Arrays.parallelSort(), реализующего разновидность алгоритма merge sort, и исполняемого на ForkJoinPool.commonPool(). Запустим этот алгоритм на одном и том же большом массиве с различными размерами commonPool и проанализируем результаты.



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

    Бенчмарк написан с использованием JMH и выглядит вот так:

    ```
    package ru.klimakov;
    import org.openjdk.jmh.annotations.*;
    import java.util.Arrays;
    import java.util.Random;
    import java.util.concurrent.TimeUnit;
     
    @Fork(1)
    @Warmup(iterations = 10)
    @Measurement(iterations = 5)
    @BenchmarkMode(Mode.AverageTime )
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public class MyBenchmark {
    	@State(Scope.Benchmark)
    	public static class BenchmarkState {
        	public static final int SEED = 42;
        	public static final int ARRAY_LENGTH = 1_000_000;
        	public static final int BOUND = 100_000;
        	volatile long[] array;
     
        	@Setup
        	public void initState() {
            	Random random = new Random(SEED);
            	this.array = new long[ARRAY_LENGTH];
            	for (int i = 0; i < this.array.length; i++) {
                	this.array[i] = random.nextInt(BOUND);
            	}
        	}
    	}
     
    	@Benchmark
    	public long[] defaultParallelSort(BenchmarkState state) {
        	Arrays.parallelSort(state.array);
        	return state.array;
    	}
     
    	@Benchmark
    	public long[] twoThreadsParallelSort(BenchmarkState state) {
        	System.setProperty(
                	"java.util.concurrent.ForkJoinPool.common.parallelism", "2");
        	Arrays.parallelSort(state.array);
        	return state.array;
    	}
     
    	@Benchmark
    	public long[] threeThreadsParallelSort(BenchmarkState state) {
        	System.setProperty(
              	  "java.util.concurrent.ForkJoinPool.common.parallelism", "3");
        	Arrays.parallelSort(state.array);
        	return state.array;
    	}
     
    	@Benchmark
    	public long[] fourThreadsParallelSort(BenchmarkState state) {
        	System.setProperty(
                    "java.util.concurrent.ForkJoinPool.common.parallelism", "4");
        	Arrays.parallelSort(state.array);
        	return state.array;
    	}
     
    	@Benchmark
    	public long[] fiveThreadsParallelSort(BenchmarkState state) {
        	System.setProperty(
                    "java.util.concurrent.ForkJoinPool.common.parallelism", "5");
        	Arrays.parallelSort(state.array);
        	return state.array;
    	}
     
    	@Benchmark
    	public long[] tooLargePoolParallelSort(BenchmarkState state) {
        	System.setProperty(
                    "java.util.concurrent.ForkJoinPool.common.parallelism", "128");
        	Arrays.parallelSort(state.array);
        	return state.array;
    	}
     
     
     
    	@Benchmark
    	public long[] singleThreadParallelSort(BenchmarkState state) {
        	System.setProperty(
                    "java.util.concurrent.ForkJoinPool.common.parallelism", "1");
        	Arrays.parallelSort(state.array);
        	return state.array;
    	}
     
    	@Benchmark
    	public long[] serialSort(BenchmarkState state) {
        	Arrays.sort(state.array);
        	return state.array;
    	}
    }
    ```

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



    Итак победителем рейтинга оказался fourThreadsParallelSort. В этом тесте мы задали размер пула равный количеству ядер на моей машине, а следовательно на единичку больше, чем дефолтный пул. Тем не менее, факт победы данного микро-бенчмарка не означает, что вам следует в своем приложении определять размер commonPool равным количеству ядер, а не использовать на один воркер меньше, как нам предлагают разработчики JDK. Мы бы предложили принять их точку зрения и для большинства приложений никогда не менять значение размера commonPool, однако если мы хотим выжать из машины максимум и готовы более общими макро-бенчмарками доказать что овчинка стоит выделки, то можем получить небольшой выигрыш в более полной утилизации CPU, установив commonPool равным количеству ядер. При этом не забудьте, что системные потоки, например Garbage Collector, станут конкурировать с вашим пулом за процессорное время и планировщик операционной системы станет прерывать ваши потоки делая переключения контекста.

    defaultParallelSort – оказался на втором месте. Размер пула — 3 потока. В процессе выполнения наблюдалась загрузка CPU около 70%, в отличии от 90% в предыдущем случае. Следовательно оказались не верны рассуждения о том, что основной поток программы из которого мы вызываем параллельную сортировку станет полноценным воркером разгребающим задачи fork join.

    fiveThreadsParallelSort – показал нам, как мы начинаем терять эффективность установив размер пула всего на единичку больше количества ядер. Этот тест немного проиграл дефолтному, при этом утилизация CPU оказалась около 95%.

    tooLargePoolParallelSort – показал что если и дальше увеличивать размер пула (в данном случае до 128 потоков), то и дальше будем терять эффективность и еще плотнее утилизировать процессор (около 100%).

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

    Вы можете спросить, а что же с serialSort и singleThreadParallelSort? А ничего. Их просто нельзя сравнивать с остальными вариантами, так там внутри вообще не merge sort, а совсем другой алгоритм — DualPivotQuickSort, и на разных входных данных может показывать результаты, как уступающие тестируемому алгоритму, так и превосходящие их. Можно сказать больше, на большинстве рандомных массивов, которые рассматривались, DualPivotQuickSort значительно обгонял Arrays.parallelSort, и просто повезло сгенерировать в этот раз такой массив, в котором parallelSort показал лучшие результаты. Поэтому и решили именно этот массив включить в бенчмарки, чтобы вы не подумали, что parallelSort вообще никогда не имеет смысла использовать. Иногда имеет, но не забудьте это проверить и доказать хорошими тестами.

    Ну и наконец, давайте попробуем ответить на вопрос: «В каких случаях может быть разумным изменить размер commonPool?». Один случай мы уже рассмотрели — если мы хотим более плотно утилизировать CPU, то иногда может иметь смысл задать размер commonPool равным количеству ядер, а не на единицу меньше, как задано по дефолту. Другим случаем может быть ситуация, когда одновременно с нашим приложением запущены другие, и мы хотим вручную поделить процессорные ядра между приложениями (хотя, кажется, docker для этих целей был бы уместнее). И, наконец, может оказаться так, что задачи, которые мы очень хотим помещать в Fork Join Pool, не достаточно чистые. Если эти задачи позволяют себе не только вычислять и читать/писать в ОЗУ, но и спать, ждать или читать/писать в блокирующий источник ввода/вывода. В таком случае очень не рекомендуется помещать такие задачи в commonPool, ибо они займут драгоценный поток из пула, поток перейдет в режим ожидания, а ядро которое по задумке должно было бы утилизироваться этим потоком станет простаивать. Поэтому для таких немного сонных задач лучше создать отдельный, кастомный ForkJoinPool.

    THE END

    Вопросы и предложения как всегда приветствуются и тут, и на дне открытых дверей. Ждём-с.
    Отус 267,25
    Профессиональные онлайн-курсы для разработчиков
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 7
    • +2

      Это всё-таки не универсальный ответ.


      На CPU-bound задачах лучше утилизировать все ядра, причём, желательно минимизировать количество переключений контекста. Для этого обычно лучше всего подходит количество ядер = количество потоков.


      А для IO-bound задач надо смотреть, что это за IO, какой планировщик используется в системе, и прочее… Вот тут только бенчмарк, заранее что-то сказать нельзя.


      UPD: увидел, что это описано в последнем абзаце, согласен.

      • 0

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

        • 0
          В этом и прелесть commonPool в java — все добропорядочные фреймворки для CPU-bound задач должны использовать именно его, а не создавать дополнительные пулы. Это позволяет снизить количество принудительных переключений контекста.
          • 0

            Я боюсь что это в общем случае невозможно. Во-первых, насколько я помню, common pool появился в 8-ке, а просто ForkJoin — раньше. Так что вполне может существовать кучка фреймворков, написанных иначе.


            А во-вторых, все равно бывают требования, которые противоречат общему пулу. Причем тривиальные — наличие одновременно пакетных и интерактивных задач, например. Или даже просто интерактивных — для них обычно наплевать, что процессор недогружен до 100%, главное чтобы время реакции было хорошим.

      • 0
        А на 2-ядерной машине стандартные параллельные методы вообще имеют смысл?
        • 0
          Конечно имеют. Чтобы убедиться можно добавить в этот бенчмарк копипасту parallelSort из JDK но только строго ту её часть, где merge sort и прогнать эту копипасту на пуле из одного потока, сравнить с пулом из двух.
          • 0
            Я наверно плохо спросил.
            Если commonPool имеет размер N-1, то как поведут себя базовые функции на 2-ядернике?

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

        Самое читаемое